Compare commits
127
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd62829f77 | ||
|
|
20399bbdfe | ||
|
|
547a975707 | ||
|
|
c009cab15b | ||
|
|
e6bc3b253b | ||
|
|
f1a13f25a4 | ||
|
|
4695e685cf | ||
|
|
c6092e4ad9 | ||
|
|
e6045ca925 | ||
|
|
ebbb4dd88a | ||
|
|
087473be6e | ||
|
|
e0eb460fc8 | ||
|
|
7c6b3f981e | ||
|
|
a5b6c57a76 | ||
|
|
65724b693b | ||
|
|
8b9a85b7e7 | ||
|
|
7cbec9a1a7 | ||
|
|
c4ba5961c8 | ||
|
|
82b432349e | ||
|
|
68ed664a3f | ||
|
|
3a30773874 | ||
|
|
0c0057a7de | ||
|
|
fa79736b87 | ||
|
|
2e0c2c9db7 | ||
|
|
3e9366487a | ||
|
|
025ed04da0 | ||
|
|
b81eca4ebc | ||
|
|
20c18689c0 | ||
|
|
a803cf8aee | ||
|
|
c526e2d908 | ||
|
|
bdbbcd8a0c | ||
|
|
43c2da24d0 | ||
|
|
3205db9c16 | ||
|
|
efbab087df | ||
|
|
9280db3297 | ||
|
|
0cc3c3bc78 | ||
|
|
ee8b38ab26 | ||
|
|
44fa3d5392 | ||
|
|
a457828a67 | ||
|
|
4752c83155 | ||
|
|
f94ee5ce90 | ||
|
|
76386f5cfc | ||
|
|
a9275def43 | ||
|
|
50ed4c6b5d | ||
|
|
f882cca98a | ||
|
|
2d2a044961 | ||
|
|
b41fbda68f | ||
|
|
794c5981a5 | ||
|
|
d5738f542c | ||
|
|
02b7eb59f8 | ||
|
|
a8f23fb548 | ||
|
|
1a642a79a6 | ||
|
|
b6b0097755 | ||
|
|
afb1cad26d | ||
|
|
58186004db | ||
|
|
a593ed4c9b | ||
|
|
449270aacc | ||
|
|
e20535655b | ||
|
|
1662e149b3 | ||
|
|
dfe3e79304 | ||
|
|
e92a2ec9db | ||
|
|
7cba1ff793 | ||
|
|
8c3cc0d447 | ||
|
|
e30562d5f4 | ||
|
|
7c06ef2477 | ||
|
|
58eccf7f53 | ||
|
|
6da60bd5d9 | ||
|
|
8a43c24934 | ||
|
|
b03172d723 | ||
|
|
e342795bd0 | ||
|
|
a44d4acb3f | ||
|
|
8b8a358de1 | ||
|
|
075fa2c0e8 | ||
|
|
8b287caaad | ||
|
|
b3e6b7a985 | ||
|
|
5c74bff8e1 | ||
|
|
172bbdaced | ||
|
|
563b4c33f2 | ||
|
|
982b71e861 | ||
|
|
75df5040ea | ||
|
|
f2b2940298 | ||
|
|
22f51c6b47 | ||
|
|
de286b08f6 | ||
|
|
0f2124db32 | ||
|
|
e30a159264 | ||
|
|
a5edf3a311 | ||
|
|
7c2907cbb4 | ||
|
|
bce9dc040f | ||
|
|
44297ffe78 | ||
|
|
cbb3141130 | ||
|
|
18cf4df6c6 | ||
|
|
f3e8a275b8 | ||
|
|
7df36cf0fa | ||
|
|
e82b112759 | ||
|
|
030b14ac4e | ||
|
|
b1e381cff7 | ||
|
|
0433d4d064 | ||
|
|
d34fdac854 | ||
|
|
ec828619ca | ||
|
|
559013e124 | ||
|
|
8e3ab4afa7 | ||
|
|
13305966e5 | ||
|
|
a618fbe8cf | ||
|
|
07dc1f8ecc | ||
|
|
445c8631a2 | ||
|
|
c4eacd0ccf | ||
|
|
dd5ec26c8c | ||
|
|
8b062ed621 | ||
|
|
ab97a95032 | ||
|
|
a98d108d2e | ||
|
|
2e875b2d65 | ||
|
|
790baec41f | ||
|
|
52fbd16e08 | ||
|
|
cf97633d7d | ||
|
|
3fe2e89d55 | ||
|
|
eb5c113cff | ||
|
|
bd9c13bb27 | ||
|
|
3019b1f825 | ||
|
|
119cc8c795 | ||
|
|
be9b2bab15 | ||
|
|
c949e5b390 | ||
|
|
1c717d62e4 | ||
|
|
27675dfd70 | ||
|
|
374275eeb6 | ||
|
|
faa848cfb1 | ||
|
|
80772e5ac2 | ||
|
|
be34747526 |
@@ -0,0 +1,3 @@
|
|||||||
|
### What does this PR do?
|
||||||
|
|
||||||
|
### How did you verify your code works?
|
||||||
@@ -9,6 +9,13 @@ on:
|
|||||||
- "nix/**"
|
- "nix/**"
|
||||||
- "packages/app/**"
|
- "packages/app/**"
|
||||||
- "packages/desktop/**"
|
- "packages/desktop/**"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "flake.nix"
|
||||||
|
- "flake.lock"
|
||||||
|
- "nix/**"
|
||||||
|
- "packages/app/**"
|
||||||
|
- "packages/desktop/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ jobs:
|
|||||||
|
|
||||||
publish-tauri:
|
publish-tauri:
|
||||||
needs: publish
|
needs: publish
|
||||||
continue-on-error: true
|
continue-on-error: false
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ target
|
|||||||
# Local dev files
|
# Local dev files
|
||||||
opencode-dev
|
opencode-dev
|
||||||
logs/
|
logs/
|
||||||
|
*.bun-build
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
|||||||
scoop bucket add extras; scoop install extras/opencode # Windows
|
scoop bucket add extras; scoop install extras/opencode # Windows
|
||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
||||||
brew install opencode # macOS and Linux (official brew formula, updated less frequently)
|
brew install opencode # macOS and Linux (official brew formula, updated less)
|
||||||
paru -S opencode-bin # Arch Linux
|
paru -S opencode-bin # Arch Linux
|
||||||
mise use -g opencode # Any OS
|
mise use -g opencode # Any OS
|
||||||
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
||||||
|
|||||||
@@ -195,3 +195,7 @@
|
|||||||
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
|
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
|
||||||
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
|
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
|
||||||
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
|
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
|
||||||
|
| 2026-01-09 | 2,443,565 (+170,935) | 1,469,451 (+36,971) | 3,913,016 (+207,906) |
|
||||||
|
| 2026-01-10 | 2,632,023 (+188,458) | 1,503,670 (+34,219) | 4,135,693 (+222,677) |
|
||||||
|
| 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) |
|
||||||
|
|||||||
+65
-2
@@ -4,8 +4,71 @@
|
|||||||
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
|
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
|
||||||
= obj` just reference it as obj.a and obj.b. this preserves context
|
= obj` just reference it as obj.a and obj.b. this preserves context
|
||||||
- AVOID `try`/`catch` where possible
|
- AVOID `try`/`catch` where possible
|
||||||
- AVOID `else` statements
|
|
||||||
- AVOID using `any` type
|
- AVOID using `any` type
|
||||||
- AVOID `let` statements
|
|
||||||
- PREFER single word variable names where possible
|
- PREFER single word variable names where possible
|
||||||
- Use as many bun apis as possible like Bun.file()
|
- Use as many bun apis as possible like Bun.file()
|
||||||
|
|
||||||
|
# Avoid let statements
|
||||||
|
|
||||||
|
we don't like let statements, especially combined with if/else statements.
|
||||||
|
prefer const
|
||||||
|
|
||||||
|
This is bad:
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const foo = condition ? 1 : 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let foo
|
||||||
|
|
||||||
|
if (condition) foo = 1
|
||||||
|
else foo = 2
|
||||||
|
```
|
||||||
|
|
||||||
|
# Avoid else statements
|
||||||
|
|
||||||
|
Prefer early returns or even using `iife` to avoid else statements
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function foo() {
|
||||||
|
if (condition) return 1
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function foo() {
|
||||||
|
if (condition) return 1
|
||||||
|
else return 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Prefer single word naming
|
||||||
|
|
||||||
|
Try your best to find a single word name for your variables, functions, etc.
|
||||||
|
Only use multiple words if you cannot.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const foo = 1
|
||||||
|
const bar = 2
|
||||||
|
const baz = 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const fooBar = 1
|
||||||
|
const barBaz = 2
|
||||||
|
const bazFoo = 3
|
||||||
|
```
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"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.7",
|
"version": "1.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -87,6 +87,7 @@
|
|||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"nitro": "3.0.1-alpha.1",
|
"nitro": "3.0.1-alpha.1",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
|
"solid-list": "0.3.0",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"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",
|
||||||
@@ -125,7 +126,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"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",
|
||||||
@@ -149,7 +150,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"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",
|
||||||
@@ -173,7 +174,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -202,7 +203,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -231,7 +232,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -247,7 +248,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -286,8 +287,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.70",
|
"@opentui/core": "0.1.72",
|
||||||
"@opentui/solid": "0.1.70",
|
"@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",
|
||||||
@@ -350,7 +351,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -370,7 +371,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.88.1",
|
"@hey-api/openapi-ts": "0.88.1",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -381,7 +382,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -394,7 +395,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -433,7 +434,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -444,7 +445,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1201,21 +1202,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.70", "", { "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.70", "@opentui/core-darwin-x64": "0.1.70", "@opentui/core-linux-arm64": "0.1.70", "@opentui/core-linux-x64": "0.1.70", "@opentui/core-win32-arm64": "0.1.70", "@opentui/core-win32-x64": "0.1.70", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6cPAlbCnaiUUtQtvZNpkr0Xv8AdVAgJuy2VAwIsDN1pIv0zMpa0ZG+mr7afCGygw1eeDRveefrjfgFAB1r0SVw=="],
|
"@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.70", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rM8EnvW1tOAXWnp2Iy2M82I+ViSmRwUagx3v1/ni6N8GCcw/3mE0C6eB3sVlYNXVMwBEgiKpWFn85RCe4+qXQw=="],
|
"@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.70", "", { "os": "darwin", "cpu": "x64" }, "sha512-XdBgW+em8J+YGSUpaKF8/NxPjikJygK3dIkeMAw5xQ2lt7jXKxeM5MMmN/V4MfK3pLMtO56rLJlXaLH/h50uQA=="],
|
"@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.70", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSVWNMSOx0Na0M0LCqtWCxeh4SuLSK5lg8ZwVzsEoimIAxh0snp9nRUo/Qi8yD9BP0DSDmXuM/B3ONtzFaf0dw=="],
|
"@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.70", "", { "os": "linux", "cpu": "x64" }, "sha512-WUrhukefMghcZ7sAjkxEy50vA6ii0X21xh7m8c4omXyYYfQXyDs25pNExB8cwoCrZEaC8RTlF4lRSNPIXsZKhA=="],
|
"@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.70", "", { "os": "win32", "cpu": "arm64" }, "sha512-p1K2VJXGmZqSV7mR61v7KJpT1Zth7DS99wEtaqqfK68OWt33K2XxLmGO0KD142R2JLfXu32NnRmBHxmVx8IjBA=="],
|
"@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.70", "", { "os": "win32", "cpu": "x64" }, "sha512-G6b8te1twMeDhjg1oZa0IcUjhOJZFCSdlQt+q5gu5vVtjCrIwAn9o7m5EwNMPakc31pDWUZ7v0ktgv0Xw1AQVA=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.1.70", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.70", "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-8Cw/w4Of2OJhsFhcp/Wdj8cJRVaGvVsIiUoYiFtyToM01J4en0bg/vnbeZteyuZWeEtA4iz1/rSEQf7Dp+2FIQ=="],
|
"@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=="],
|
||||||
|
|
||||||
|
|||||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767364772,
|
"lastModified": 1767966113,
|
||||||
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
|
"narHash": "sha256-mSTsvXa4WveSRJexsmCbm9dY17B1fKp7NLpJxllpQw4=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
|
"rev": "5f02c91314c8ba4afe83b256b023756412218535",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": "sha256-KjBAaI9Kv6huOmPvUbtyYsMhbScI91w1lOZyXpIWqI0="
|
"nodeModules": "sha256-aIP+P0hSnQ50+lhnKvfRzQ8DlOhLjrrwarckxI3LBmE="
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-30
@@ -14,36 +14,7 @@
|
|||||||
<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 -->
|
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
||||||
<script id="oc-theme-preload-script">
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
;(function () {
|
|
||||||
var themeId = localStorage.getItem("opencode-theme-id")
|
|
||||||
if (!themeId) return
|
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
|
||||||
var mode = isDark ? "dark" : "light"
|
|
||||||
|
|
||||||
document.documentElement.dataset.theme = themeId
|
|
||||||
document.documentElement.dataset.colorScheme = mode
|
|
||||||
|
|
||||||
if (themeId === "oc-1") return
|
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
|
||||||
if (css) {
|
|
||||||
var style = document.createElement("style")
|
|
||||||
style.id = "oc-theme-preload"
|
|
||||||
style.textContent =
|
|
||||||
":root{color-scheme:" +
|
|
||||||
mode +
|
|
||||||
";--text-mix-blend-mode:" +
|
|
||||||
(isDark ? "plus-lighter" : "multiply") +
|
|
||||||
";" +
|
|
||||||
css +
|
|
||||||
"}"
|
|
||||||
document.head.appendChild(style)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
;(function () {
|
||||||
|
var themeId = localStorage.getItem("opencode-theme-id")
|
||||||
|
if (!themeId) return
|
||||||
|
|
||||||
|
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||||
|
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
|
var mode = isDark ? "dark" : "light"
|
||||||
|
|
||||||
|
document.documentElement.dataset.theme = themeId
|
||||||
|
document.documentElement.dataset.colorScheme = mode
|
||||||
|
|
||||||
|
if (themeId === "oc-1") return
|
||||||
|
|
||||||
|
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||||
|
if (css) {
|
||||||
|
var style = document.createElement("style")
|
||||||
|
style.id = "oc-theme-preload"
|
||||||
|
style.textContent =
|
||||||
|
":root{color-scheme:" +
|
||||||
|
mode +
|
||||||
|
";--text-mix-blend-mode:" +
|
||||||
|
(isDark ? "plus-lighter" : "multiply") +
|
||||||
|
";" +
|
||||||
|
css +
|
||||||
|
"}"
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
})()
|
||||||
+22
-27
@@ -33,22 +33,10 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
|
__OPENCODE__?: { updaterEnabled?: boolean }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultServerUrl = iife(() => {
|
|
||||||
const param = new URLSearchParams(document.location.search).get("url")
|
|
||||||
if (param) return param
|
|
||||||
|
|
||||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
|
||||||
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
|
||||||
|
|
||||||
return window.location.origin
|
|
||||||
})
|
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps) {
|
export function AppBaseProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
@@ -77,9 +65,18 @@ function ServerKey(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppInterface() {
|
export function AppInterface(props: { defaultUrl?: string }) {
|
||||||
|
const defaultServerUrl = () => {
|
||||||
|
if (props.defaultUrl) return props.defaultUrl
|
||||||
|
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
|
if (import.meta.env.DEV)
|
||||||
|
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
return window.location.origin
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultUrl={defaultServerUrl}>
|
<ServerProvider defaultUrl={defaultServerUrl()}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
@@ -108,18 +105,16 @@ export function AppInterface() {
|
|||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
<Route
|
<Route
|
||||||
path="/session/:id?"
|
path="/session/:id?"
|
||||||
component={(p) => (
|
component={() => (
|
||||||
<Show when={p.params.id ?? "new"} keyed>
|
<TerminalProvider>
|
||||||
<TerminalProvider>
|
<FileProvider>
|
||||||
<FileProvider>
|
<PromptProvider>
|
||||||
<PromptProvider>
|
<Suspense fallback={<Loading />}>
|
||||||
<Suspense fallback={<Loading />}>
|
<Session />
|
||||||
<Session />
|
</Suspense>
|
||||||
</Suspense>
|
</PromptProvider>
|
||||||
</PromptProvider>
|
</FileProvider>
|
||||||
</FileProvider>
|
</TerminalProvider>
|
||||||
</TerminalProvider>
|
|
||||||
</Show>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -7,15 +7,11 @@ import { createMemo, createSignal, For, Show } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
function getFilename(input: string) {
|
|
||||||
const parts = input.split("/")
|
|
||||||
return parts[parts.length - 1] || input
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogEditProject(props: { project: LocalProject }) {
|
export function DialogEditProject(props: { project: LocalProject }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Component, createMemo } from "solid-js"
|
||||||
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
|
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
|
|
||||||
|
interface ForkableMessage {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DialogFork: Component = () => {
|
||||||
|
const params = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const messages = createMemo((): ForkableMessage[] => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return []
|
||||||
|
|
||||||
|
const msgs = sync.data.message[sessionID] ?? []
|
||||||
|
const result: ForkableMessage[] = []
|
||||||
|
|
||||||
|
for (const message of msgs) {
|
||||||
|
if (message.role !== "user") continue
|
||||||
|
|
||||||
|
const parts = sync.data.part[message.id] ?? []
|
||||||
|
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
||||||
|
if (!textPart) continue
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: message.id,
|
||||||
|
text: textPart.text.replace(/\n/g, " ").slice(0, 200),
|
||||||
|
time: formatTime(new Date(message.time.created)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.reverse()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelect = (item: ForkableMessage | undefined) => {
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
const parts = sync.data.part[item.id] ?? []
|
||||||
|
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||||
|
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
|
||||||
|
if (!forked.data) return
|
||||||
|
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
prompt.set(restored)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title="Fork from message">
|
||||||
|
<List
|
||||||
|
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
|
||||||
|
search={{ placeholder: "Search", autofocus: true }}
|
||||||
|
emptyMessage="No messages to fork from"
|
||||||
|
key={(x) => x.id}
|
||||||
|
items={messages}
|
||||||
|
filterKeys={["text"]}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<div class="w-full flex items-center gap-2">
|
||||||
|
<span class="truncate flex-1 min-w-0 text-left" style={{ "font-weight": "400" }}>
|
||||||
|
{item.text}
|
||||||
|
</span>
|
||||||
|
<span class="text-text-weak shrink-0" style={{ "font-weight": "400" }}>
|
||||||
|
{item.time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export function DialogSelectFile() {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
|
const view = createMemo(() => layout.view(sessionKey()))
|
||||||
return (
|
return (
|
||||||
<Dialog title="Select file">
|
<Dialog title="Select file">
|
||||||
<List
|
<List
|
||||||
@@ -27,7 +28,7 @@ export function DialogSelectFile() {
|
|||||||
const value = file.tab(path)
|
const value = file.tab(path)
|
||||||
tabs().open(value)
|
tabs().open(value)
|
||||||
file.load(path)
|
file.load(path)
|
||||||
layout.review.open()
|
view().reviewPanel.open()
|
||||||
}
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const ModelSelectorPopover: Component<{
|
|||||||
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
||||||
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
|
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
|
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
|
||||||
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
|
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
|
||||||
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
|
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
|
||||||
</Kobalte.Content>
|
</Kobalte.Content>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -35,6 +35,8 @@ export function DialogSelectServer() {
|
|||||||
error: "",
|
error: "",
|
||||||
status: {} as Record<string, ServerStatus | undefined>,
|
status: {} as Record<string, ServerStatus | undefined>,
|
||||||
})
|
})
|
||||||
|
const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
|
||||||
|
const isDesktop = platform.platform === "desktop"
|
||||||
|
|
||||||
const items = createMemo(() => {
|
const items = createMemo(() => {
|
||||||
const current = server.url
|
const current = server.url
|
||||||
@@ -173,6 +175,53 @@ export function DialogSelectServer() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={isDesktop}>
|
||||||
|
<div class="mt-6 px-3 flex flex-col gap-1.5">
|
||||||
|
<div class="px-3">
|
||||||
|
<h3 class="text-14-regular text-text-weak">Default server</h3>
|
||||||
|
<p class="text-12-regular text-text-weak mt-1">
|
||||||
|
Connect to this server on app launch instead of starting a local server. Requires restart.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2">
|
||||||
|
<Show
|
||||||
|
when={defaultUrl()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={server.url}
|
||||||
|
fallback={<span class="text-14-regular text-text-weak">No server selected</span>}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
await platform.setDefaultServerUrl?.(server.url)
|
||||||
|
defaultUrlActions.refetch(server.url)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set current server as default
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
await platform.setDefaultServerUrl?.(null)
|
||||||
|
defaultUrlActions.refetch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
@@ -386,6 +387,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const {
|
const {
|
||||||
flat: atFlat,
|
flat: atFlat,
|
||||||
active: atActive,
|
active: atActive,
|
||||||
|
setActive: setAtActive,
|
||||||
onInput: atOnInput,
|
onInput: atOnInput,
|
||||||
onKeyDown: atOnKeyDown,
|
onKeyDown: atOnKeyDown,
|
||||||
} = useFilteredList<AtOption>({
|
} = useFilteredList<AtOption>({
|
||||||
@@ -452,6 +454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const {
|
const {
|
||||||
flat: slashFlat,
|
flat: slashFlat,
|
||||||
active: slashActive,
|
active: slashActive,
|
||||||
|
setActive: setSlashActive,
|
||||||
onInput: slashOnInput,
|
onInput: slashOnInput,
|
||||||
onKeyDown: slashOnKeyDown,
|
onKeyDown: slashOnKeyDown,
|
||||||
refetch: slashRefetch,
|
refetch: slashRefetch,
|
||||||
@@ -1299,6 +1302,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
|
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
|
||||||
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.popover === "at"}>
|
<Match when={store.popover === "at"}>
|
||||||
@@ -1314,6 +1318,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
"bg-surface-raised-base-hover": atActive() === atKey(item),
|
"bg-surface-raised-base-hover": atActive() === atKey(item),
|
||||||
}}
|
}}
|
||||||
onClick={() => handleAtSelect(item)}
|
onClick={() => handleAtSelect(item)}
|
||||||
|
onMouseEnter={() => setAtActive(atKey(item))}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={item.type === "agent"}
|
when={item.type === "agent"}
|
||||||
@@ -1360,6 +1365,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
"bg-surface-raised-base-hover": slashActive() === cmd.id,
|
"bg-surface-raised-base-hover": slashActive() === cmd.id,
|
||||||
}}
|
}}
|
||||||
onClick={() => handleSlashSelect(cmd)}
|
onClick={() => handleSlashSelect(cmd)}
|
||||||
|
onMouseEnter={() => setSlashActive(cmd.id)}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
||||||
@@ -1479,7 +1485,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<img
|
<img
|
||||||
src={attachment.dataUrl}
|
src={attachment.dataUrl}
|
||||||
alt={attachment.filename}
|
alt={attachment.filename}
|
||||||
class="size-16 rounded-md object-cover border border-border-base"
|
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
const variant = createMemo(() => props.variant ?? "button")
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
|
const view = createMemo(() => layout.view(sessionKey()))
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
@@ -48,7 +49,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
layout.review.open()
|
view().reviewPanel.open()
|
||||||
tabs().open("context")
|
tabs().open("context")
|
||||||
tabs().setActive("context")
|
tabs().setActive("context")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
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 worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
|
||||||
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
|
const view = createMemo(() => layout.view(sessionKey()))
|
||||||
|
|
||||||
function navigateToProject(directory: string) {
|
function navigateToProject(directory: string) {
|
||||||
navigate(`/${base64Encode(directory)}`)
|
navigate(`/${base64Encode(directory)}`)
|
||||||
@@ -171,20 +173,24 @@ export function SessionHeader() {
|
|||||||
title="Toggle review"
|
title="Toggle review"
|
||||||
keybind={command.keybind("review.toggle")}
|
keybind={command.keybind("review.toggle")}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.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">
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
<Icon
|
<Icon
|
||||||
name={layout.review.opened() ? "layout-right" : "layout-left"}
|
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
|
||||||
size="small"
|
size="small"
|
||||||
class="group-hover/review-toggle:hidden"
|
class="group-hover/review-toggle:hidden"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
|
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
|
||||||
size="small"
|
size="small"
|
||||||
class="hidden group-hover/review-toggle:inline-block"
|
class="hidden group-hover/review-toggle:inline-block"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
|
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
|
||||||
size="small"
|
size="small"
|
||||||
class="hidden group-active/review-toggle:inline-block"
|
class="hidden group-active/review-toggle:inline-block"
|
||||||
/>
|
/>
|
||||||
@@ -197,11 +203,11 @@ export function SessionHeader() {
|
|||||||
title="Toggle terminal"
|
title="Toggle terminal"
|
||||||
keybind={command.keybind("terminal.toggle")}
|
keybind={command.keybind("terminal.toggle")}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.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">
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
<Icon
|
<Icon
|
||||||
size="small"
|
size="small"
|
||||||
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
||||||
class="group-hover/terminal-toggle:hidden"
|
class="group-hover/terminal-toggle:hidden"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -211,7 +217,7 @@ export function SessionHeader() {
|
|||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
size="small"
|
size="small"
|
||||||
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
||||||
class="hidden group-active/terminal-toggle:inline-block"
|
class="hidden group-active/terminal-toggle:inline-block"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
let serializeAddon: SerializeAddon
|
let serializeAddon: SerializeAddon
|
||||||
let fitAddon: FitAddon
|
let fitAddon: FitAddon
|
||||||
let handleResize: () => void
|
let handleResize: () => void
|
||||||
|
let handleTextareaFocus: () => void
|
||||||
|
let handleTextareaBlur: () => void
|
||||||
let reconnect: number | undefined
|
let reconnect: number | undefined
|
||||||
let disposed = false
|
let disposed = false
|
||||||
|
|
||||||
@@ -105,6 +107,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const t = new mod.Terminal({
|
const t = new mod.Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
|
cursorStyle: "bar",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "IBM Plex Mono, monospace",
|
fontFamily: "IBM Plex Mono, monospace",
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
@@ -170,6 +173,17 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
t.open(container)
|
t.open(container)
|
||||||
container.addEventListener("pointerdown", handlePointerDown)
|
container.addEventListener("pointerdown", handlePointerDown)
|
||||||
|
|
||||||
|
handleTextareaFocus = () => {
|
||||||
|
t.options.cursorBlink = true
|
||||||
|
}
|
||||||
|
handleTextareaBlur = () => {
|
||||||
|
t.options.cursorBlink = false
|
||||||
|
}
|
||||||
|
|
||||||
|
t.textarea?.addEventListener("focus", handleTextareaFocus)
|
||||||
|
t.textarea?.addEventListener("blur", handleTextareaBlur)
|
||||||
|
|
||||||
focusTerminal()
|
focusTerminal()
|
||||||
|
|
||||||
if (local.pty.buffer) {
|
if (local.pty.buffer) {
|
||||||
@@ -242,6 +256,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
window.removeEventListener("resize", handleResize)
|
window.removeEventListener("resize", handleResize)
|
||||||
}
|
}
|
||||||
container.removeEventListener("pointerdown", handlePointerDown)
|
container.removeEventListener("pointerdown", handlePointerDown)
|
||||||
|
term?.textarea?.removeEventListener("focus", handleTextareaFocus)
|
||||||
|
term?.textarea?.removeEventListener("blur", handleTextareaBlur)
|
||||||
|
|
||||||
const t = term
|
const t = term
|
||||||
if (serializeAddon && props.onCleanup && t) {
|
if (serializeAddon && props.onCleanup && t) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||||
@@ -82,8 +82,106 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
|
const MAX_FILE_VIEW_SESSIONS = 20
|
||||||
|
const MAX_VIEW_FILES = 500
|
||||||
|
|
||||||
|
type ViewSession = ReturnType<typeof createViewSession>
|
||||||
|
|
||||||
|
type ViewCacheEntry = {
|
||||||
|
value: ViewSession
|
||||||
|
dispose: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
function createViewSession(dir: string, id: string | undefined) {
|
||||||
|
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
||||||
|
|
||||||
|
const [view, setView, _, ready] = persisted(
|
||||||
|
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
|
||||||
|
createStore<{
|
||||||
|
file: Record<string, FileViewState>
|
||||||
|
}>({
|
||||||
|
file: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta = { pruned: false }
|
||||||
|
|
||||||
|
const pruneView = (keep?: string) => {
|
||||||
|
const keys = Object.keys(view.file)
|
||||||
|
if (keys.length <= MAX_VIEW_FILES) return
|
||||||
|
|
||||||
|
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
|
||||||
|
if (drop.length === 0) return
|
||||||
|
|
||||||
|
setView(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const key of drop) {
|
||||||
|
delete draft.file[key]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (meta.pruned) return
|
||||||
|
meta.pruned = true
|
||||||
|
pruneView()
|
||||||
|
})
|
||||||
|
|
||||||
|
const scrollTop = (path: string) => view.file[path]?.scrollTop
|
||||||
|
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
|
||||||
|
const selectedLines = (path: string) => view.file[path]?.selectedLines
|
||||||
|
|
||||||
|
const setScrollTop = (path: string, top: number) => {
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollTop === top) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollTop: top,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setScrollLeft = (path: string, left: number) => {
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollLeft === left) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollLeft: left,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
|
||||||
|
const next = range ? normalizeSelectedLines(range) : null
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.selectedLines === next) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
selectedLines: next,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
scrollTop,
|
||||||
|
scrollLeft,
|
||||||
|
selectedLines,
|
||||||
|
setScrollTop,
|
||||||
|
setScrollLeft,
|
||||||
|
setSelectedLines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
name: "File",
|
name: "File",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -134,42 +232,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
file: {},
|
file: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
|
const viewCache = new Map<string, ViewCacheEntry>()
|
||||||
|
|
||||||
const [view, setView, _, ready] = persisted(
|
const disposeViews = () => {
|
||||||
Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]),
|
for (const entry of viewCache.values()) {
|
||||||
createStore<{
|
entry.dispose()
|
||||||
file: Record<string, FileViewState>
|
}
|
||||||
}>({
|
viewCache.clear()
|
||||||
file: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const MAX_VIEW_FILES = 500
|
|
||||||
const viewMeta = { pruned: false }
|
|
||||||
|
|
||||||
const pruneView = (keep?: string) => {
|
|
||||||
const keys = Object.keys(view.file)
|
|
||||||
if (keys.length <= MAX_VIEW_FILES) return
|
|
||||||
|
|
||||||
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
|
|
||||||
if (drop.length === 0) return
|
|
||||||
|
|
||||||
setView(
|
|
||||||
produce((draft) => {
|
|
||||||
for (const key of drop) {
|
|
||||||
delete draft.file[key]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
const pruneViews = () => {
|
||||||
if (!ready()) return
|
while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
|
||||||
if (viewMeta.pruned) return
|
const first = viewCache.keys().next().value
|
||||||
viewMeta.pruned = true
|
if (!first) return
|
||||||
pruneView()
|
const entry = viewCache.get(first)
|
||||||
})
|
entry?.dispose()
|
||||||
|
viewCache.delete(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadView = (dir: string, id: string | undefined) => {
|
||||||
|
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||||
|
const existing = viewCache.get(key)
|
||||||
|
if (existing) {
|
||||||
|
viewCache.delete(key)
|
||||||
|
viewCache.set(key, existing)
|
||||||
|
return existing.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createRoot((dispose) => ({
|
||||||
|
value: createViewSession(dir, id),
|
||||||
|
dispose,
|
||||||
|
}))
|
||||||
|
|
||||||
|
viewCache.set(key, entry)
|
||||||
|
pruneViews()
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = createMemo(() => loadView(params.dir!, params.id))
|
||||||
|
|
||||||
function ensure(path: string) {
|
function ensure(path: string) {
|
||||||
if (!path) return
|
if (!path) return
|
||||||
@@ -246,51 +347,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const get = (input: string) => store.file[normalize(input)]
|
const get = (input: string) => store.file[normalize(input)]
|
||||||
|
|
||||||
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
|
const scrollTop = (input: string) => view().scrollTop(normalize(input))
|
||||||
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
|
const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
|
||||||
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
|
const selectedLines = (input: string) => view().selectedLines(normalize(input))
|
||||||
|
|
||||||
const setScrollTop = (input: string, top: number) => {
|
const setScrollTop = (input: string, top: number) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
setView("file", path, (current) => {
|
view().setScrollTop(path, top)
|
||||||
if (current?.scrollTop === top) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
scrollTop: top,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
pruneView(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setScrollLeft = (input: string, left: number) => {
|
const setScrollLeft = (input: string, left: number) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
setView("file", path, (current) => {
|
view().setScrollLeft(path, left)
|
||||||
if (current?.scrollLeft === left) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
scrollLeft: left,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
pruneView(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
const next = range ? normalizeSelectedLines(range) : null
|
view().setSelectedLines(path, range)
|
||||||
setView("file", path, (current) => {
|
|
||||||
if (current?.selectedLines === next) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
selectedLines: next,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
pruneView(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => stop())
|
onCleanup(() => {
|
||||||
|
stop()
|
||||||
|
disposeViews()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => view().ready(),
|
||||||
normalize,
|
normalize,
|
||||||
tab,
|
tab,
|
||||||
pathFromTab,
|
pathFromTab,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type SessionTabs = {
|
|||||||
type SessionView = {
|
type SessionView = {
|
||||||
scroll: Record<string, SessionScroll>
|
scroll: Record<string, SessionScroll>
|
||||||
reviewOpen?: string[]
|
reviewOpen?: string[]
|
||||||
|
terminalOpened?: boolean
|
||||||
|
reviewPanelOpened?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||||
@@ -53,11 +55,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
width: 280,
|
width: 280,
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
opened: false,
|
|
||||||
height: 280,
|
height: 280,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
opened: true,
|
|
||||||
diffStyle: "split" as ReviewDiffStyle,
|
diffStyle: "split" as ReviewDiffStyle,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
@@ -150,7 +150,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
const current = store.sessionView[sessionKey]
|
const current = store.sessionView[sessionKey]
|
||||||
const keep = meta.active ?? sessionKey
|
const keep = meta.active ?? sessionKey
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setStore("sessionView", sessionKey, { scroll: next })
|
setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
|
||||||
prune(keep)
|
prune(keep)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -306,40 +306,20 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
opened: createMemo(() => store.terminal.opened),
|
|
||||||
open() {
|
|
||||||
setStore("terminal", "opened", true)
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
setStore("terminal", "opened", false)
|
|
||||||
},
|
|
||||||
toggle() {
|
|
||||||
setStore("terminal", "opened", (x) => !x)
|
|
||||||
},
|
|
||||||
height: createMemo(() => store.terminal.height),
|
height: createMemo(() => store.terminal.height),
|
||||||
resize(height: number) {
|
resize(height: number) {
|
||||||
setStore("terminal", "height", height)
|
setStore("terminal", "height", height)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
opened: createMemo(() => store.review?.opened ?? true),
|
|
||||||
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
||||||
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
||||||
if (!store.review) {
|
if (!store.review) {
|
||||||
setStore("review", { opened: true, diffStyle })
|
setStore("review", { diffStyle })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("review", "diffStyle", diffStyle)
|
setStore("review", "diffStyle", diffStyle)
|
||||||
},
|
},
|
||||||
open() {
|
|
||||||
setStore("review", "opened", true)
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
setStore("review", "opened", false)
|
|
||||||
},
|
|
||||||
toggle() {
|
|
||||||
setStore("review", "opened", (x) => !x)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
width: createMemo(() => store.session?.width ?? 600),
|
width: createMemo(() => store.session?.width ?? 600),
|
||||||
@@ -367,6 +347,33 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
touch(sessionKey)
|
touch(sessionKey)
|
||||||
scroll.seed(sessionKey)
|
scroll.seed(sessionKey)
|
||||||
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
||||||
|
const terminalOpened = createMemo(() => s().terminalOpened ?? false)
|
||||||
|
const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true)
|
||||||
|
|
||||||
|
function setTerminalOpened(next: boolean) {
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = current.terminalOpened ?? false
|
||||||
|
if (value === next) return
|
||||||
|
setStore("sessionView", sessionKey, "terminalOpened", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReviewPanelOpened(next: boolean) {
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = current.reviewPanelOpened ?? true
|
||||||
|
if (value === next) return
|
||||||
|
setStore("sessionView", sessionKey, "reviewPanelOpened", next)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scroll(tab: string) {
|
scroll(tab: string) {
|
||||||
return scroll.scroll(sessionKey, tab)
|
return scroll.scroll(sessionKey, tab)
|
||||||
@@ -374,12 +381,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
setScroll(tab: string, pos: SessionScroll) {
|
setScroll(tab: string, pos: SessionScroll) {
|
||||||
scroll.setScroll(sessionKey, tab, pos)
|
scroll.setScroll(sessionKey, tab, pos)
|
||||||
},
|
},
|
||||||
|
terminal: {
|
||||||
|
opened: terminalOpened,
|
||||||
|
open() {
|
||||||
|
setTerminalOpened(true)
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
setTerminalOpened(false)
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
setTerminalOpened(!terminalOpened())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reviewPanel: {
|
||||||
|
opened: reviewPanelOpened,
|
||||||
|
open() {
|
||||||
|
setReviewPanelOpened(true)
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
setReviewPanelOpened(false)
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
setReviewPanelOpened(!reviewPanelOpened())
|
||||||
|
},
|
||||||
|
},
|
||||||
review: {
|
review: {
|
||||||
open: createMemo(() => s().reviewOpen),
|
open: createMemo(() => s().reviewOpen),
|
||||||
setOpen(open: string[]) {
|
setOpen(open: string[]) {
|
||||||
const current = store.sessionView[sessionKey]
|
const current = store.sessionView[sessionKey]
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
|
setStore("sessionView", sessionKey, {
|
||||||
|
scroll: {},
|
||||||
|
terminalOpened: false,
|
||||||
|
reviewPanelOpened: true,
|
||||||
|
reviewOpen: open,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export type Platform = {
|
|||||||
|
|
||||||
/** Fetch override */
|
/** Fetch override */
|
||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
|
|
||||||
|
/** Get the configured default server URL (desktop only) */
|
||||||
|
getDefaultServerUrl?(): Promise<string | null>
|
||||||
|
|
||||||
|
/** Set the default server URL to use on app startup (desktop only) */
|
||||||
|
setDefaultServerUrl?(url: string | null): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -99,74 +99,146 @@ function clonePrompt(prompt: Prompt): Prompt {
|
|||||||
return prompt.map(clonePart)
|
return prompt.map(clonePart)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
|
const MAX_PROMPT_SESSIONS = 20
|
||||||
|
|
||||||
|
type PromptSession = ReturnType<typeof createPromptSession>
|
||||||
|
|
||||||
|
type PromptCacheEntry = {
|
||||||
|
value: PromptSession
|
||||||
|
dispose: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPromptSession(dir: string, id: string | undefined) {
|
||||||
|
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
|
||||||
|
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
Persist.scoped(dir, id, "prompt", [legacy]),
|
||||||
|
createStore<{
|
||||||
|
prompt: Prompt
|
||||||
|
cursor?: number
|
||||||
|
context: {
|
||||||
|
activeTab: boolean
|
||||||
|
items: (ContextItem & { key: string })[]
|
||||||
|
}
|
||||||
|
}>({
|
||||||
|
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||||
|
cursor: undefined,
|
||||||
|
context: {
|
||||||
|
activeTab: true,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function keyForItem(item: ContextItem) {
|
||||||
|
if (item.type !== "file") return item.type
|
||||||
|
const start = item.selection?.startLine
|
||||||
|
const end = item.selection?.endLine
|
||||||
|
return `${item.type}:${item.path}:${start}:${end}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
current: createMemo(() => store.prompt),
|
||||||
|
cursor: createMemo(() => store.cursor),
|
||||||
|
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||||
|
context: {
|
||||||
|
activeTab: createMemo(() => store.context.activeTab),
|
||||||
|
items: createMemo(() => store.context.items),
|
||||||
|
addActive() {
|
||||||
|
setStore("context", "activeTab", true)
|
||||||
|
},
|
||||||
|
removeActive() {
|
||||||
|
setStore("context", "activeTab", false)
|
||||||
|
},
|
||||||
|
add(item: ContextItem) {
|
||||||
|
const key = keyForItem(item)
|
||||||
|
if (store.context.items.find((x) => x.key === key)) return
|
||||||
|
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||||
|
},
|
||||||
|
remove(key: string) {
|
||||||
|
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
set(prompt: Prompt, cursorPosition?: number) {
|
||||||
|
const next = clonePrompt(prompt)
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", next)
|
||||||
|
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||||
|
setStore("cursor", 0)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||||
name: "Prompt",
|
name: "Prompt",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
|
const cache = new Map<string, PromptCacheEntry>()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const disposeAll = () => {
|
||||||
Persist.scoped(params.dir!, params.id, "prompt", [legacy()]),
|
for (const entry of cache.values()) {
|
||||||
createStore<{
|
entry.dispose()
|
||||||
prompt: Prompt
|
}
|
||||||
cursor?: number
|
cache.clear()
|
||||||
context: {
|
|
||||||
activeTab: boolean
|
|
||||||
items: (ContextItem & { key: string })[]
|
|
||||||
}
|
|
||||||
}>({
|
|
||||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
|
||||||
cursor: undefined,
|
|
||||||
context: {
|
|
||||||
activeTab: true,
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function keyForItem(item: ContextItem) {
|
|
||||||
if (item.type !== "file") return item.type
|
|
||||||
const start = item.selection?.startLine
|
|
||||||
const end = item.selection?.endLine
|
|
||||||
return `${item.type}:${item.path}:${start}:${end}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCleanup(disposeAll)
|
||||||
|
|
||||||
|
const prune = () => {
|
||||||
|
while (cache.size > MAX_PROMPT_SESSIONS) {
|
||||||
|
const first = cache.keys().next().value
|
||||||
|
if (!first) return
|
||||||
|
const entry = cache.get(first)
|
||||||
|
entry?.dispose()
|
||||||
|
cache.delete(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = (dir: string, id: string | undefined) => {
|
||||||
|
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||||
|
const existing = cache.get(key)
|
||||||
|
if (existing) {
|
||||||
|
cache.delete(key)
|
||||||
|
cache.set(key, existing)
|
||||||
|
return existing.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createRoot((dispose) => ({
|
||||||
|
value: createPromptSession(dir, id),
|
||||||
|
dispose,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cache.set(key, entry)
|
||||||
|
prune()
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createMemo(() => load(params.dir!, params.id))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => session().ready(),
|
||||||
current: createMemo(() => store.prompt),
|
current: () => session().current(),
|
||||||
cursor: createMemo(() => store.cursor),
|
cursor: () => session().cursor(),
|
||||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
dirty: () => session().dirty(),
|
||||||
context: {
|
context: {
|
||||||
activeTab: createMemo(() => store.context.activeTab),
|
activeTab: () => session().context.activeTab(),
|
||||||
items: createMemo(() => store.context.items),
|
items: () => session().context.items(),
|
||||||
addActive() {
|
addActive: () => session().context.addActive(),
|
||||||
setStore("context", "activeTab", true)
|
removeActive: () => session().context.removeActive(),
|
||||||
},
|
add: (item: ContextItem) => session().context.add(item),
|
||||||
removeActive() {
|
remove: (key: string) => session().context.remove(key),
|
||||||
setStore("context", "activeTab", false)
|
|
||||||
},
|
|
||||||
add(item: ContextItem) {
|
|
||||||
const key = keyForItem(item)
|
|
||||||
if (store.context.items.find((x) => x.key === key)) return
|
|
||||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
|
||||||
},
|
|
||||||
remove(key: string) {
|
|
||||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
set(prompt: Prompt, cursorPosition?: number) {
|
|
||||||
const next = clonePrompt(prompt)
|
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", next)
|
|
||||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
reset() {
|
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
|
||||||
setStore("cursor", 0)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
|
||||||
|
reset: () => session().reset(),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,10 +16,7 @@ export function normalizeServerUrl(input: string) {
|
|||||||
|
|
||||||
export function serverDisplayName(url: string) {
|
export function serverDisplayName(url: string) {
|
||||||
if (!url) return ""
|
if (!url) return ""
|
||||||
return url
|
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||||
.replace(/^https?:\/\//, "")
|
|
||||||
.replace(/\/+$/, "")
|
|
||||||
.split("/")[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectsKey(url: string) {
|
function projectsKey(url: string) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
@@ -14,6 +14,76 @@ 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 = 200
|
||||||
|
const inflight = new Map<string, Promise<void>>()
|
||||||
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
|
const [meta, setMeta] = createStore({
|
||||||
|
limit: {} as Record<string, number>,
|
||||||
|
complete: {} as Record<string, boolean>,
|
||||||
|
loading: {} as Record<string, boolean>,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getSession = (sessionID: string) => {
|
||||||
|
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||||
|
if (match.found) return store.session[match.index]
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitFor = (count: number) => {
|
||||||
|
if (count <= chunk) return chunk
|
||||||
|
return Math.ceil(count / chunk) * chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateMessages = (sessionID: string) => {
|
||||||
|
if (meta.limit[sessionID] !== undefined) return
|
||||||
|
|
||||||
|
const messages = store.message[sessionID]
|
||||||
|
if (!messages) return
|
||||||
|
|
||||||
|
const limit = limitFor(messages.length)
|
||||||
|
setMeta("limit", sessionID, limit)
|
||||||
|
setMeta("complete", sessionID, messages.length < limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMessages = async (sessionID: string, limit: number) => {
|
||||||
|
if (meta.loading[sessionID]) return
|
||||||
|
|
||||||
|
setMeta("loading", sessionID, true)
|
||||||
|
await retry(() => sdk.client.session.messages({ sessionID, limit }))
|
||||||
|
.then((messages) => {
|
||||||
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
|
const next = items
|
||||||
|
.map((x) => x.info)
|
||||||
|
.filter((m) => !!m?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setStore("message", sessionID, reconcile(next, { key: "id" }))
|
||||||
|
|
||||||
|
for (const message of items) {
|
||||||
|
setStore(
|
||||||
|
"part",
|
||||||
|
message.info.id,
|
||||||
|
reconcile(
|
||||||
|
message.parts
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setMeta("limit", sessionID, limit)
|
||||||
|
setMeta("complete", sessionID, next.length < limit)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setMeta("loading", sessionID, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: store,
|
data: store,
|
||||||
@@ -30,11 +100,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
get(sessionID: string) {
|
get: getSession,
|
||||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
|
||||||
if (match.found) return store.session[match.index]
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
addOptimisticMessage(input: {
|
addOptimisticMessage(input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID: string
|
messageID: string
|
||||||
@@ -66,58 +132,98 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
async sync(sessionID: string, _isRetry = false) {
|
async sync(sessionID: string) {
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const hasSession = getSession(sessionID) !== undefined
|
||||||
retry(() => sdk.client.session.get({ sessionID })),
|
hydrateMessages(sessionID)
|
||||||
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
|
|
||||||
retry(() => sdk.client.session.todo({ sessionID })),
|
|
||||||
retry(() => sdk.client.session.diff({ sessionID })),
|
|
||||||
])
|
|
||||||
|
|
||||||
batch(() => {
|
const hasMessages = store.message[sessionID] !== undefined
|
||||||
setStore(
|
if (hasSession && hasMessages) return
|
||||||
"session",
|
|
||||||
produce((draft) => {
|
|
||||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
|
||||||
if (match.found) {
|
|
||||||
draft[match.index] = session.data!
|
|
||||||
return
|
|
||||||
}
|
|
||||||
draft.splice(match.index, 0, session.data!)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
const pending = inflight.get(sessionID)
|
||||||
setStore(
|
if (pending) return pending
|
||||||
"message",
|
|
||||||
sessionID,
|
|
||||||
reconcile(
|
|
||||||
(messages.data ?? [])
|
|
||||||
.map((x) => x.info)
|
|
||||||
.filter((m) => !!m?.id)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const message of messages.data ?? []) {
|
const limit = meta.limit[sessionID] ?? chunk
|
||||||
if (!message?.info?.id) continue
|
|
||||||
setStore(
|
|
||||||
"part",
|
|
||||||
message.info.id,
|
|
||||||
reconcile(
|
|
||||||
message.parts
|
|
||||||
.filter((p) => !!p?.id)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
const sessionReq = hasSession
|
||||||
})
|
? Promise.resolve()
|
||||||
|
: retry(() => sdk.client.session.get({ sessionID })).then((session) => {
|
||||||
|
const data = session.data
|
||||||
|
if (!data) return
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
produce((draft) => {
|
||||||
|
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||||
|
if (match.found) {
|
||||||
|
draft[match.index] = data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.splice(match.index, 0, data)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
|
||||||
|
|
||||||
|
const promise = Promise.all([sessionReq, messagesReq])
|
||||||
|
.then(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflight.set(sessionID, promise)
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
async diff(sessionID: string) {
|
||||||
|
if (store.session_diff[sessionID] !== undefined) return
|
||||||
|
|
||||||
|
const pending = inflightDiff.get(sessionID)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const promise = retry(() => sdk.client.session.diff({ sessionID }))
|
||||||
|
.then((diff) => {
|
||||||
|
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightDiff.delete(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflightDiff.set(sessionID, promise)
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
async todo(sessionID: string) {
|
||||||
|
if (store.todo[sessionID] !== undefined) return
|
||||||
|
|
||||||
|
const pending = inflightTodo.get(sessionID)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const promise = retry(() => sdk.client.session.todo({ sessionID }))
|
||||||
|
.then((todo) => {
|
||||||
|
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightTodo.delete(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflightTodo.set(sessionID, promise)
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
more(sessionID: string) {
|
||||||
|
if (store.message[sessionID] === undefined) return false
|
||||||
|
if (meta.limit[sessionID] === undefined) return false
|
||||||
|
if (meta.complete[sessionID]) return false
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
loading(sessionID: string) {
|
||||||
|
return meta.loading[sessionID] ?? false
|
||||||
|
},
|
||||||
|
async loadMore(sessionID: string, count = chunk) {
|
||||||
|
if (meta.loading[sessionID]) return
|
||||||
|
if (meta.complete[sessionID]) return
|
||||||
|
|
||||||
|
const current = meta.limit[sessionID] ?? chunk
|
||||||
|
await loadMessages(sessionID, current + count)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
setStore("limit", (x) => x + count)
|
setStore("limit", (x) => x + count)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -14,108 +14,175 @@ export type LocalPTY = {
|
|||||||
scrollY?: number
|
scrollY?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
|
const MAX_TERMINAL_SESSIONS = 20
|
||||||
|
|
||||||
|
type TerminalSession = ReturnType<typeof createTerminalSession>
|
||||||
|
|
||||||
|
type TerminalCacheEntry = {
|
||||||
|
value: TerminalSession
|
||||||
|
dispose: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
|
||||||
|
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
|
||||||
|
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
Persist.scoped(dir, id, "terminal", [legacy]),
|
||||||
|
createStore<{
|
||||||
|
active?: string
|
||||||
|
all: LocalPTY[]
|
||||||
|
}>({
|
||||||
|
all: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
all: createMemo(() => Object.values(store.all)),
|
||||||
|
active: createMemo(() => store.active),
|
||||||
|
new() {
|
||||||
|
sdk.client.pty
|
||||||
|
.create({ title: `Terminal ${store.all.length + 1}` })
|
||||||
|
.then((pty) => {
|
||||||
|
const id = pty.data?.id
|
||||||
|
if (!id) return
|
||||||
|
setStore("all", [
|
||||||
|
...store.all,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
title: pty.data?.title ?? "Terminal",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setStore("active", id)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to create terminal", e)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
|
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
||||||
|
sdk.client.pty
|
||||||
|
.update({
|
||||||
|
ptyID: pty.id,
|
||||||
|
title: pty.title,
|
||||||
|
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to update terminal", e)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async clone(id: string) {
|
||||||
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
|
const pty = store.all[index]
|
||||||
|
if (!pty) return
|
||||||
|
const clone = await sdk.client.pty
|
||||||
|
.create({
|
||||||
|
title: pty.title,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to clone terminal", e)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!clone?.data) return
|
||||||
|
setStore("all", index, {
|
||||||
|
...pty,
|
||||||
|
...clone.data,
|
||||||
|
})
|
||||||
|
if (store.active === pty.id) {
|
||||||
|
setStore("active", clone.data.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
open(id: string) {
|
||||||
|
setStore("active", id)
|
||||||
|
},
|
||||||
|
async close(id: string) {
|
||||||
|
batch(() => {
|
||||||
|
setStore(
|
||||||
|
"all",
|
||||||
|
store.all.filter((x) => x.id !== id),
|
||||||
|
)
|
||||||
|
if (store.active === id) {
|
||||||
|
const index = store.all.findIndex((f) => f.id === id)
|
||||||
|
const previous = store.all[Math.max(0, index - 1)]
|
||||||
|
setStore("active", previous?.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
|
||||||
|
console.error("Failed to close terminal", e)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
move(id: string, to: number) {
|
||||||
|
const index = store.all.findIndex((f) => f.id === id)
|
||||||
|
if (index === -1) return
|
||||||
|
setStore(
|
||||||
|
"all",
|
||||||
|
produce((all) => {
|
||||||
|
all.splice(to, 0, all.splice(index, 1)[0])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
|
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
|
||||||
name: "Terminal",
|
name: "Terminal",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
|
const cache = new Map<string, TerminalCacheEntry>()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const disposeAll = () => {
|
||||||
Persist.scoped(params.dir!, params.id, "terminal", [legacy()]),
|
for (const entry of cache.values()) {
|
||||||
createStore<{
|
entry.dispose()
|
||||||
active?: string
|
}
|
||||||
all: LocalPTY[]
|
cache.clear()
|
||||||
}>({
|
}
|
||||||
all: [],
|
|
||||||
}),
|
onCleanup(disposeAll)
|
||||||
)
|
|
||||||
|
const prune = () => {
|
||||||
|
while (cache.size > MAX_TERMINAL_SESSIONS) {
|
||||||
|
const first = cache.keys().next().value
|
||||||
|
if (!first) return
|
||||||
|
const entry = cache.get(first)
|
||||||
|
entry?.dispose()
|
||||||
|
cache.delete(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = (dir: string, id: string | undefined) => {
|
||||||
|
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||||
|
const existing = cache.get(key)
|
||||||
|
if (existing) {
|
||||||
|
cache.delete(key)
|
||||||
|
cache.set(key, existing)
|
||||||
|
return existing.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createRoot((dispose) => ({
|
||||||
|
value: createTerminalSession(sdk, dir, id),
|
||||||
|
dispose,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cache.set(key, entry)
|
||||||
|
prune()
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createMemo(() => load(params.dir!, params.id))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => session().ready(),
|
||||||
all: createMemo(() => Object.values(store.all)),
|
all: () => session().all(),
|
||||||
active: createMemo(() => store.active),
|
active: () => session().active(),
|
||||||
new() {
|
new: () => session().new(),
|
||||||
sdk.client.pty
|
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
|
||||||
.create({ title: `Terminal ${store.all.length + 1}` })
|
clone: (id: string) => session().clone(id),
|
||||||
.then((pty) => {
|
open: (id: string) => session().open(id),
|
||||||
const id = pty.data?.id
|
close: (id: string) => session().close(id),
|
||||||
if (!id) return
|
move: (id: string, to: number) => session().move(id, to),
|
||||||
setStore("all", [
|
|
||||||
...store.all,
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
title: pty.data?.title ?? "Terminal",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setStore("active", id)
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to create terminal", e)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
|
||||||
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
|
||||||
sdk.client.pty
|
|
||||||
.update({
|
|
||||||
ptyID: pty.id,
|
|
||||||
title: pty.title,
|
|
||||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to update terminal", e)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async clone(id: string) {
|
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
|
||||||
const pty = store.all[index]
|
|
||||||
if (!pty) return
|
|
||||||
const clone = await sdk.client.pty
|
|
||||||
.create({
|
|
||||||
title: pty.title,
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to clone terminal", e)
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
if (!clone?.data) return
|
|
||||||
setStore("all", index, {
|
|
||||||
...pty,
|
|
||||||
...clone.data,
|
|
||||||
})
|
|
||||||
if (store.active === pty.id) {
|
|
||||||
setStore("active", clone.data.id)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
open(id: string) {
|
|
||||||
setStore("active", id)
|
|
||||||
},
|
|
||||||
async close(id: string) {
|
|
||||||
batch(() => {
|
|
||||||
setStore(
|
|
||||||
"all",
|
|
||||||
store.all.filter((x) => x.id !== id),
|
|
||||||
)
|
|
||||||
if (store.active === id) {
|
|
||||||
const index = store.all.findIndex((f) => f.id === id)
|
|
||||||
const previous = store.all[Math.max(0, index - 1)]
|
|
||||||
setStore("active", previous?.id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
|
|
||||||
console.error("Failed to close terminal", e)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
move(id: string, to: number) {
|
|
||||||
const index = store.all.findIndex((f) => f.id === id)
|
|
||||||
if (index === -1) return
|
|
||||||
setStore(
|
|
||||||
"all",
|
|
||||||
produce((all) => {
|
|
||||||
all.splice(to, 0, all.splice(index, 1)[0])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
batch,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
@@ -31,7 +32,7 @@ import { getFilename } from "@opencode-ai/util/path"
|
|||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import {
|
import {
|
||||||
DragDropProvider,
|
DragDropProvider,
|
||||||
DragDropSensors,
|
DragDropSensors,
|
||||||
@@ -47,6 +48,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
|
|||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||||
@@ -55,6 +57,7 @@ import { DialogEditProject } from "@/components/dialog-edit-project"
|
|||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||||
|
import { navStart } from "@/utils/perf"
|
||||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
|
||||||
@@ -284,6 +287,146 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const currentSessions = createMemo(() => projectSessions(currentProject()))
|
const currentSessions = createMemo(() => projectSessions(currentProject()))
|
||||||
|
|
||||||
|
type PrefetchQueue = {
|
||||||
|
inflight: Set<string>
|
||||||
|
pending: string[]
|
||||||
|
pendingSet: Set<string>
|
||||||
|
running: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefetchChunk = 200
|
||||||
|
const prefetchConcurrency = 1
|
||||||
|
const prefetchPendingLimit = 6
|
||||||
|
const prefetchToken = { value: 0 }
|
||||||
|
const prefetchQueues = new Map<string, PrefetchQueue>()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
params.dir
|
||||||
|
globalSDK.url
|
||||||
|
|
||||||
|
prefetchToken.value += 1
|
||||||
|
for (const q of prefetchQueues.values()) {
|
||||||
|
q.pending.length = 0
|
||||||
|
q.pendingSet.clear()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const queueFor = (directory: string) => {
|
||||||
|
const existing = prefetchQueues.get(directory)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const created: PrefetchQueue = {
|
||||||
|
inflight: new Set(),
|
||||||
|
pending: [],
|
||||||
|
pendingSet: new Set(),
|
||||||
|
running: 0,
|
||||||
|
}
|
||||||
|
prefetchQueues.set(directory, created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefetchMessages = (directory: string, sessionID: string, token: number) => {
|
||||||
|
const [, setStore] = globalSync.child(directory)
|
||||||
|
|
||||||
|
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||||
|
.then((messages) => {
|
||||||
|
if (prefetchToken.value !== token) return
|
||||||
|
|
||||||
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
|
const next = items
|
||||||
|
.map((x) => x.info)
|
||||||
|
.filter((m) => !!m?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setStore("message", sessionID, reconcile(next, { key: "id" }))
|
||||||
|
|
||||||
|
for (const message of items) {
|
||||||
|
setStore(
|
||||||
|
"part",
|
||||||
|
message.info.id,
|
||||||
|
reconcile(
|
||||||
|
message.parts
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pumpPrefetch = (directory: string) => {
|
||||||
|
const q = queueFor(directory)
|
||||||
|
if (q.running >= prefetchConcurrency) return
|
||||||
|
|
||||||
|
const sessionID = q.pending.shift()
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
q.pendingSet.delete(sessionID)
|
||||||
|
q.inflight.add(sessionID)
|
||||||
|
q.running += 1
|
||||||
|
|
||||||
|
const token = prefetchToken.value
|
||||||
|
|
||||||
|
void prefetchMessages(directory, sessionID, token).finally(() => {
|
||||||
|
q.running -= 1
|
||||||
|
q.inflight.delete(sessionID)
|
||||||
|
pumpPrefetch(directory)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
|
||||||
|
const directory = session.directory
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
const [store] = globalSync.child(directory)
|
||||||
|
if (store.message[session.id] !== undefined) return
|
||||||
|
|
||||||
|
const q = queueFor(directory)
|
||||||
|
if (q.inflight.has(session.id)) return
|
||||||
|
if (q.pendingSet.has(session.id)) return
|
||||||
|
|
||||||
|
if (priority === "high") q.pending.unshift(session.id)
|
||||||
|
if (priority !== "high") q.pending.push(session.id)
|
||||||
|
q.pendingSet.add(session.id)
|
||||||
|
|
||||||
|
while (q.pending.length > prefetchPendingLimit) {
|
||||||
|
const dropped = q.pending.pop()
|
||||||
|
if (!dropped) continue
|
||||||
|
q.pendingSet.delete(dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
pumpPrefetch(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const sessions = currentSessions()
|
||||||
|
const id = params.id
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
const first = sessions[0]
|
||||||
|
if (first) prefetchSession(first)
|
||||||
|
|
||||||
|
const second = sessions[1]
|
||||||
|
if (second) prefetchSession(second)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = sessions.findIndex((s) => s.id === id)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
const next = sessions[index + 1]
|
||||||
|
if (next) prefetchSession(next)
|
||||||
|
|
||||||
|
const prev = sessions[index - 1]
|
||||||
|
if (prev) prefetchSession(prev)
|
||||||
|
})
|
||||||
|
|
||||||
function navigateSessionByOffset(offset: number) {
|
function navigateSessionByOffset(offset: number) {
|
||||||
const projects = layout.projects.list()
|
const projects = layout.projects.list()
|
||||||
if (projects.length === 0) return
|
if (projects.length === 0) return
|
||||||
@@ -309,6 +452,27 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
if (targetIndex >= 0 && targetIndex < sessions.length) {
|
if (targetIndex >= 0 && targetIndex < sessions.length) {
|
||||||
const session = sessions[targetIndex]
|
const session = sessions[targetIndex]
|
||||||
|
const next = sessions[targetIndex + 1]
|
||||||
|
const prev = sessions[targetIndex - 1]
|
||||||
|
|
||||||
|
if (offset > 0) {
|
||||||
|
if (next) prefetchSession(next, "high")
|
||||||
|
if (prev) prefetchSession(prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset < 0) {
|
||||||
|
if (prev) prefetchSession(prev, "high")
|
||||||
|
if (next) prefetchSession(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
navStart({
|
||||||
|
dir: base64Encode(session.directory),
|
||||||
|
from: params.id,
|
||||||
|
to: session.id,
|
||||||
|
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
|
||||||
|
})
|
||||||
|
}
|
||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
queueMicrotask(() => scrollToSession(session.id))
|
queueMicrotask(() => scrollToSession(session.id))
|
||||||
return
|
return
|
||||||
@@ -324,7 +488,27 @@ export default function Layout(props: ParentProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
|
const index = offset > 0 ? 0 : nextProjectSessions.length - 1
|
||||||
|
const targetSession = nextProjectSessions[index]
|
||||||
|
const nextSession = nextProjectSessions[index + 1]
|
||||||
|
const prevSession = nextProjectSessions[index - 1]
|
||||||
|
|
||||||
|
if (offset > 0) {
|
||||||
|
if (nextSession) prefetchSession(nextSession, "high")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset < 0) {
|
||||||
|
if (prevSession) prefetchSession(prevSession, "high")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
navStart({
|
||||||
|
dir: base64Encode(targetSession.directory),
|
||||||
|
from: params.id,
|
||||||
|
to: targetSession.id,
|
||||||
|
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
|
||||||
|
})
|
||||||
|
}
|
||||||
navigateToSession(targetSession)
|
navigateToSession(targetSession)
|
||||||
queueMicrotask(() => scrollToSession(targetSession.id))
|
queueMicrotask(() => scrollToSession(targetSession.id))
|
||||||
}
|
}
|
||||||
@@ -679,6 +863,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
<A
|
<A
|
||||||
href={`${props.slug}/session/${props.session.id}`}
|
href={`${props.slug}/session/${props.session.id}`}
|
||||||
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
|
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
|
||||||
|
onMouseEnter={() => prefetchSession(props.session, "high")}
|
||||||
|
onFocus={() => prefetchSession(props.session, "high")}
|
||||||
>
|
>
|
||||||
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
||||||
<span
|
<span
|
||||||
@@ -875,7 +1061,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Tooltip placement="right" value={props.project.worktree}>
|
<Tooltip placement="right" value={getFilename(props.project.worktree)}>
|
||||||
<ProjectVisual project={props.project} />
|
<ProjectVisual project={props.project} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
+533
-203
@@ -1,4 +1,4 @@
|
|||||||
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
@@ -8,6 +8,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||||
@@ -30,6 +31,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||||
|
import { DialogFork } from "@/components/dialog-fork"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
@@ -49,10 +51,17 @@ import {
|
|||||||
NewSessionView,
|
NewSessionView,
|
||||||
} from "@/components/session"
|
} from "@/components/session"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { navMark, navParams } from "@/utils/perf"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
|
|
||||||
type DiffStyle = "unified" | "split"
|
type DiffStyle = "unified" | "split"
|
||||||
|
|
||||||
|
const handoff = {
|
||||||
|
prompt: "",
|
||||||
|
terminals: [] as string[],
|
||||||
|
files: {} as Record<string, SelectedLineRange | null>,
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionReviewTabProps {
|
interface SessionReviewTabProps {
|
||||||
diffs: () => FileDiff[]
|
diffs: () => FileDiff[]
|
||||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||||
@@ -162,6 +171,46 @@ export default function Page() {
|
|||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
const view = createMemo(() => layout.view(sessionKey()))
|
const view = createMemo(() => layout.view(sessionKey()))
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [params.dir, params.id] as const,
|
||||||
|
([dir, id], prev) => {
|
||||||
|
if (!id) return
|
||||||
|
navParams({ dir, from: prev?.[1], to: id })
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
if (!prompt.ready()) return
|
||||||
|
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
if (!terminal.ready()) return
|
||||||
|
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
if (!file.ready()) return
|
||||||
|
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
if (sync.data.message[id] === undefined) return
|
||||||
|
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
function normalizeTab(tab: string) {
|
function normalizeTab(tab: string) {
|
||||||
@@ -216,6 +265,8 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
|
const reviewCount = createMemo(() => info()?.summary?.files ?? 0)
|
||||||
|
const hasReview = createMemo(() => reviewCount() > 0)
|
||||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
const messagesReady = createMemo(() => {
|
const messagesReady = createMemo(() => {
|
||||||
@@ -223,6 +274,16 @@ export default function Page() {
|
|||||||
if (!id) return true
|
if (!id) return true
|
||||||
return sync.data.message[id] !== undefined
|
return sync.data.message[id] !== undefined
|
||||||
})
|
})
|
||||||
|
const historyMore = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return false
|
||||||
|
return sync.session.history.more(id)
|
||||||
|
})
|
||||||
|
const historyLoading = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return false
|
||||||
|
return sync.session.history.loading(id)
|
||||||
|
})
|
||||||
const emptyUserMessages: UserMessage[] = []
|
const emptyUserMessages: UserMessage[] = []
|
||||||
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
|
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
|
||||||
const visibleUserMessages = createMemo(() => {
|
const visibleUserMessages = createMemo(() => {
|
||||||
@@ -249,11 +310,20 @@ export default function Page() {
|
|||||||
activeTerminalDraggable: undefined as string | undefined,
|
activeTerminalDraggable: undefined as string | undefined,
|
||||||
expanded: {} as Record<string, boolean>,
|
expanded: {} as Record<string, boolean>,
|
||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
|
turnStart: 0,
|
||||||
mobileTab: "session" as "session" | "review",
|
mobileTab: "session" as "session" | "review",
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
promptHeight: 0,
|
promptHeight: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const renderedUserMessages = createMemo(() => {
|
||||||
|
const msgs = visibleUserMessages()
|
||||||
|
const start = store.turnStart
|
||||||
|
if (start <= 0) return msgs
|
||||||
|
if (start >= msgs.length) return emptyUserMessages
|
||||||
|
return msgs.slice(start)
|
||||||
|
}, emptyUserMessages)
|
||||||
|
|
||||||
const newSessionWorktree = createMemo(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.newSessionWorktree === "create") return "create"
|
if (store.newSessionWorktree === "create") return "create"
|
||||||
const project = sync.project
|
const project = sync.project
|
||||||
@@ -290,6 +360,12 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
|
const diffsReady = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return true
|
||||||
|
if (!hasReview()) return true
|
||||||
|
return sync.data.session_diff[id] !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
const idle = { type: "idle" as const }
|
const idle = { type: "idle" as const }
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
@@ -302,11 +378,10 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (layout.terminal.opened()) {
|
if (!view().terminal.opened()) return
|
||||||
if (terminal.all().length === 0) {
|
if (!terminal.ready()) return
|
||||||
terminal.new()
|
if (terminal.all().length !== 0) return
|
||||||
}
|
terminal.new()
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -366,7 +441,7 @@ export default function Page() {
|
|||||||
category: "View",
|
category: "View",
|
||||||
keybind: "ctrl+`",
|
keybind: "ctrl+`",
|
||||||
slash: "terminal",
|
slash: "terminal",
|
||||||
onSelect: () => layout.terminal.toggle(),
|
onSelect: () => view().terminal.toggle(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "review.toggle",
|
id: "review.toggle",
|
||||||
@@ -374,7 +449,7 @@ export default function Page() {
|
|||||||
description: "Show or hide the review panel",
|
description: "Show or hide the review panel",
|
||||||
category: "View",
|
category: "View",
|
||||||
keybind: "mod+shift+r",
|
keybind: "mod+shift+r",
|
||||||
onSelect: () => layout.review.toggle(),
|
onSelect: () => view().reviewPanel.toggle(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "terminal.new",
|
id: "terminal.new",
|
||||||
@@ -571,6 +646,15 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "session.fork",
|
||||||
|
title: "Fork from message",
|
||||||
|
description: "Create a new session from a previous message",
|
||||||
|
category: "Session",
|
||||||
|
slash: "fork",
|
||||||
|
disabled: !params.id || visibleUserMessages().length === 0,
|
||||||
|
onSelect: () => dialog.show(() => <DialogFork />),
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -643,11 +727,11 @@ export default function Page() {
|
|||||||
.filter((tab) => tab !== "context"),
|
.filter((tab) => tab !== "context"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
|
const reviewTab = createMemo(() => hasReview() || tabs().active() === "review")
|
||||||
const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")
|
const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review")
|
||||||
|
|
||||||
const showTabs = createMemo(
|
const showTabs = createMemo(
|
||||||
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
|
() => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()),
|
||||||
)
|
)
|
||||||
|
|
||||||
const activeTab = createMemo(() => {
|
const activeTab = createMemo(() => {
|
||||||
@@ -664,10 +748,22 @@ export default function Page() {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!layout.ready()) return
|
if (!layout.ready()) return
|
||||||
if (tabs().active()) return
|
if (tabs().active()) return
|
||||||
if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return
|
if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return
|
||||||
tabs().setActive(activeTab())
|
tabs().setActive(activeTab())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
if (!hasReview()) return
|
||||||
|
|
||||||
|
const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : store.mobileTab === "review"
|
||||||
|
if (!wants) return
|
||||||
|
if (diffsReady()) return
|
||||||
|
|
||||||
|
sync.session.diff(id)
|
||||||
|
})
|
||||||
|
|
||||||
const isWorking = createMemo(() => status().type !== "idle")
|
const isWorking = createMemo(() => status().type !== "idle")
|
||||||
const autoScroll = createAutoScroll({
|
const autoScroll = createAutoScroll({
|
||||||
working: isWorking,
|
working: isWorking,
|
||||||
@@ -683,6 +779,88 @@ export default function Page() {
|
|||||||
autoScroll.scrollRef(el)
|
autoScroll.scrollRef(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const turnInit = 20
|
||||||
|
const turnBatch = 20
|
||||||
|
let turnHandle: number | undefined
|
||||||
|
let turnIdle = false
|
||||||
|
|
||||||
|
function cancelTurnBackfill() {
|
||||||
|
const handle = turnHandle
|
||||||
|
if (handle === undefined) return
|
||||||
|
turnHandle = undefined
|
||||||
|
|
||||||
|
if (turnIdle && window.cancelIdleCallback) {
|
||||||
|
window.cancelIdleCallback(handle)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTurnBackfill() {
|
||||||
|
if (turnHandle !== undefined) return
|
||||||
|
if (store.turnStart <= 0) return
|
||||||
|
|
||||||
|
if (window.requestIdleCallback) {
|
||||||
|
turnIdle = true
|
||||||
|
turnHandle = window.requestIdleCallback(() => {
|
||||||
|
turnHandle = undefined
|
||||||
|
backfillTurns()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
turnIdle = false
|
||||||
|
turnHandle = window.setTimeout(() => {
|
||||||
|
turnHandle = undefined
|
||||||
|
backfillTurns()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function backfillTurns() {
|
||||||
|
const start = store.turnStart
|
||||||
|
if (start <= 0) return
|
||||||
|
|
||||||
|
const next = start - turnBatch
|
||||||
|
const nextStart = next > 0 ? next : 0
|
||||||
|
|
||||||
|
const el = scroller
|
||||||
|
if (!el) {
|
||||||
|
setStore("turnStart", nextStart)
|
||||||
|
scheduleTurnBackfill()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeTop = el.scrollTop
|
||||||
|
const beforeHeight = el.scrollHeight
|
||||||
|
|
||||||
|
setStore("turnStart", nextStart)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const delta = el.scrollHeight - beforeHeight
|
||||||
|
if (delta) el.scrollTop = beforeTop + delta
|
||||||
|
})
|
||||||
|
|
||||||
|
scheduleTurnBackfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [params.id, messagesReady()] as const,
|
||||||
|
([id, ready]) => {
|
||||||
|
cancelTurnBackfill()
|
||||||
|
setStore("turnStart", 0)
|
||||||
|
if (!id || !ready) return
|
||||||
|
|
||||||
|
const len = visibleUserMessages().length
|
||||||
|
const start = len > turnInit ? len - turnInit : 0
|
||||||
|
setStore("turnStart", start)
|
||||||
|
scheduleTurnBackfill()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
createResizeObserver(
|
createResizeObserver(
|
||||||
() => promptDock,
|
() => promptDock,
|
||||||
({ height }) => {
|
({ height }) => {
|
||||||
@@ -710,6 +888,21 @@ export default function Page() {
|
|||||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||||
setActiveMessage(message)
|
setActiveMessage(message)
|
||||||
|
|
||||||
|
const msgs = visibleUserMessages()
|
||||||
|
const index = msgs.findIndex((m) => m.id === message.id)
|
||||||
|
if (index !== -1 && index < store.turnStart) {
|
||||||
|
setStore("turnStart", index)
|
||||||
|
scheduleTurnBackfill()
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = document.getElementById(anchor(message.id))
|
||||||
|
if (el) el.scrollIntoView({ behavior, block: "start" })
|
||||||
|
})
|
||||||
|
|
||||||
|
updateHash(message.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const el = document.getElementById(anchor(message.id))
|
const el = document.getElementById(anchor(message.id))
|
||||||
if (el) el.scrollIntoView({ behavior, block: "start" })
|
if (el) el.scrollIntoView({ behavior, block: "start" })
|
||||||
updateHash(message.id)
|
updateHash(message.id)
|
||||||
@@ -755,12 +948,27 @@ export default function Page() {
|
|||||||
if (!sessionID || !ready) return
|
if (!sessionID || !ready) return
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const id = window.location.hash.slice(1)
|
const hash = window.location.hash.slice(1)
|
||||||
const hashTarget = id ? document.getElementById(id) : undefined
|
if (!hash) {
|
||||||
|
autoScroll.forceScrollToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashTarget = document.getElementById(hash)
|
||||||
if (hashTarget) {
|
if (hashTarget) {
|
||||||
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
|
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const match = hash.match(/^message-(.+)$/)
|
||||||
|
if (match) {
|
||||||
|
const msg = visibleUserMessages().find((m) => m.id === match[1])
|
||||||
|
if (msg) {
|
||||||
|
scrollToMessage(msg, "auto")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
autoScroll.forceScrollToBottom()
|
autoScroll.forceScrollToBottom()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -769,7 +977,43 @@ export default function Page() {
|
|||||||
document.addEventListener("keydown", handleKeyDown)
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const previewPrompt = () =>
|
||||||
|
prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => {
|
||||||
|
if (part.type === "file") return `[file:${part.path}]`
|
||||||
|
if (part.type === "agent") return `@${part.name}`
|
||||||
|
if (part.type === "image") return `[image:${part.filename}]`
|
||||||
|
return part.content
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!prompt.ready()) return
|
||||||
|
handoff.prompt = previewPrompt()
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!terminal.ready()) return
|
||||||
|
handoff.terminals = terminal.all().map((t) => t.title)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!file.ready()) return
|
||||||
|
handoff.files = Object.fromEntries(
|
||||||
|
tabs()
|
||||||
|
.all()
|
||||||
|
.flatMap((tab) => {
|
||||||
|
const path = file.pathFromTab(tab)
|
||||||
|
if (!path) return []
|
||||||
|
return [[path, file.selectedLines(path) ?? null] as const]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
cancelTurnBackfill()
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
||||||
})
|
})
|
||||||
@@ -779,7 +1023,7 @@ export default function Page() {
|
|||||||
<SessionHeader />
|
<SessionHeader />
|
||||||
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||||
{/* Mobile tab bar - only shown on mobile when there are diffs */}
|
{/* Mobile tab bar - only shown on mobile when there are diffs */}
|
||||||
<Show when={!isDesktop() && diffs().length > 0}>
|
<Show when={!isDesktop() && hasReview()}>
|
||||||
<Tabs class="h-auto">
|
<Tabs class="h-auto">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
@@ -796,7 +1040,7 @@ export default function Page() {
|
|||||||
classes={{ button: "w-full" }}
|
classes={{ button: "w-full" }}
|
||||||
onClick={() => setStore("mobileTab", "review")}
|
onClick={() => setStore("mobileTab", "review")}
|
||||||
>
|
>
|
||||||
{diffs().length} Files Changed
|
{reviewCount()} Files Changed
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -821,21 +1065,26 @@ export default function Page() {
|
|||||||
when={!mobileReview()}
|
when={!mobileReview()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="relative h-full overflow-hidden">
|
<div class="relative h-full overflow-hidden">
|
||||||
<SessionReviewTab
|
<Show
|
||||||
diffs={diffs}
|
when={diffsReady()}
|
||||||
view={view}
|
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
|
||||||
diffStyle="unified"
|
>
|
||||||
onViewFile={(path) => {
|
<SessionReviewTab
|
||||||
const value = file.tab(path)
|
diffs={diffs}
|
||||||
tabs().open(value)
|
view={view}
|
||||||
file.load(path)
|
diffStyle="unified"
|
||||||
}}
|
onViewFile={(path) => {
|
||||||
classes={{
|
const value = file.tab(path)
|
||||||
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
tabs().open(value)
|
||||||
header: "px-4",
|
file.load(path)
|
||||||
container: "px-4",
|
}}
|
||||||
}}
|
classes={{
|
||||||
/>
|
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
||||||
|
header: "px-4",
|
||||||
|
container: "px-4",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -868,42 +1117,82 @@ export default function Page() {
|
|||||||
"mt-0": showTabs(),
|
"mt-0": showTabs(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<For each={visibleUserMessages()}>
|
<Show when={store.turnStart > 0}>
|
||||||
{(message) => (
|
<div class="w-full flex justify-center">
|
||||||
<div
|
<Button
|
||||||
id={anchor(message.id)}
|
variant="ghost"
|
||||||
data-message-id={message.id}
|
size="large"
|
||||||
classList={{
|
class="text-12-medium opacity-50"
|
||||||
"min-w-0 w-full max-w-full": true,
|
onClick={() => setStore("turnStart", 0)}
|
||||||
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
|
>
|
||||||
platform.platform !== "desktop",
|
Render earlier messages
|
||||||
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
|
</Button>
|
||||||
platform.platform === "desktop",
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={historyMore()}>
|
||||||
|
<div class="w-full flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="large"
|
||||||
|
class="text-12-medium opacity-50"
|
||||||
|
disabled={historyLoading()}
|
||||||
|
onClick={() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
setStore("turnStart", 0)
|
||||||
|
sync.session.history.loadMore(id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SessionTurn
|
{historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
|
||||||
sessionID={params.id!}
|
</Button>
|
||||||
messageID={message.id}
|
</div>
|
||||||
lastUserMessageID={lastUserMessage()?.id}
|
</Show>
|
||||||
stepsExpanded={store.expanded[message.id] ?? false}
|
<For each={renderedUserMessages()}>
|
||||||
onStepsExpandedToggle={() =>
|
{(message) => {
|
||||||
setStore("expanded", message.id, (open: boolean | undefined) => !open)
|
if (import.meta.env.DEV) {
|
||||||
}
|
onMount(() => {
|
||||||
classes={{
|
const id = params.id
|
||||||
root: "min-w-0 w-full relative",
|
if (!id) return
|
||||||
content:
|
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
|
||||||
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
})
|
||||||
container:
|
}
|
||||||
"px-4 md:px-6 " +
|
|
||||||
(!showTabs()
|
return (
|
||||||
? "md:max-w-200 md:mx-auto"
|
<div
|
||||||
: visibleUserMessages().length > 1
|
id={anchor(message.id)}
|
||||||
? "md:pr-6 md:pl-18"
|
data-message-id={message.id}
|
||||||
: ""),
|
classList={{
|
||||||
|
"min-w-0 w-full max-w-full": true,
|
||||||
|
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
|
||||||
|
platform.platform !== "desktop",
|
||||||
|
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
|
||||||
|
platform.platform === "desktop",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</div>
|
<SessionTurn
|
||||||
)}
|
sessionID={params.id!}
|
||||||
|
messageID={message.id}
|
||||||
|
lastUserMessageID={lastUserMessage()?.id}
|
||||||
|
stepsExpanded={store.expanded[message.id] ?? false}
|
||||||
|
onStepsExpandedToggle={() =>
|
||||||
|
setStore("expanded", message.id, (open: boolean | undefined) => !open)
|
||||||
|
}
|
||||||
|
classes={{
|
||||||
|
root: "min-w-0 w-full relative",
|
||||||
|
content:
|
||||||
|
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
||||||
|
container:
|
||||||
|
"px-4 md:px-6 " +
|
||||||
|
(!showTabs()
|
||||||
|
? "md:max-w-200 md:mx-auto"
|
||||||
|
: visibleUserMessages().length > 1
|
||||||
|
? "md:pr-6 md:pl-18"
|
||||||
|
: ""),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -944,13 +1233,22 @@ export default function Page() {
|
|||||||
"md:max-w-200": !showTabs(),
|
"md:max-w-200": !showTabs(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PromptInput
|
<Show
|
||||||
ref={(el) => {
|
when={prompt.ready()}
|
||||||
inputRef = el
|
fallback={
|
||||||
}}
|
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||||
newSessionWorktree={newSessionWorktree()}
|
{handoff.prompt || "Loading prompt..."}
|
||||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
</div>
|
||||||
/>
|
}
|
||||||
|
>
|
||||||
|
<PromptInput
|
||||||
|
ref={(el) => {
|
||||||
|
inputRef = el
|
||||||
|
}}
|
||||||
|
newSessionWorktree={newSessionWorktree()}
|
||||||
|
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1034,31 +1332,40 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
<Show when={reviewTab()}>
|
<Show when={reviewTab()}>
|
||||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<Show when={activeTab() === "review"}>
|
||||||
<SessionReviewTab
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
diffs={diffs}
|
<Show
|
||||||
view={view}
|
when={diffsReady()}
|
||||||
diffStyle={layout.review.diffStyle()}
|
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
|
||||||
onDiffStyleChange={layout.review.setDiffStyle}
|
>
|
||||||
onViewFile={(path) => {
|
<SessionReviewTab
|
||||||
const value = file.tab(path)
|
diffs={diffs}
|
||||||
tabs().open(value)
|
view={view}
|
||||||
file.load(path)
|
diffStyle={layout.review.diffStyle()}
|
||||||
}}
|
onDiffStyleChange={layout.review.setDiffStyle}
|
||||||
/>
|
onViewFile={(path) => {
|
||||||
</div>
|
const value = file.tab(path)
|
||||||
|
tabs().open(value)
|
||||||
|
file.load(path)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={contextOpen()}>
|
<Show when={contextOpen()}>
|
||||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<Show when={activeTab() === "context"}>
|
||||||
<SessionContextTab
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
messages={messages}
|
<SessionContextTab
|
||||||
visibleUserMessages={visibleUserMessages}
|
messages={messages}
|
||||||
view={view}
|
visibleUserMessages={visibleUserMessages}
|
||||||
info={info}
|
view={view}
|
||||||
/>
|
info={info}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
</Show>
|
||||||
<For each={openedTabs()}>
|
<For each={openedTabs()}>
|
||||||
@@ -1107,7 +1414,8 @@ export default function Page() {
|
|||||||
const selectedLines = createMemo(() => {
|
const selectedLines = createMemo(() => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
return file.selectedLines(p) ?? null
|
if (file.ready()) return file.selectedLines(p) ?? null
|
||||||
|
return handoff.files[p] ?? null
|
||||||
})
|
})
|
||||||
const selection = createMemo(() => {
|
const selection = createMemo(() => {
|
||||||
const range = selectedLines()
|
const range = selectedLines()
|
||||||
@@ -1204,37 +1512,63 @@ export default function Page() {
|
|||||||
}}
|
}}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<Show when={selection()}>
|
<Show when={activeTab() === tab}>
|
||||||
{(sel) => (
|
<Show when={selection()}>
|
||||||
<div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
|
{(sel) => (
|
||||||
<button
|
<div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
|
||||||
type="button"
|
<button
|
||||||
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
|
type="button"
|
||||||
onClick={() => {
|
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
|
||||||
const p = path()
|
onClick={() => {
|
||||||
if (!p) return
|
const p = path()
|
||||||
prompt.context.add({ type: "file", path: p, selection: sel() })
|
if (!p) return
|
||||||
}}
|
prompt.context.add({ type: "file", path: p, selection: sel() })
|
||||||
>
|
}}
|
||||||
<Icon name="plus-small" size="small" />
|
>
|
||||||
<span>Add {selectionLabel()} to context</span>
|
<Icon name="plus-small" size="small" />
|
||||||
</button>
|
<span>Add {selectionLabel()} to context</span>
|
||||||
</div>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</Show>
|
)}
|
||||||
<Switch>
|
</Show>
|
||||||
<Match when={state()?.loaded && isImage()}>
|
<Switch>
|
||||||
<div class="px-6 py-4 pb-40">
|
<Match when={state()?.loaded && isImage()}>
|
||||||
<img src={imageDataUrl()} alt={path()} class="max-w-full" />
|
<div class="px-6 py-4 pb-40">
|
||||||
</div>
|
<img src={imageDataUrl()} alt={path()} class="max-w-full" />
|
||||||
</Match>
|
</div>
|
||||||
<Match when={state()?.loaded && isSvg()}>
|
</Match>
|
||||||
<div class="flex flex-col gap-4 px-6 py-4">
|
<Match when={state()?.loaded && isSvg()}>
|
||||||
|
<div class="flex flex-col gap-4 px-6 py-4">
|
||||||
|
<Dynamic
|
||||||
|
component={codeComponent}
|
||||||
|
file={{
|
||||||
|
name: path() ?? "",
|
||||||
|
contents: svgContent() ?? "",
|
||||||
|
cacheKey: cacheKey(),
|
||||||
|
}}
|
||||||
|
enableLineSelection
|
||||||
|
selectedLines={selectedLines()}
|
||||||
|
onLineSelected={(range: SelectedLineRange | null) => {
|
||||||
|
const p = path()
|
||||||
|
if (!p) return
|
||||||
|
file.setSelectedLines(p, range)
|
||||||
|
}}
|
||||||
|
overflow="scroll"
|
||||||
|
class="select-text"
|
||||||
|
/>
|
||||||
|
<Show when={svgPreviewUrl()}>
|
||||||
|
<div class="flex justify-center pb-40">
|
||||||
|
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={state()?.loaded}>
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={codeComponent}
|
component={codeComponent}
|
||||||
file={{
|
file={{
|
||||||
name: path() ?? "",
|
name: path() ?? "",
|
||||||
contents: svgContent() ?? "",
|
contents: contents(),
|
||||||
cacheKey: cacheKey(),
|
cacheKey: cacheKey(),
|
||||||
}}
|
}}
|
||||||
enableLineSelection
|
enableLineSelection
|
||||||
@@ -1245,41 +1579,17 @@ export default function Page() {
|
|||||||
file.setSelectedLines(p, range)
|
file.setSelectedLines(p, range)
|
||||||
}}
|
}}
|
||||||
overflow="scroll"
|
overflow="scroll"
|
||||||
class="select-text"
|
class="select-text pb-40"
|
||||||
/>
|
/>
|
||||||
<Show when={svgPreviewUrl()}>
|
</Match>
|
||||||
<div class="flex justify-center pb-40">
|
<Match when={state()?.loading}>
|
||||||
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
<div class="px-6 py-4 text-text-weak">Loading...</div>
|
||||||
</div>
|
</Match>
|
||||||
</Show>
|
<Match when={state()?.error}>
|
||||||
</div>
|
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={state()?.loaded}>
|
</Switch>
|
||||||
<Dynamic
|
</Show>
|
||||||
component={codeComponent}
|
|
||||||
file={{
|
|
||||||
name: path() ?? "",
|
|
||||||
contents: contents(),
|
|
||||||
cacheKey: cacheKey(),
|
|
||||||
}}
|
|
||||||
enableLineSelection
|
|
||||||
selectedLines={selectedLines()}
|
|
||||||
onLineSelected={(range: SelectedLineRange | null) => {
|
|
||||||
const p = path()
|
|
||||||
if (!p) return
|
|
||||||
file.setSelectedLines(p, range)
|
|
||||||
}}
|
|
||||||
overflow="scroll"
|
|
||||||
class="select-text pb-40"
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={state()?.loading}>
|
|
||||||
<div class="px-6 py-4 text-text-weak">Loading...</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={state()?.error}>
|
|
||||||
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -1302,7 +1612,7 @@ export default function Page() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isDesktop() && layout.terminal.opened()}>
|
<Show when={isDesktop() && view().terminal.opened()}>
|
||||||
<div
|
<div
|
||||||
class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
|
class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
|
||||||
style={{ height: `${layout.terminal.height()}px` }}
|
style={{ height: `${layout.terminal.height()}px` }}
|
||||||
@@ -1314,56 +1624,76 @@ export default function Page() {
|
|||||||
max={window.innerHeight * 0.6}
|
max={window.innerHeight * 0.6}
|
||||||
collapseThreshold={50}
|
collapseThreshold={50}
|
||||||
onResize={layout.terminal.resize}
|
onResize={layout.terminal.resize}
|
||||||
onCollapse={layout.terminal.close}
|
onCollapse={view().terminal.close}
|
||||||
/>
|
/>
|
||||||
<DragDropProvider
|
<Show
|
||||||
onDragStart={handleTerminalDragStart}
|
when={terminal.ready()}
|
||||||
onDragEnd={handleTerminalDragEnd}
|
fallback={
|
||||||
onDragOver={handleTerminalDragOver}
|
<div class="flex flex-col h-full pointer-events-none">
|
||||||
collisionDetector={closestCenter}
|
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
||||||
>
|
<For each={handoff.terminals}>
|
||||||
<DragDropSensors />
|
{(title) => (
|
||||||
<ConstrainDragYAxis />
|
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||||
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
|
{title}
|
||||||
<Tabs.List class="h-10">
|
</div>
|
||||||
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
)}
|
||||||
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
</For>
|
||||||
</SortableProvider>
|
<div class="flex-1" />
|
||||||
<div class="h-full flex items-center justify-center">
|
<div class="text-text-weak pr-2">Loading...</div>
|
||||||
<TooltipKeybind
|
|
||||||
title="New terminal"
|
|
||||||
keybind={command.keybind("terminal.new")}
|
|
||||||
class="flex items-center"
|
|
||||||
>
|
|
||||||
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
</div>
|
||||||
</Tabs.List>
|
<div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div>
|
||||||
<For each={terminal.all()}>
|
</div>
|
||||||
{(pty) => (
|
}
|
||||||
<Tabs.Content value={pty.id}>
|
>
|
||||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
<DragDropProvider
|
||||||
</Tabs.Content>
|
onDragStart={handleTerminalDragStart}
|
||||||
)}
|
onDragEnd={handleTerminalDragEnd}
|
||||||
</For>
|
onDragOver={handleTerminalDragOver}
|
||||||
</Tabs>
|
collisionDetector={closestCenter}
|
||||||
<DragOverlay>
|
>
|
||||||
<Show when={store.activeTerminalDraggable}>
|
<DragDropSensors />
|
||||||
{(draggedId) => {
|
<ConstrainDragYAxis />
|
||||||
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
|
||||||
return (
|
<Tabs.List class="h-10">
|
||||||
<Show when={pty()}>
|
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
||||||
{(t) => (
|
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
||||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
</SortableProvider>
|
||||||
{t().title}
|
<div class="h-full flex items-center justify-center">
|
||||||
</div>
|
<TooltipKeybind
|
||||||
)}
|
title="New terminal"
|
||||||
</Show>
|
keybind={command.keybind("terminal.new")}
|
||||||
)
|
class="flex items-center"
|
||||||
}}
|
>
|
||||||
</Show>
|
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
||||||
</DragOverlay>
|
</TooltipKeybind>
|
||||||
</DragDropProvider>
|
</div>
|
||||||
|
</Tabs.List>
|
||||||
|
<For each={terminal.all()}>
|
||||||
|
{(pty) => (
|
||||||
|
<Tabs.Content value={pty.id}>
|
||||||
|
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
||||||
|
</Tabs.Content>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Tabs>
|
||||||
|
<DragOverlay>
|
||||||
|
<Show when={store.activeTerminalDraggable}>
|
||||||
|
{(draggedId) => {
|
||||||
|
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
||||||
|
return (
|
||||||
|
<Show when={pty()}>
|
||||||
|
{(t) => (
|
||||||
|
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||||
|
{t().title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
type Nav = {
|
||||||
|
id: string
|
||||||
|
dir?: string
|
||||||
|
from?: string
|
||||||
|
to: string
|
||||||
|
trigger?: string
|
||||||
|
start: number
|
||||||
|
marks: Record<string, number>
|
||||||
|
logged: boolean
|
||||||
|
timer?: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
const dev = import.meta.env.DEV
|
||||||
|
|
||||||
|
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
|
||||||
|
|
||||||
|
const now = () => performance.now()
|
||||||
|
|
||||||
|
const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)
|
||||||
|
|
||||||
|
const navs = new Map<string, Nav>()
|
||||||
|
const pending = new Map<string, string>()
|
||||||
|
const active = new Map<string, string>()
|
||||||
|
|
||||||
|
const required = [
|
||||||
|
"session:params",
|
||||||
|
"session:data-ready",
|
||||||
|
"session:first-turn-mounted",
|
||||||
|
"storage:prompt-ready",
|
||||||
|
"storage:terminal-ready",
|
||||||
|
"storage:file-view-ready",
|
||||||
|
]
|
||||||
|
|
||||||
|
function flush(id: string, reason: "complete" | "timeout") {
|
||||||
|
if (!dev) return
|
||||||
|
const nav = navs.get(id)
|
||||||
|
if (!nav) return
|
||||||
|
if (nav.logged) return
|
||||||
|
|
||||||
|
nav.logged = true
|
||||||
|
if (nav.timer) clearTimeout(nav.timer)
|
||||||
|
|
||||||
|
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
|
||||||
|
const base = nav.marks[baseName] ?? nav.start
|
||||||
|
|
||||||
|
const ms = Object.fromEntries(
|
||||||
|
Object.entries(nav.marks)
|
||||||
|
.slice()
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"perf.session-nav " +
|
||||||
|
JSON.stringify({
|
||||||
|
type: "perf.session-nav.v0",
|
||||||
|
id: nav.id,
|
||||||
|
dir: nav.dir,
|
||||||
|
from: nav.from,
|
||||||
|
to: nav.to,
|
||||||
|
trigger: nav.trigger,
|
||||||
|
base: baseName,
|
||||||
|
reason,
|
||||||
|
ms,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
navs.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeFlush(id: string) {
|
||||||
|
if (!dev) return
|
||||||
|
const nav = navs.get(id)
|
||||||
|
if (!nav) return
|
||||||
|
if (nav.logged) return
|
||||||
|
if (!required.every((name) => nav.marks[name] !== undefined)) return
|
||||||
|
flush(id, "complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
|
||||||
|
const existing = navs.get(id)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const nav: Nav = {
|
||||||
|
...data,
|
||||||
|
marks: {},
|
||||||
|
logged: false,
|
||||||
|
}
|
||||||
|
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
|
||||||
|
navs.set(id, nav)
|
||||||
|
return nav
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
|
||||||
|
if (!dev) return
|
||||||
|
|
||||||
|
const id = uid()
|
||||||
|
const start = now()
|
||||||
|
const nav = ensure(id, { ...input, id, start })
|
||||||
|
nav.marks["navigate:start"] = start
|
||||||
|
|
||||||
|
pending.set(key(input.dir, input.to), id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navParams(input: { dir?: string; from?: string; to: string }) {
|
||||||
|
if (!dev) return
|
||||||
|
|
||||||
|
const k = key(input.dir, input.to)
|
||||||
|
const pendingId = pending.get(k)
|
||||||
|
if (pendingId) pending.delete(k)
|
||||||
|
const id = pendingId ?? uid()
|
||||||
|
|
||||||
|
const start = now()
|
||||||
|
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
|
||||||
|
nav.marks["session:params"] = start
|
||||||
|
|
||||||
|
active.set(k, id)
|
||||||
|
maybeFlush(id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navMark(input: { dir?: string; to: string; name: string }) {
|
||||||
|
if (!dev) return
|
||||||
|
|
||||||
|
const id = active.get(key(input.dir, input.to))
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const nav = navs.get(id)
|
||||||
|
if (!nav) return
|
||||||
|
if (nav.marks[input.name] !== undefined) return
|
||||||
|
|
||||||
|
nav.marks[input.name] = now()
|
||||||
|
maybeFlush(id)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"nitro": "3.0.1-alpha.1",
|
"nitro": "3.0.1-alpha.1",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
|
"solid-list": "0.3.0",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -9,8 +9,8 @@ export const config = {
|
|||||||
github: {
|
github: {
|
||||||
repoUrl: "https://github.com/anomalyco/opencode",
|
repoUrl: "https://github.com/anomalyco/opencode",
|
||||||
starsFormatted: {
|
starsFormatted: {
|
||||||
compact: "50K",
|
compact: "60K",
|
||||||
full: "50,000",
|
full: "60,000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
[data-page="black"] {
|
||||||
|
background: #000;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
[data-component="header-gradient"] {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 288px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="header"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
/* [data-component="header-logo"] { */
|
||||||
|
/* } */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="content"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
[data-slot="hero-black"] {
|
||||||
|
margin-top: 110px;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
margin-top: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="cta"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: -18px;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="heading"] {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 160%; /* 28.8px */
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[data-slot="subheading"] {
|
||||||
|
color: rgba(255, 255, 255, 0.59);
|
||||||
|
font-size: 15px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 160%;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 160%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[data-slot="button"] {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
text-decoration: none;
|
||||||
|
color: #000;
|
||||||
|
font-family: "JetBrains Mono Nerd Font";
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: normal;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[data-slot="back-soon"] {
|
||||||
|
color: rgba(255, 255, 255, 0.59);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 160%; /* 20.8px */
|
||||||
|
}
|
||||||
|
[data-slot="follow-us"] {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||||
|
color: rgba(255, 255, 255, 0.59);
|
||||||
|
font-family: "JetBrains Mono Nerd Font";
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="fine-print"] {
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 160%; /* 20.8px */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="footer"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="footer-content"] {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span,
|
||||||
|
a {
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
font-family: "JetBrains Mono Nerd Font";
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="github-stars"] {
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
font-family: "JetBrains Mono Nerd Font";
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="anomaly"] {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[data-slot="anomaly-alt"] {
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
font-family: "JetBrains Mono Nerd Font";
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
font-family: "JetBrains Mono Nerd Font";
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,214 @@
|
|||||||
|
[data-page="black"] {
|
||||||
|
background: #000;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
[data-component="header-gradient"] {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 288px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="header"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
/* [data-component="header-logo"] { */
|
||||||
|
/* } */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="content"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
[data-slot="hero-black"] {
|
||||||
|
margin-top: 110px;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
margin-top: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="select-workspace"] {
|
||||||
|
display: flex;
|
||||||
|
margin-top: -24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
height: 305px;
|
||||||
|
padding: 32px 20px 0 20px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
border: 1px solid #303030;
|
||||||
|
background: #0a0a0a;
|
||||||
|
box-shadow:
|
||||||
|
0 100px 80px 0 rgba(0, 0, 0, 0.04),
|
||||||
|
0 41.778px 33.422px 0 rgba(0, 0, 0, 0.05),
|
||||||
|
0 22.336px 17.869px 0 rgba(0, 0, 0, 0.06),
|
||||||
|
0 12.522px 10.017px 0 rgba(0, 0, 0, 0.08),
|
||||||
|
0 6.65px 5.32px 0 rgba(0, 0, 0, 0.09),
|
||||||
|
0 2.767px 2.214px 0 rgba(0, 0, 0, 0.13);
|
||||||
|
|
||||||
|
[data-slot="select-workspace-title"] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
color: rgba(255, 255, 255, 0.59);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 160%; /* 25.6px */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="workspaces"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
align-self: stretch;
|
||||||
|
outline: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="workspace"] {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
align-self: stretch;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
[data-slot="selected-icon"] {
|
||||||
|
visibility: hidden;
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
font-family: "IBM Plex Mono";
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 160%; /* 25.6px */
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 160%; /* 25.6px */
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="workspace"]:hover,
|
||||||
|
[data-slot="workspace"][data-active="true"] {
|
||||||
|
background: #161616;
|
||||||
|
|
||||||
|
[data-slot="selected-icon"] {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="footer"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="footer-content"] {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span,
|
||||||
|
a {
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
font-family: "JetBrains Mono Nerd Font";
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="github-stars"] {
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
font-family: "JetBrains Mono Nerd Font";
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="anomaly"] {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[data-slot="anomaly-alt"] {
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
font-family: "JetBrains Mono Nerd Font";
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(255, 255, 255, 0.39);
|
||||||
|
font-family: "JetBrains Mono Nerd Font";
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -129,9 +129,9 @@ export default function Download() {
|
|||||||
</code>
|
</code>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
<button data-component="cli-row" onClick={handleCopyClick("brew install opencode")}>
|
<button data-component="cli-row" onClick={handleCopyClick("brew install anomalyco/tap/opencode")}>
|
||||||
<code>
|
<code>
|
||||||
brew install <strong>opencode</strong>
|
brew install <strong>anomalyco/tap/opencode</strong>
|
||||||
</code>
|
</code>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
@@ -244,7 +244,8 @@ export default function Download() {
|
|||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div data-component="download-row">
|
{/* Disabled temporarily as it doesn't work */}
|
||||||
|
{/*<div data-component="download-row">
|
||||||
<div data-component="download-info">
|
<div data-component="download-info">
|
||||||
<span data-slot="icon">
|
<span data-slot="icon">
|
||||||
<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">
|
||||||
@@ -259,7 +260,7 @@ export default function Download() {
|
|||||||
<a href={getDownloadHref("linux-x64-appimage")} data-component="action-button">
|
<a href={getDownloadHref("linux-x64-appimage")} data-component="action-button">
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>*/}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ body {
|
|||||||
[data-slot="content"] {
|
[data-slot="content"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 1ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="text"] {
|
[data-slot="text"] {
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export default function Home() {
|
|||||||
<button data-copy data-slot="command" onClick={handleCopyClick}>
|
<button data-copy data-slot="command" onClick={handleCopyClick}>
|
||||||
<span>
|
<span>
|
||||||
<span data-slot="protocol">brew install </span>
|
<span data-slot="protocol">brew install </span>
|
||||||
<span data-slot="highlight">opencode</span>
|
<span data-slot="highlight">anomalyco/tap/opencode</span>
|
||||||
</span>
|
</span>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
@@ -195,6 +195,12 @@ export default function Home() {
|
|||||||
<strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account
|
<strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>[*]</span>
|
||||||
|
<div>
|
||||||
|
<strong>ChatGPT Plus/Pro</strong> Log in with OpenAI to use your ChatGPT Plus or Pro account
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>[*]</span>
|
<span>[*]</span>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
|
||||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||||
|
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
|
||||||
|
|
||||||
export async function POST(input: APIEvent) {
|
export async function POST(input: APIEvent) {
|
||||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||||
@@ -40,7 +41,7 @@ export async function POST(input: APIEvent) {
|
|||||||
.where(eq(BillingTable.customerID, customerID))
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "checkout.session.completed") {
|
if (body.type === "checkout.session.completed" && body.data.object.mode === "payment") {
|
||||||
const workspaceID = body.data.object.metadata?.workspaceID
|
const workspaceID = body.data.object.metadata?.workspaceID
|
||||||
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
||||||
const customerID = body.data.object.customer as string
|
const customerID = body.data.object.customer as string
|
||||||
@@ -103,85 +104,112 @@ export async function POST(input: APIEvent) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "charge.refunded") {
|
if (body.type === "checkout.session.completed" && body.data.object.mode === "subscription") {
|
||||||
|
const workspaceID = body.data.object.custom_fields.find((f) => f.key === "workspaceid")?.text?.value
|
||||||
|
const amountInCents = body.data.object.amount_total as number
|
||||||
const customerID = body.data.object.customer as string
|
const customerID = body.data.object.customer as string
|
||||||
const paymentIntentID = body.data.object.payment_intent as string
|
const customerEmail = body.data.object.customer_details?.email as string
|
||||||
if (!customerID) throw new Error("Customer ID not found")
|
const invoiceID = body.data.object.invoice as string
|
||||||
if (!paymentIntentID) throw new Error("Payment ID not found")
|
const subscriptionID = body.data.object.subscription as string
|
||||||
|
const promoCode = body.data.object.discounts?.[0]?.promotion_code as string
|
||||||
|
|
||||||
const workspaceID = await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.select({
|
|
||||||
workspaceID: BillingTable.workspaceID,
|
|
||||||
})
|
|
||||||
.from(BillingTable)
|
|
||||||
.where(eq(BillingTable.customerID, customerID))
|
|
||||||
.then((rows) => rows[0]?.workspaceID),
|
|
||||||
)
|
|
||||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||||
|
|
||||||
const amount = await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.select({
|
|
||||||
amount: PaymentTable.amount,
|
|
||||||
})
|
|
||||||
.from(PaymentTable)
|
|
||||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
|
||||||
.then((rows) => rows[0]?.amount),
|
|
||||||
)
|
|
||||||
if (!amount) throw new Error("Payment not found")
|
|
||||||
|
|
||||||
await Database.transaction(async (tx) => {
|
|
||||||
await tx
|
|
||||||
.update(PaymentTable)
|
|
||||||
.set({
|
|
||||||
timeRefunded: new Date(body.created * 1000),
|
|
||||||
})
|
|
||||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.update(BillingTable)
|
|
||||||
.set({
|
|
||||||
balance: sql`${BillingTable.balance} - ${amount}`,
|
|
||||||
})
|
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
|
|
||||||
const invoiceID = body.data.object.id as string
|
|
||||||
const amountInCents = body.data.object.amount_paid
|
|
||||||
const customerID = body.data.object.customer as string
|
|
||||||
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
|
|
||||||
|
|
||||||
if (!customerID) throw new Error("Customer ID not found")
|
if (!customerID) throw new Error("Customer ID not found")
|
||||||
|
if (!amountInCents) throw new Error("Amount not found")
|
||||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||||
|
|
||||||
|
// get payment id from invoice
|
||||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||||
expand: ["payments"],
|
expand: ["payments"],
|
||||||
})
|
})
|
||||||
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||||
if (!paymentID) throw new Error("Payment ID not found")
|
if (!paymentID) throw new Error("Payment ID not found")
|
||||||
|
|
||||||
const workspaceID = await Database.use((tx) =>
|
// get payment method for the payment intent
|
||||||
tx
|
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
||||||
.select({ workspaceID: BillingTable.workspaceID })
|
expand: ["payment_method"],
|
||||||
.from(BillingTable)
|
})
|
||||||
.where(eq(BillingTable.customerID, customerID))
|
const paymentMethod = paymentIntent.payment_method
|
||||||
.then((rows) => rows[0]?.workspaceID),
|
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
||||||
)
|
|
||||||
if (!workspaceID) throw new Error("Workspace ID not found for customer")
|
|
||||||
|
|
||||||
await Database.use((tx) =>
|
// get coupon id from promotion code
|
||||||
tx.insert(PaymentTable).values({
|
const couponID = await (async () => {
|
||||||
workspaceID,
|
if (!promoCode) return
|
||||||
id: Identifier.create("payment"),
|
const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
|
||||||
amount: centsToMicroCents(amountInCents),
|
const couponID = coupon.coupon.id
|
||||||
paymentID,
|
if (!couponID) throw new Error("Coupon not found for promotion code")
|
||||||
invoiceID,
|
return couponID
|
||||||
customerID,
|
})()
|
||||||
}),
|
|
||||||
)
|
// get user
|
||||||
|
|
||||||
|
await Actor.provide("system", { workspaceID }, async () => {
|
||||||
|
// look up current billing
|
||||||
|
const billing = await Billing.get()
|
||||||
|
if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
|
||||||
|
|
||||||
|
// Temporarily skip this check because during Black drop, user can checkout
|
||||||
|
// as a new customer
|
||||||
|
//if (billing.customerID !== customerID) throw new Error("Customer ID mismatch")
|
||||||
|
|
||||||
|
// Temporarily check the user to apply to. After Black drop, we will allow
|
||||||
|
// look up the user to apply to
|
||||||
|
const users = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ id: UserTable.id, email: AuthTable.subject })
|
||||||
|
.from(UserTable)
|
||||||
|
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
||||||
|
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||||
|
)
|
||||||
|
const user = users.find((u) => u.email === customerEmail) ?? users[0]
|
||||||
|
if (!user) {
|
||||||
|
console.error(`Error: User with email ${customerEmail} not found in workspace ${workspaceID}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set customer metadata
|
||||||
|
if (!billing?.customerID) {
|
||||||
|
await Billing.stripe().customers.update(customerID, {
|
||||||
|
metadata: {
|
||||||
|
workspaceID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await Database.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
customerID,
|
||||||
|
subscriptionID,
|
||||||
|
subscriptionCouponID: couponID,
|
||||||
|
paymentMethodID: paymentMethod.id,
|
||||||
|
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||||
|
paymentMethodType: paymentMethod.type,
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
|
await tx.insert(SubscriptionTable).values({
|
||||||
|
workspaceID,
|
||||||
|
id: Identifier.create("subscription"),
|
||||||
|
userID: user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.insert(PaymentTable).values({
|
||||||
|
workspaceID,
|
||||||
|
id: Identifier.create("payment"),
|
||||||
|
amount: centsToMicroCents(amountInCents),
|
||||||
|
paymentID,
|
||||||
|
invoiceID,
|
||||||
|
customerID,
|
||||||
|
enrichment: {
|
||||||
|
type: "subscription",
|
||||||
|
couponID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "customer.subscription.created") {
|
if (body.type === "customer.subscription.created") {
|
||||||
const data = {
|
const data = {
|
||||||
@@ -378,9 +406,111 @@ export async function POST(input: APIEvent) {
|
|||||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||||
|
|
||||||
await Database.transaction(async (tx) => {
|
await Database.transaction(async (tx) => {
|
||||||
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({ subscriptionID: null, subscriptionCouponID: null })
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
|
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (body.type === "invoice.payment_succeeded") {
|
||||||
|
if (body.data.object.billing_reason === "subscription_cycle") {
|
||||||
|
const invoiceID = body.data.object.id as string
|
||||||
|
const amountInCents = body.data.object.amount_paid
|
||||||
|
const customerID = body.data.object.customer as string
|
||||||
|
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
|
||||||
|
|
||||||
|
if (!customerID) throw new Error("Customer ID not found")
|
||||||
|
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||||
|
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||||
|
|
||||||
|
// get coupon id from subscription
|
||||||
|
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscriptionID, {
|
||||||
|
expand: ["discounts"],
|
||||||
|
})
|
||||||
|
const couponID =
|
||||||
|
typeof subscriptionData.discounts[0] === "string"
|
||||||
|
? subscriptionData.discounts[0]
|
||||||
|
: subscriptionData.discounts[0]?.coupon?.id
|
||||||
|
|
||||||
|
// get payment id from invoice
|
||||||
|
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||||
|
expand: ["payments"],
|
||||||
|
})
|
||||||
|
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||||
|
if (!paymentID) {
|
||||||
|
// payment id can be undefined when using coupon
|
||||||
|
if (!couponID) throw new Error("Payment ID not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceID = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ workspaceID: BillingTable.workspaceID })
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
|
.then((rows) => rows[0]?.workspaceID),
|
||||||
|
)
|
||||||
|
if (!workspaceID) throw new Error("Workspace ID not found for customer")
|
||||||
|
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx.insert(PaymentTable).values({
|
||||||
|
workspaceID,
|
||||||
|
id: Identifier.create("payment"),
|
||||||
|
amount: centsToMicroCents(amountInCents),
|
||||||
|
paymentID,
|
||||||
|
invoiceID,
|
||||||
|
customerID,
|
||||||
|
enrichment: {
|
||||||
|
type: "subscription",
|
||||||
|
couponID,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.type === "charge.refunded") {
|
||||||
|
const customerID = body.data.object.customer as string
|
||||||
|
const paymentIntentID = body.data.object.payment_intent as string
|
||||||
|
if (!customerID) throw new Error("Customer ID not found")
|
||||||
|
if (!paymentIntentID) throw new Error("Payment ID not found")
|
||||||
|
|
||||||
|
const workspaceID = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
workspaceID: BillingTable.workspaceID,
|
||||||
|
})
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
|
.then((rows) => rows[0]?.workspaceID),
|
||||||
|
)
|
||||||
|
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||||
|
|
||||||
|
const amount = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
amount: PaymentTable.amount,
|
||||||
|
})
|
||||||
|
.from(PaymentTable)
|
||||||
|
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||||
|
.then((rows) => rows[0]?.amount),
|
||||||
|
)
|
||||||
|
if (!amount) throw new Error("Payment not found")
|
||||||
|
|
||||||
|
await Database.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(PaymentTable)
|
||||||
|
.set({
|
||||||
|
timeRefunded: new Date(body.created * 1000),
|
||||||
|
})
|
||||||
|
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
balance: sql`${BillingTable.balance} - ${amount}`,
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export default function Home() {
|
|||||||
<li>
|
<li>
|
||||||
<strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account
|
<strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>ChatGPT Plus/Pro</strong> Log in with OpenAI to use your ChatGPT Plus or Pro account
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Use any model</strong> Supports 75+ LLM providers through{" "}
|
<strong>Use any model</strong> Supports 75+ LLM providers through{" "}
|
||||||
<a href="https://models.dev">Models.dev</a>, including local models
|
<a href="https://models.dev">Models.dev</a>, including local models
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
|
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
|
||||||
import { For, Show } from "solid-js"
|
import { For, Match, Show, Switch } from "solid-js"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { formatDateUTC, formatDateForTable } from "../../common"
|
import { formatDateUTC, formatDateForTable } from "../../common"
|
||||||
import styles from "./payment-section.module.css"
|
import styles from "./payment-section.module.css"
|
||||||
@@ -77,7 +77,8 @@ export function PaymentSection() {
|
|||||||
<For each={payments()!}>
|
<For each={payments()!}>
|
||||||
{(payment) => {
|
{(payment) => {
|
||||||
const date = new Date(payment.timeCreated)
|
const date = new Date(payment.timeCreated)
|
||||||
const isCredit = !payment.paymentID
|
const amount =
|
||||||
|
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
||||||
@@ -85,13 +86,14 @@ export function PaymentSection() {
|
|||||||
</td>
|
</td>
|
||||||
<td data-slot="payment-id">{payment.id}</td>
|
<td data-slot="payment-id">{payment.id}</td>
|
||||||
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
||||||
${((payment.amount ?? 0) / 100000000).toFixed(2)}
|
${((amount ?? 0) / 100000000).toFixed(2)}
|
||||||
{isCredit ? " (credit)" : ""}
|
<Switch>
|
||||||
|
<Match when={payment.enrichment?.type === "credit"}> (credit)</Match>
|
||||||
|
<Match when={payment.enrichment?.type === "subscription"}> (subscription)</Match>
|
||||||
|
</Switch>
|
||||||
</td>
|
</td>
|
||||||
<td data-slot="payment-receipt">
|
<td data-slot="payment-receipt">
|
||||||
{isCredit ? (
|
{payment.paymentID ? (
|
||||||
<span>-</span>
|
|
||||||
) : (
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
|
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
|
||||||
@@ -103,6 +105,8 @@ export function PaymentSection() {
|
|||||||
>
|
>
|
||||||
View
|
View
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<span>-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
|
|||||||
eq(UsageTable.workspaceID, workspaceID),
|
eq(UsageTable.workspaceID, workspaceID),
|
||||||
gte(UsageTable.timeCreated, startDate),
|
gte(UsageTable.timeCreated, startDate),
|
||||||
lte(UsageTable.timeCreated, endDate),
|
lte(UsageTable.timeCreated, endDate),
|
||||||
|
or(isNull(UsageTable.enrichment), sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') != 'sub'`),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
|
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||||
import { BillingTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
import { BillingTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||||
|
import { getWeekBounds } from "@opencode-ai/console-core/util/date.js"
|
||||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
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"
|
||||||
@@ -415,11 +416,11 @@ export async function handler(
|
|||||||
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
||||||
},
|
},
|
||||||
subscription: {
|
subscription: {
|
||||||
timeSubscribed: UserTable.timeSubscribed,
|
id: SubscriptionTable.id,
|
||||||
subIntervalUsage: UserTable.subIntervalUsage,
|
rollingUsage: SubscriptionTable.rollingUsage,
|
||||||
subMonthlyUsage: UserTable.subMonthlyUsage,
|
fixedUsage: SubscriptionTable.fixedUsage,
|
||||||
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
|
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
||||||
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
|
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
||||||
},
|
},
|
||||||
provider: {
|
provider: {
|
||||||
credentials: ProviderTable.credentials,
|
credentials: ProviderTable.credentials,
|
||||||
@@ -440,6 +441,14 @@ export async function handler(
|
|||||||
)
|
)
|
||||||
: sql`false`,
|
: sql`false`,
|
||||||
)
|
)
|
||||||
|
.leftJoin(
|
||||||
|
SubscriptionTable,
|
||||||
|
and(
|
||||||
|
eq(SubscriptionTable.workspaceID, KeyTable.workspaceID),
|
||||||
|
eq(SubscriptionTable.userID, KeyTable.userID),
|
||||||
|
isNull(SubscriptionTable.timeDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||||
.then((rows) => rows[0]),
|
.then((rows) => rows[0]),
|
||||||
)
|
)
|
||||||
@@ -448,7 +457,7 @@ export async function handler(
|
|||||||
logger.metric({
|
logger.metric({
|
||||||
api_key: data.apiKey,
|
api_key: data.apiKey,
|
||||||
workspace: data.workspaceID,
|
workspace: data.workspaceID,
|
||||||
isSubscription: data.subscription.timeSubscribed ? true : false,
|
isSubscription: data.subscription ? true : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -456,7 +465,7 @@ export async function handler(
|
|||||||
workspaceID: data.workspaceID,
|
workspaceID: data.workspaceID,
|
||||||
billing: data.billing,
|
billing: data.billing,
|
||||||
user: data.user,
|
user: data.user,
|
||||||
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
|
subscription: data.subscription,
|
||||||
provider: data.provider,
|
provider: data.provider,
|
||||||
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
||||||
isDisabled: !!data.timeDisabled,
|
isDisabled: !!data.timeDisabled,
|
||||||
@@ -484,23 +493,11 @@ export async function handler(
|
|||||||
return `${minutes}min`
|
return `${minutes}min`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check monthly limit (based on subscription billing cycle)
|
// Check weekly limit
|
||||||
if (
|
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
||||||
sub.subMonthlyUsage &&
|
const week = getWeekBounds(now)
|
||||||
sub.timeSubMonthlyUsageUpdated &&
|
if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
|
||||||
sub.subMonthlyUsage >= centsToMicroCents(black.monthlyLimit * 100)
|
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
|
||||||
) {
|
|
||||||
const subscribeDay = sub.timeSubscribed!.getUTCDate()
|
|
||||||
const cycleStart = new Date(
|
|
||||||
Date.UTC(
|
|
||||||
now.getUTCFullYear(),
|
|
||||||
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
|
|
||||||
subscribeDay,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const cycleEnd = new Date(Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay))
|
|
||||||
if (sub.timeSubMonthlyUsageUpdated >= cycleStart && sub.timeSubMonthlyUsageUpdated < cycleEnd) {
|
|
||||||
const retryAfter = Math.ceil((cycleEnd.getTime() - now.getTime()) / 1000)
|
|
||||||
throw new SubscriptionError(
|
throw new SubscriptionError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||||
retryAfter,
|
retryAfter,
|
||||||
@@ -508,14 +505,12 @@ export async function handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check interval limit
|
// Check rolling limit
|
||||||
const intervalMs = black.intervalLength * 3600 * 1000
|
if (sub.rollingUsage && sub.timeRollingUpdated) {
|
||||||
if (sub.subIntervalUsage && sub.timeSubIntervalUsageUpdated) {
|
const rollingWindowMs = black.rollingWindow * 3600 * 1000
|
||||||
const currentInterval = Math.floor(now.getTime() / intervalMs)
|
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
||||||
const usageInterval = Math.floor(sub.timeSubIntervalUsageUpdated.getTime() / intervalMs)
|
if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
|
||||||
if (currentInterval === usageInterval && sub.subIntervalUsage >= centsToMicroCents(black.intervalLimit * 100)) {
|
const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
|
||||||
const nextInterval = (currentInterval + 1) * intervalMs
|
|
||||||
const retryAfter = Math.ceil((nextInterval - now.getTime()) / 1000)
|
|
||||||
throw new SubscriptionError(
|
throw new SubscriptionError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||||
retryAfter,
|
retryAfter,
|
||||||
@@ -661,38 +656,39 @@ export async function handler(
|
|||||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||||
...(authInfo.subscription
|
...(authInfo.subscription
|
||||||
? (() => {
|
? (() => {
|
||||||
const now = new Date()
|
const black = BlackData.get()
|
||||||
const subscribeDay = authInfo.subscription.timeSubscribed!.getUTCDate()
|
const week = getWeekBounds(new Date())
|
||||||
const cycleStart = new Date(
|
const rollingWindowSeconds = black.rollingWindow * 3600
|
||||||
Date.UTC(
|
|
||||||
now.getUTCFullYear(),
|
|
||||||
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
|
|
||||||
subscribeDay,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const cycleEnd = new Date(
|
|
||||||
Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay),
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
db
|
db
|
||||||
.update(UserTable)
|
.update(SubscriptionTable)
|
||||||
.set({
|
.set({
|
||||||
subMonthlyUsage: sql`
|
fixedUsage: sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN ${UserTable.timeSubMonthlyUsageUpdated} >= ${cycleStart} AND ${UserTable.timeSubMonthlyUsageUpdated} < ${cycleEnd} THEN ${UserTable.subMonthlyUsage} + ${cost}
|
WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost}
|
||||||
ELSE ${cost}
|
ELSE ${cost}
|
||||||
END
|
END
|
||||||
`,
|
`,
|
||||||
timeSubMonthlyUsageUpdated: sql`now()`,
|
timeFixedUpdated: sql`now()`,
|
||||||
subIntervalUsage: sql`
|
rollingUsage: sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 3600)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 3600)) THEN ${UserTable.subIntervalUsage} + ${cost}
|
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost}
|
||||||
ELSE ${cost}
|
ELSE ${cost}
|
||||||
END
|
END
|
||||||
`,
|
`,
|
||||||
timeSubIntervalUsageUpdated: sql`now()`,
|
timeRollingUpdated: sql`
|
||||||
|
CASE
|
||||||
|
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.timeRollingUpdated}
|
||||||
|
ELSE now()
|
||||||
|
END
|
||||||
|
`,
|
||||||
})
|
})
|
||||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
.where(
|
||||||
|
and(
|
||||||
|
eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
|
||||||
|
eq(SubscriptionTable.userID, authInfo.user.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
})()
|
})()
|
||||||
: [
|
: [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `subscription` (
|
||||||
|
`id` varchar(30) NOT NULL,
|
||||||
|
`workspace_id` varchar(30) NOT NULL,
|
||||||
|
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||||
|
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
`time_deleted` timestamp(3),
|
||||||
|
`user_id` varchar(30) NOT NULL,
|
||||||
|
`rolling_usage` bigint,
|
||||||
|
`fixed_usage` bigint,
|
||||||
|
`time_rolling_updated` timestamp(3),
|
||||||
|
`time_fixed_updated` timestamp(3),
|
||||||
|
CONSTRAINT `subscription_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`)
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE INDEX `workspace_user_id` ON `subscription` (`workspace_id`,`user_id`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `time_subscribed`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `sub_interval_usage`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `sub_monthly_usage`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `sub_time_interval_usage_updated`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `sub_time_monthly_usage_updated`;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX `workspace_user_id` ON `subscription`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscription` ADD CONSTRAINT `workspace_user_id` UNIQUE(`workspace_id`,`user_id`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `billing` ADD `subscription_coupon_id` varchar(28);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `payment` ADD `enrichment` json;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -323,6 +323,41 @@
|
|||||||
"when": 1767765497502,
|
"when": 1767765497502,
|
||||||
"tag": "0045_cuddly_diamondback",
|
"tag": "0045_cuddly_diamondback",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 46,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767912262458,
|
||||||
|
"tag": "0046_charming_black_bolt",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 47,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767916965243,
|
||||||
|
"tag": "0047_huge_omega_red",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 48,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767917785224,
|
||||||
|
"tag": "0048_mean_frank_castle",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 49,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767922954153,
|
||||||
|
"tag": "0049_noisy_domino",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 50,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767931290031,
|
||||||
|
"tag": "0050_bumpy_mephistopheles",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.7",
|
"version": "1.1.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Database, eq, sql, inArray } from "../src/drizzle/index.js"
|
import { Database, and, eq, sql } from "../src/drizzle/index.js"
|
||||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||||
import { UserTable } from "../src/schema/user.sql.js"
|
import { UserTable } from "../src/schema/user.sql.js"
|
||||||
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
|
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "../src/schema/billing.sql.js"
|
||||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||||
|
import { BlackData } from "../src/black.js"
|
||||||
|
import { centsToMicroCents } from "../src/util/price.js"
|
||||||
|
import { getWeekBounds } from "../src/util/date.js"
|
||||||
|
|
||||||
// get input from command line
|
// get input from command line
|
||||||
const identifier = process.argv[2]
|
const identifier = process.argv[2]
|
||||||
@@ -35,10 +38,21 @@ if (identifier.startsWith("wrk_")) {
|
|||||||
workspaceID: UserTable.workspaceID,
|
workspaceID: UserTable.workspaceID,
|
||||||
workspaceName: WorkspaceTable.name,
|
workspaceName: WorkspaceTable.name,
|
||||||
role: UserTable.role,
|
role: UserTable.role,
|
||||||
|
subscribed: SubscriptionTable.timeCreated,
|
||||||
})
|
})
|
||||||
.from(UserTable)
|
.from(UserTable)
|
||||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
|
.rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
|
||||||
.where(eq(UserTable.accountID, accountID)),
|
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
|
||||||
|
.where(eq(UserTable.accountID, accountID))
|
||||||
|
.then((rows) =>
|
||||||
|
rows.map((row) => ({
|
||||||
|
userID: row.userID,
|
||||||
|
workspaceID: row.workspaceID,
|
||||||
|
workspaceName: row.workspaceName,
|
||||||
|
role: row.role,
|
||||||
|
subscribed: formatDate(row.subscribed),
|
||||||
|
})),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get all payments for these workspaces
|
// Get all payments for these workspaces
|
||||||
@@ -56,11 +70,51 @@ async function printWorkspace(workspaceID: string) {
|
|||||||
|
|
||||||
printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
|
printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
|
||||||
|
|
||||||
|
await printTable("Users", (tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
authEmail: AuthTable.subject,
|
||||||
|
inviteEmail: UserTable.email,
|
||||||
|
role: UserTable.role,
|
||||||
|
timeSeen: UserTable.timeSeen,
|
||||||
|
monthlyLimit: UserTable.monthlyLimit,
|
||||||
|
monthlyUsage: UserTable.monthlyUsage,
|
||||||
|
timeDeleted: UserTable.timeDeleted,
|
||||||
|
fixedUsage: SubscriptionTable.fixedUsage,
|
||||||
|
rollingUsage: SubscriptionTable.rollingUsage,
|
||||||
|
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
||||||
|
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
||||||
|
timeSubscriptionCreated: SubscriptionTable.timeCreated,
|
||||||
|
})
|
||||||
|
.from(UserTable)
|
||||||
|
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||||
|
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
|
||||||
|
.where(eq(UserTable.workspaceID, workspace.id))
|
||||||
|
.then((rows) =>
|
||||||
|
rows.map((row) => {
|
||||||
|
const subStatus = getSubscriptionStatus(row)
|
||||||
|
return {
|
||||||
|
email: (row.timeDeleted ? "❌ " : "") + (row.authEmail ?? row.inviteEmail),
|
||||||
|
role: row.role,
|
||||||
|
timeSeen: formatDate(row.timeSeen),
|
||||||
|
monthly: formatMonthlyUsage(row.monthlyUsage, row.monthlyLimit),
|
||||||
|
subscribed: formatDate(row.timeSubscriptionCreated),
|
||||||
|
subWeekly: subStatus.weekly,
|
||||||
|
subRolling: subStatus.rolling,
|
||||||
|
rateLimited: subStatus.rateLimited,
|
||||||
|
retryIn: subStatus.retryIn,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
await printTable("Billing", (tx) =>
|
await printTable("Billing", (tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.select({
|
||||||
balance: BillingTable.balance,
|
balance: BillingTable.balance,
|
||||||
customerID: BillingTable.customerID,
|
customerID: BillingTable.customerID,
|
||||||
|
subscriptionID: BillingTable.subscriptionID,
|
||||||
|
subscriptionCouponID: BillingTable.subscriptionCouponID,
|
||||||
})
|
})
|
||||||
.from(BillingTable)
|
.from(BillingTable)
|
||||||
.where(eq(BillingTable.workspaceID, workspace.id))
|
.where(eq(BillingTable.workspaceID, workspace.id))
|
||||||
@@ -97,6 +151,7 @@ async function printWorkspace(workspaceID: string) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
await printTable("Usage", (tx) =>
|
await printTable("Usage", (tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.select({
|
||||||
@@ -122,6 +177,81 @@ async function printWorkspace(workspaceID: string) {
|
|||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMicroCents(value: number | null | undefined) {
|
||||||
|
if (value === null || value === undefined) return null
|
||||||
|
return `$${(value / 100000000).toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: Date | null | undefined) {
|
||||||
|
if (!value) return null
|
||||||
|
return value.toISOString().split("T")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthlyUsage(usage: number | null | undefined, limit: number | null | undefined) {
|
||||||
|
const usageText = formatMicroCents(usage) ?? "$0.00"
|
||||||
|
if (limit === null || limit === undefined) return `${usageText} / no limit`
|
||||||
|
return `${usageText} / $${limit.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRetryTime(seconds: number) {
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.ceil((seconds % 3600) / 60)
|
||||||
|
if (hours >= 1) return `${hours}hr ${minutes}min`
|
||||||
|
return `${minutes}min`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubscriptionStatus(row: {
|
||||||
|
timeSubscriptionCreated: Date | null
|
||||||
|
fixedUsage: number | null
|
||||||
|
rollingUsage: number | null
|
||||||
|
timeFixedUpdated: Date | null
|
||||||
|
timeRollingUpdated: Date | null
|
||||||
|
}) {
|
||||||
|
if (!row.timeSubscriptionCreated) {
|
||||||
|
return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const black = BlackData.get()
|
||||||
|
const now = new Date()
|
||||||
|
const week = getWeekBounds(now)
|
||||||
|
|
||||||
|
const fixedLimit = black.fixedLimit ? centsToMicroCents(black.fixedLimit * 100) : null
|
||||||
|
const rollingLimit = black.rollingLimit ? centsToMicroCents(black.rollingLimit * 100) : null
|
||||||
|
const rollingWindowMs = (black.rollingWindow ?? 5) * 3600 * 1000
|
||||||
|
|
||||||
|
// Calculate current weekly usage (reset if outside current week)
|
||||||
|
const currentWeekly =
|
||||||
|
row.fixedUsage && row.timeFixedUpdated && row.timeFixedUpdated >= week.start ? row.fixedUsage : 0
|
||||||
|
|
||||||
|
// Calculate current rolling usage
|
||||||
|
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
||||||
|
const currentRolling =
|
||||||
|
row.rollingUsage && row.timeRollingUpdated && row.timeRollingUpdated >= windowStart ? row.rollingUsage : 0
|
||||||
|
|
||||||
|
// Check rate limiting
|
||||||
|
const isWeeklyLimited = fixedLimit !== null && currentWeekly >= fixedLimit
|
||||||
|
const isRollingLimited = rollingLimit !== null && currentRolling >= rollingLimit
|
||||||
|
|
||||||
|
let retryIn: string | null = null
|
||||||
|
if (isWeeklyLimited) {
|
||||||
|
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
|
||||||
|
retryIn = formatRetryTime(retryAfter)
|
||||||
|
} else if (isRollingLimited && row.timeRollingUpdated) {
|
||||||
|
const retryAfter = Math.ceil((row.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
|
||||||
|
retryIn = formatRetryTime(retryAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekly: fixedLimit !== null ? `${formatMicroCents(currentWeekly)} / $${black.fixedLimit}` : null,
|
||||||
|
rolling: rollingLimit !== null ? `${formatMicroCents(currentRolling)} / $${black.rollingLimit}` : null,
|
||||||
|
rateLimited: isWeeklyLimited || isRollingLimited ? "yes" : "no",
|
||||||
|
retryIn,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function printHeader(title: string) {
|
function printHeader(title: string) {
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { Billing } from "../src/billing.js"
|
import { Billing } from "../src/billing.js"
|
||||||
import { Database, eq, and, sql } from "../src/drizzle/index.js"
|
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
|
||||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
|
||||||
import { UserTable } from "../src/schema/user.sql.js"
|
import { UserTable } from "../src/schema/user.sql.js"
|
||||||
import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js"
|
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||||
import { Identifier } from "../src/identifier.js"
|
import { Identifier } from "../src/identifier.js"
|
||||||
import { centsToMicroCents } from "../src/util/price.js"
|
import { centsToMicroCents } from "../src/util/price.js"
|
||||||
|
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||||
|
|
||||||
const workspaceID = process.argv[2]
|
const workspaceID = process.argv[2]
|
||||||
const email = process.argv[3]
|
const email = process.argv[3]
|
||||||
|
|
||||||
|
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
|
||||||
|
|
||||||
if (!workspaceID || !email) {
|
if (!workspaceID || !email) {
|
||||||
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
|
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the Stripe customer by email
|
// Look up the Stripe customer by email
|
||||||
const customers = await Billing.stripe().customers.list({ email, limit: 1 })
|
const customers = await Billing.stripe().customers.list({ email, limit: 10, expand: ["data.subscriptions"] })
|
||||||
const customer = customers.data[0]
|
if (!customers.data) {
|
||||||
if (!customer) {
|
|
||||||
console.error(`Error: No Stripe customer found for email ${email}`)
|
console.error(`Error: No Stripe customer found for email ${email}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
const customerID = customer.id
|
const customer = customers.data.find((c) => c.subscriptions?.data[0]?.items.data[0]?.price.unit_amount === 20000)
|
||||||
|
if (!customer) {
|
||||||
// Get the subscription id
|
console.error(`Error: No Stripe customer found for email ${email} with $200 subscription`)
|
||||||
const subscriptions = await Billing.stripe().subscriptions.list({ customer: customerID, limit: 1 })
|
|
||||||
const subscription = subscriptions.data[0]
|
|
||||||
if (!subscription) {
|
|
||||||
console.error(`Error: Customer ${customerID} does not have a subscription`)
|
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customerID = customer.id
|
||||||
|
const subscription = customer.subscriptions!.data[0]
|
||||||
const subscriptionID = subscription.id
|
const subscriptionID = subscription.id
|
||||||
|
|
||||||
// Validate the subscription is $200
|
// Validate the subscription is $200
|
||||||
@@ -39,6 +39,12 @@ if (amountInCents !== 20000) {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscription.id, { expand: ["discounts"] })
|
||||||
|
const couponID =
|
||||||
|
typeof subscriptionData.discounts[0] === "string"
|
||||||
|
? subscriptionData.discounts[0]
|
||||||
|
: subscriptionData.discounts[0]?.coupon?.id
|
||||||
|
|
||||||
// Check if subscription is already tied to another workspace
|
// Check if subscription is already tied to another workspace
|
||||||
const existingSubscription = await Database.use((tx) =>
|
const existingSubscription = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
@@ -90,29 +96,21 @@ const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.re
|
|||||||
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
|
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
|
||||||
const paymentMethodType = paymentMethod?.type ?? null
|
const paymentMethodType = paymentMethod?.type ?? null
|
||||||
|
|
||||||
// Look up the user by email via AuthTable
|
// Look up the user in the workspace
|
||||||
const auth = await Database.use((tx) =>
|
const users = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.select({ accountID: AuthTable.accountID })
|
.select({ id: UserTable.id, email: AuthTable.subject })
|
||||||
.from(AuthTable)
|
.from(UserTable)
|
||||||
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
|
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
||||||
.then((rows) => rows[0]),
|
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||||
)
|
)
|
||||||
if (!auth) {
|
if (users.length === 0) {
|
||||||
console.error(`Error: No user found with email ${email}`)
|
console.error(`Error: No users found in workspace ${workspaceID}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
const user = users.length === 1 ? users[0] : users.find((u) => u.email === email)
|
||||||
// Look up the user in the workspace
|
|
||||||
const user = await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.select({ id: UserTable.id })
|
|
||||||
.from(UserTable)
|
|
||||||
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, auth.accountID)))
|
|
||||||
.then((rows) => rows[0]),
|
|
||||||
)
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.error(`Error: User with email ${email} is not a member of workspace ${workspaceID}`)
|
console.error(`Error: User with email ${email} not found in workspace ${workspaceID}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,19 +128,19 @@ await Database.transaction(async (tx) => {
|
|||||||
.set({
|
.set({
|
||||||
customerID,
|
customerID,
|
||||||
subscriptionID,
|
subscriptionID,
|
||||||
|
subscriptionCouponID: couponID,
|
||||||
paymentMethodID,
|
paymentMethodID,
|
||||||
paymentMethodLast4,
|
paymentMethodLast4,
|
||||||
paymentMethodType,
|
paymentMethodType,
|
||||||
})
|
})
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
// Set current time as timeSubscribed on user
|
// Create a row in subscription table
|
||||||
await tx
|
await tx.insert(SubscriptionTable).values({
|
||||||
.update(UserTable)
|
workspaceID,
|
||||||
.set({
|
id: Identifier.create("subscription"),
|
||||||
timeSubscribed: sql`now()`,
|
userID: user.id,
|
||||||
})
|
})
|
||||||
.where(eq(UserTable.id, user.id))
|
|
||||||
|
|
||||||
// Create a row in payments table
|
// Create a row in payments table
|
||||||
await tx.insert(PaymentTable).values({
|
await tx.insert(PaymentTable).values({
|
||||||
@@ -152,6 +150,10 @@ await Database.transaction(async (tx) => {
|
|||||||
customerID,
|
customerID,
|
||||||
invoiceID,
|
invoiceID,
|
||||||
paymentID,
|
paymentID,
|
||||||
|
enrichment: {
|
||||||
|
type: "subscription",
|
||||||
|
couponID,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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 remove-black.ts <workspaceID>")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Removing subscription from workspace ${workspaceID}`)
|
||||||
|
|
||||||
|
// Look up the workspace billing
|
||||||
|
const billing = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
customerID: BillingTable.customerID,
|
||||||
|
subscriptionID: BillingTable.subscriptionID,
|
||||||
|
})
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!billing) {
|
||||||
|
console.error(`Error: No billing record found for workspace ${workspaceID}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!billing.subscriptionID) {
|
||||||
|
console.error(`Error: Workspace ${workspaceID} does not have a subscription`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Customer ID: ${billing.customerID}`)
|
||||||
|
console.log(` Subscription ID: ${billing.subscriptionID}`)
|
||||||
|
|
||||||
|
// Clear workspaceID from Stripe customer metadata
|
||||||
|
if (billing.customerID) {
|
||||||
|
//await Billing.stripe().customers.update(billing.customerID, {
|
||||||
|
// metadata: {
|
||||||
|
// workspaceID: "",
|
||||||
|
// },
|
||||||
|
//})
|
||||||
|
//console.log(`Cleared workspaceID from Stripe customer metadata`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Database.transaction(async (tx) => {
|
||||||
|
// Clear subscription-related fields from billing table
|
||||||
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
// customerID: null,
|
||||||
|
subscriptionID: null,
|
||||||
|
subscriptionCouponID: null,
|
||||||
|
// paymentMethodID: null,
|
||||||
|
// paymentMethodLast4: null,
|
||||||
|
// paymentMethodType: null,
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
|
// Delete from subscription table
|
||||||
|
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
|
// Delete from payments table
|
||||||
|
await tx
|
||||||
|
.delete(PaymentTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(PaymentTable.workspaceID, workspaceID),
|
||||||
|
eq(PaymentTable.enrichment, { type: "subscription" }),
|
||||||
|
eq(PaymentTable.amount, 20000000000),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Successfully removed subscription from workspace ${workspaceID}`)
|
||||||
@@ -171,6 +171,9 @@ export namespace Billing {
|
|||||||
workspaceID,
|
workspaceID,
|
||||||
id: Identifier.create("payment"),
|
id: Identifier.create("payment"),
|
||||||
amount: amountInMicroCents,
|
amount: amountInMicroCents,
|
||||||
|
enrichment: {
|
||||||
|
type: "credit",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return amountInMicroCents
|
return amountInMicroCents
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { Resource } from "@opencode-ai/console-resource"
|
|||||||
|
|
||||||
export namespace BlackData {
|
export namespace BlackData {
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
monthlyLimit: z.number().int(),
|
fixedLimit: z.number().int(),
|
||||||
intervalLimit: z.number().int(),
|
rollingLimit: z.number().int(),
|
||||||
intervalLength: z.number().int(),
|
rollingWindow: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const validate = fn(Schema, (input) => {
|
export const validate = fn(Schema, (input) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export namespace Identifier {
|
|||||||
model: "mod",
|
model: "mod",
|
||||||
payment: "pay",
|
payment: "pay",
|
||||||
provider: "prv",
|
provider: "prv",
|
||||||
|
subscription: "sub",
|
||||||
usage: "usg",
|
usage: "usg",
|
||||||
user: "usr",
|
user: "usr",
|
||||||
workspace: "wrk",
|
workspace: "wrk",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const BillingTable = mysqlTable(
|
|||||||
timeReloadError: utc("time_reload_error"),
|
timeReloadError: utc("time_reload_error"),
|
||||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||||
|
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
...workspaceIndexes(table),
|
...workspaceIndexes(table),
|
||||||
@@ -30,6 +31,20 @@ export const BillingTable = mysqlTable(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const SubscriptionTable = mysqlTable(
|
||||||
|
"subscription",
|
||||||
|
{
|
||||||
|
...workspaceColumns,
|
||||||
|
...timestamps,
|
||||||
|
userID: ulid("user_id").notNull(),
|
||||||
|
rollingUsage: bigint("rolling_usage", { mode: "number" }),
|
||||||
|
fixedUsage: bigint("fixed_usage", { mode: "number" }),
|
||||||
|
timeRollingUpdated: utc("time_rolling_updated"),
|
||||||
|
timeFixedUpdated: utc("time_fixed_updated"),
|
||||||
|
},
|
||||||
|
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
|
||||||
|
)
|
||||||
|
|
||||||
export const PaymentTable = mysqlTable(
|
export const PaymentTable = mysqlTable(
|
||||||
"payment",
|
"payment",
|
||||||
{
|
{
|
||||||
@@ -40,6 +55,15 @@ export const PaymentTable = mysqlTable(
|
|||||||
paymentID: varchar("payment_id", { length: 255 }),
|
paymentID: varchar("payment_id", { length: 255 }),
|
||||||
amount: bigint("amount", { mode: "number" }).notNull(),
|
amount: bigint("amount", { mode: "number" }).notNull(),
|
||||||
timeRefunded: utc("time_refunded"),
|
timeRefunded: utc("time_refunded"),
|
||||||
|
enrichment: json("enrichment").$type<
|
||||||
|
| {
|
||||||
|
type: "subscription"
|
||||||
|
couponID?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "credit"
|
||||||
|
}
|
||||||
|
>(),
|
||||||
},
|
},
|
||||||
(table) => [...workspaceIndexes(table)],
|
(table) => [...workspaceIndexes(table)],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ export const UserTable = mysqlTable(
|
|||||||
monthlyLimit: int("monthly_limit"),
|
monthlyLimit: int("monthly_limit"),
|
||||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||||
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
||||||
// subscription
|
|
||||||
timeSubscribed: utc("time_subscribed"),
|
|
||||||
subIntervalUsage: bigint("sub_interval_usage", { mode: "number" }),
|
|
||||||
subMonthlyUsage: bigint("sub_monthly_usage", { mode: "number" }),
|
|
||||||
timeSubIntervalUsageUpdated: utc("sub_time_interval_usage_updated"),
|
|
||||||
timeSubMonthlyUsageUpdated: utc("sub_time_monthly_usage_updated"),
|
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
...workspaceIndexes(table),
|
...workspaceIndexes(table),
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function getWeekBounds(date: Date) {
|
||||||
|
const dayOfWeek = date.getUTCDay()
|
||||||
|
const start = new Date(date)
|
||||||
|
start.setUTCDate(date.getUTCDate() - dayOfWeek + 1)
|
||||||
|
start.setUTCHours(0, 0, 0, 0)
|
||||||
|
const end = new Date(start)
|
||||||
|
end.setUTCDate(start.getUTCDate() + 7)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"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",
|
||||||
|
|||||||
@@ -13,36 +13,7 @@
|
|||||||
<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" />
|
||||||
<script id="oc-theme-preload-script">
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
;(function () {
|
|
||||||
var themeId = localStorage.getItem("opencode-theme-id")
|
|
||||||
if (!themeId) return
|
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
|
||||||
var mode = isDark ? "dark" : "light"
|
|
||||||
|
|
||||||
document.documentElement.dataset.theme = themeId
|
|
||||||
document.documentElement.dataset.colorScheme = mode
|
|
||||||
|
|
||||||
if (themeId === "oc-1") return
|
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
|
||||||
if (css) {
|
|
||||||
var style = document.createElement("style")
|
|
||||||
style.id = "oc-theme-preload"
|
|
||||||
style.textContent =
|
|
||||||
":root{color-scheme:" +
|
|
||||||
mode +
|
|
||||||
";--text-mix-blend-mode:" +
|
|
||||||
(isDark ? "plus-lighter" : "multiply") +
|
|
||||||
";" +
|
|
||||||
css +
|
|
||||||
"}"
|
|
||||||
document.head.appendChild(style)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
@@ -2795,6 +2795,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"gtk",
|
"gtk",
|
||||||
"listeners",
|
"listeners",
|
||||||
|
"reqwest",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name = "opencode-desktop"
|
|||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
description = "The open source AI coding agent"
|
description = "The open source AI coding agent"
|
||||||
authors = ["Anomaly Innovations"]
|
authors = ["Anomaly Innovations"]
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ listeners = "0.3"
|
|||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
semver = "1.0.27"
|
semver = "1.0.27"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
gtk = "0.18.2"
|
gtk = "0.18.2"
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
|
use tauri::{path::BaseDirectory, AppHandle, Manager};
|
||||||
|
use tauri_plugin_shell::{process::Command, ShellExt};
|
||||||
|
|
||||||
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||||
const CLI_BINARY_NAME: &str = "opencode";
|
const CLI_BINARY_NAME: &str = "opencode";
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub port: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub server: Option<ServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_config(app: &AppHandle) -> Option<Config> {
|
||||||
|
create_command(app, "debug config")
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| eprintln!("Failed to read OC config: {e}"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|out| String::from_utf8(out.stdout.to_vec()).ok())
|
||||||
|
.and_then(|s| serde_json::from_str::<Config>(&s).ok())
|
||||||
|
}
|
||||||
|
|
||||||
fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
||||||
std::env::var("HOME").ok().map(|home| {
|
std::env::var("HOME").ok().map(|home| {
|
||||||
std::path::PathBuf::from(home)
|
std::path::PathBuf::from(home)
|
||||||
@@ -9,9 +33,10 @@ fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_sidecar_path() -> std::path::PathBuf {
|
pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
|
||||||
tauri::utils::platform::current_exe()
|
// Get binary with symlinks support
|
||||||
.expect("Failed to get current exe")
|
tauri::process::current_binary(&app.env())
|
||||||
|
.expect("Failed to get current binary")
|
||||||
.parent()
|
.parent()
|
||||||
.expect("Failed to get parent dir")
|
.expect("Failed to get parent dir")
|
||||||
.join("opencode-cli")
|
.join("opencode-cli")
|
||||||
@@ -26,12 +51,12 @@ fn is_cli_installed() -> bool {
|
|||||||
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn install_cli() -> Result<String, String> {
|
pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
|
||||||
if cfg!(not(unix)) {
|
if cfg!(not(unix)) {
|
||||||
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let sidecar = get_sidecar_path();
|
let sidecar = get_sidecar_path(&app);
|
||||||
if !sidecar.exists() {
|
if !sidecar.exists() {
|
||||||
return Err("Sidecar binary not found".to_string());
|
return Err("Sidecar binary not found".to_string());
|
||||||
}
|
}
|
||||||
@@ -108,9 +133,41 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
cli_version, app_version
|
cli_version, app_version
|
||||||
);
|
);
|
||||||
|
|
||||||
install_cli()?;
|
install_cli(app)?;
|
||||||
|
|
||||||
println!("Synced installed CLI");
|
println!("Synced installed CLI");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_user_shell() -> String {
|
||||||
|
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
|
||||||
|
let state_dir = app
|
||||||
|
.path()
|
||||||
|
.resolve("", BaseDirectory::AppLocalData)
|
||||||
|
.expect("Failed to resolve app local data dir");
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return app
|
||||||
|
.shell()
|
||||||
|
.sidecar("opencode-cli")
|
||||||
|
.unwrap()
|
||||||
|
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
|
||||||
|
.env("OPENCODE_CLIENT", "desktop")
|
||||||
|
.env("XDG_STATE_HOME", &state_dir);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
return {
|
||||||
|
let sidecar = get_sidecar_path(app);
|
||||||
|
let shell = get_user_shell();
|
||||||
|
app.shell()
|
||||||
|
.command(&shell)
|
||||||
|
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
|
||||||
|
.env("OPENCODE_CLIENT", "desktop")
|
||||||
|
.env("XDG_STATE_HOME", &state_dir)
|
||||||
|
.args(["-il", "-c", &format!("\"{}\" {}", sidecar.display(), args)])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,34 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
mod window_customizer;
|
mod window_customizer;
|
||||||
|
|
||||||
use cli::{get_sidecar_path, install_cli, sync_cli};
|
use cli::{install_cli, sync_cli};
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
use std::{
|
use std::{
|
||||||
collections::VecDeque,
|
collections::VecDeque,
|
||||||
net::{SocketAddr, TcpListener},
|
net::TcpListener,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use tauri::{
|
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, WebviewWindow};
|
||||||
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||||
WebviewWindow,
|
|
||||||
};
|
|
||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||||
use tauri_plugin_shell::ShellExt;
|
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
use tokio::net::TcpSocket;
|
|
||||||
|
|
||||||
use crate::window_customizer::PinchZoomDisablePlugin;
|
use crate::window_customizer::PinchZoomDisablePlugin;
|
||||||
|
|
||||||
|
const SETTINGS_STORE: &str = "opencode.settings.dat";
|
||||||
|
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ServerState {
|
struct ServerState {
|
||||||
child: Arc<Mutex<Option<CommandChild>>>,
|
child: Arc<Mutex<Option<CommandChild>>>,
|
||||||
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
|
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<String, String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerState {
|
impl ServerState {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
child: Option<CommandChild>,
|
child: Option<CommandChild>,
|
||||||
status: tokio::sync::oneshot::Receiver<Result<(), String>>,
|
status: tokio::sync::oneshot::Receiver<Result<String, String>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
child: Arc::new(Mutex::new(child)),
|
child: Arc::new(Mutex::new(child)),
|
||||||
@@ -46,65 +45,6 @@ impl ServerState {
|
|||||||
struct LogState(Arc<Mutex<VecDeque<String>>>);
|
struct LogState(Arc<Mutex<VecDeque<String>>>);
|
||||||
|
|
||||||
const MAX_LOG_ENTRIES: usize = 200;
|
const MAX_LOG_ENTRIES: usize = 200;
|
||||||
const GLOBAL_STORAGE: &str = "opencode.global.dat";
|
|
||||||
|
|
||||||
/// Check if a URL's origin matches any configured server in the store.
|
|
||||||
/// Returns true if the URL should be allowed for internal navigation.
|
|
||||||
fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool {
|
|
||||||
// Always allow localhost and 127.0.0.1
|
|
||||||
if let Some(host) = url.host_str() {
|
|
||||||
if host == "localhost" || host == "127.0.0.1" {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to read the server list from the store
|
|
||||||
let Ok(store) = app.store(GLOBAL_STORAGE) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(server_data) = store.get("server") else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse the server list from the stored JSON
|
|
||||||
let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the origin of the navigation URL (scheme + host + port)
|
|
||||||
let url_origin = format!(
|
|
||||||
"{}://{}{}",
|
|
||||||
url.scheme(),
|
|
||||||
url.host_str().unwrap_or(""),
|
|
||||||
url.port().map(|p| format!(":{}", p)).unwrap_or_default()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if any configured server matches the URL's origin
|
|
||||||
for server in list {
|
|
||||||
let Some(server_url) = server.as_str() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse the server URL to extract its origin
|
|
||||||
let Ok(parsed) = tauri::Url::parse(server_url) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let server_origin = format!(
|
|
||||||
"{}://{}{}",
|
|
||||||
parsed.scheme(),
|
|
||||||
parsed.host_str().unwrap_or(""),
|
|
||||||
parsed.port().map(|p| format!(":{}", p)).unwrap_or_default()
|
|
||||||
);
|
|
||||||
|
|
||||||
if url_origin == server_origin {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn kill_sidecar(app: AppHandle) {
|
fn kill_sidecar(app: AppHandle) {
|
||||||
@@ -140,7 +80,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
|
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<String, String> {
|
||||||
state
|
state
|
||||||
.status
|
.status
|
||||||
.clone()
|
.clone()
|
||||||
@@ -148,6 +88,41 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri
|
|||||||
.map_err(|_| "Failed to get server status".to_string())?
|
.map_err(|_| "Failed to get server status".to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let store = app
|
||||||
|
.store(SETTINGS_STORE)
|
||||||
|
.map_err(|e| format!("Failed to open settings store: {}", e))?;
|
||||||
|
|
||||||
|
let value = store.get(DEFAULT_SERVER_URL_KEY);
|
||||||
|
match value {
|
||||||
|
Some(v) => Ok(v.as_str().map(String::from)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store(SETTINGS_STORE)
|
||||||
|
.map_err(|e| format!("Failed to open settings store: {}", e))?;
|
||||||
|
|
||||||
|
match url {
|
||||||
|
Some(u) => {
|
||||||
|
store.set(DEFAULT_SERVER_URL_KEY, serde_json::Value::String(u));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
store.delete(DEFAULT_SERVER_URL_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store
|
||||||
|
.save()
|
||||||
|
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn get_sidecar_port() -> u32 {
|
fn get_sidecar_port() -> u32 {
|
||||||
option_env!("OPENCODE_PORT")
|
option_env!("OPENCODE_PORT")
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
@@ -162,49 +137,16 @@ fn get_sidecar_port() -> u32 {
|
|||||||
}) as u32
|
}) as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_user_shell() -> String {
|
|
||||||
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||||
let log_state = app.state::<LogState>();
|
let log_state = app.state::<LogState>();
|
||||||
let log_state_clone = log_state.inner().clone();
|
let log_state_clone = log_state.inner().clone();
|
||||||
|
|
||||||
let state_dir = app
|
println!("spawning sidecar on port {port}");
|
||||||
.path()
|
|
||||||
.resolve("", BaseDirectory::AppLocalData)
|
|
||||||
.expect("Failed to resolve app local data dir");
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
let (mut rx, child) = cli::create_command(app, format!("serve --port {port}").as_str())
|
||||||
let (mut rx, child) = app
|
|
||||||
.shell()
|
|
||||||
.sidecar("opencode-cli")
|
|
||||||
.unwrap()
|
|
||||||
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
|
|
||||||
.env("OPENCODE_CLIENT", "desktop")
|
|
||||||
.env("XDG_STATE_HOME", &state_dir)
|
|
||||||
.args(["serve", &format!("--port={port}")])
|
|
||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to spawn opencode");
|
.expect("Failed to spawn opencode");
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let (mut rx, child) = {
|
|
||||||
let sidecar = get_sidecar_path();
|
|
||||||
let shell = get_user_shell();
|
|
||||||
app.shell()
|
|
||||||
.command(&shell)
|
|
||||||
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
|
|
||||||
.env("OPENCODE_CLIENT", "desktop")
|
|
||||||
.env("XDG_STATE_HOME", &state_dir)
|
|
||||||
.args([
|
|
||||||
"-il",
|
|
||||||
"-c",
|
|
||||||
&format!("\"{}\" serve --port={}", sidecar.display(), port),
|
|
||||||
])
|
|
||||||
.spawn()
|
|
||||||
.expect("Failed to spawn opencode")
|
|
||||||
};
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
while let Some(event) = rx.recv().await {
|
while let Some(event) = rx.recv().await {
|
||||||
match event {
|
match event {
|
||||||
@@ -242,15 +184,22 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
|||||||
child
|
child
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_server_running(port: u32) -> bool {
|
async fn check_server_health(url: &str) -> bool {
|
||||||
TcpSocket::new_v4()
|
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
||||||
.unwrap()
|
let client = reqwest::Client::builder()
|
||||||
.connect(SocketAddr::new(
|
.timeout(Duration::from_secs(3))
|
||||||
"127.0.0.1".parse().expect("Failed to parse IP"),
|
.build();
|
||||||
port as u16,
|
|
||||||
))
|
let Ok(client) = client else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
client
|
||||||
|
.get(&health_url)
|
||||||
|
.send()
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.map(|r| r.status().is_success())
|
||||||
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@@ -279,7 +228,9 @@ pub fn run() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
kill_sidecar,
|
kill_sidecar,
|
||||||
install_cli,
|
install_cli,
|
||||||
ensure_server_started
|
ensure_server_ready,
|
||||||
|
get_default_server_url,
|
||||||
|
set_default_server_url
|
||||||
])
|
])
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let app = app.handle().clone();
|
let app = app.handle().clone();
|
||||||
@@ -287,16 +238,12 @@ pub fn run() {
|
|||||||
// Initialize log state
|
// Initialize log state
|
||||||
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
|
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
|
||||||
|
|
||||||
// Get port and create window immediately for faster perceived startup
|
|
||||||
let port = get_sidecar_port();
|
|
||||||
|
|
||||||
let primary_monitor = app.primary_monitor().ok().flatten();
|
let primary_monitor = app.primary_monitor().ok().flatten();
|
||||||
let size = primary_monitor
|
let size = primary_monitor
|
||||||
.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));
|
||||||
|
|
||||||
// Create window immediately with serverReady = false
|
#[allow(unused_mut)]
|
||||||
let app_for_nav = app.clone();
|
|
||||||
let mut window_builder =
|
let mut window_builder =
|
||||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
||||||
.title("OpenCode")
|
.title("OpenCode")
|
||||||
@@ -304,27 +251,10 @@ pub fn run() {
|
|||||||
.decorations(true)
|
.decorations(true)
|
||||||
.zoom_hotkeys_enabled(true)
|
.zoom_hotkeys_enabled(true)
|
||||||
.disable_drag_drop_handler()
|
.disable_drag_drop_handler()
|
||||||
.on_navigation(move |url| {
|
|
||||||
// Allow internal navigation (tauri:// scheme)
|
|
||||||
if url.scheme() == "tauri" {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Allow navigation to configured servers (localhost, 127.0.0.1, or remote)
|
|
||||||
if is_allowed_server(&app_for_nav, url) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Open external http/https URLs in default browser
|
|
||||||
if url.scheme() == "http" || url.scheme() == "https" {
|
|
||||||
let _ = app_for_nav.shell().open(url.as_str(), None);
|
|
||||||
return false; // Cancel internal navigation
|
|
||||||
}
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.initialization_script(format!(
|
.initialization_script(format!(
|
||||||
r#"
|
r#"
|
||||||
window.__OPENCODE__ ??= {{}};
|
window.__OPENCODE__ ??= {{}};
|
||||||
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
||||||
window.__OPENCODE__.port = {port};
|
|
||||||
"#
|
"#
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -335,7 +265,7 @@ pub fn run() {
|
|||||||
.hidden_title(true);
|
.hidden_title(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let window = window_builder.build().expect("Failed to create window");
|
window_builder.build().expect("Failed to create window");
|
||||||
|
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
app.manage(ServerState::new(None, rx));
|
app.manage(ServerState::new(None, rx));
|
||||||
@@ -343,43 +273,29 @@ pub fn run() {
|
|||||||
{
|
{
|
||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let should_spawn_sidecar = !is_server_running(port).await;
|
let mut custom_url = None;
|
||||||
|
|
||||||
let (child, res) = if should_spawn_sidecar {
|
if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
|
||||||
let child = spawn_sidecar(&app, port);
|
println!("Using desktop-specific custom URL: {url}");
|
||||||
|
custom_url = Some(url);
|
||||||
let timestamp = Instant::now();
|
|
||||||
let res = loop {
|
|
||||||
if timestamp.elapsed() > Duration::from_secs(7) {
|
|
||||||
break Err(format!(
|
|
||||||
"Failed to spawn OpenCode Server. Logs:\n{}",
|
|
||||||
get_logs(app.clone()).await.unwrap()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
||||||
|
|
||||||
if is_server_running(port).await {
|
|
||||||
// give the server a little bit more time to warm up
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
||||||
|
|
||||||
break Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("Server ready after {:?}", timestamp.elapsed());
|
|
||||||
|
|
||||||
(Some(child), res)
|
|
||||||
} else {
|
|
||||||
(None, Ok(()))
|
|
||||||
};
|
|
||||||
|
|
||||||
app.state::<ServerState>().set_child(child);
|
|
||||||
|
|
||||||
if res.is_ok() {
|
|
||||||
let _ = window.eval("window.__OPENCODE__.serverReady = true;");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if custom_url.is_none()
|
||||||
|
&& let Some(cli_config) = cli::get_config(&app).await
|
||||||
|
&& let Some(url) = get_server_url_from_config(&cli_config)
|
||||||
|
{
|
||||||
|
println!("Using custom server URL from config: {url}");
|
||||||
|
custom_url = Some(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = match setup_server_connection(&app, custom_url).await {
|
||||||
|
Ok((child, url)) => {
|
||||||
|
app.state::<ServerState>().set_child(child);
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
};
|
||||||
|
|
||||||
let _ = tx.send(res);
|
let _ = tx.send(res);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -411,3 +327,82 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
|
||||||
|
let server = config.server.as_ref()?;
|
||||||
|
let port = server.port?;
|
||||||
|
println!("server.port found in OC config: {port}");
|
||||||
|
let hostname = server.hostname.as_ref();
|
||||||
|
|
||||||
|
Some(format!(
|
||||||
|
"http://{}:{}",
|
||||||
|
hostname.map(|v| v.as_str()).unwrap_or("127.0.0.1"),
|
||||||
|
port
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_server_connection(
|
||||||
|
app: &AppHandle,
|
||||||
|
custom_url: Option<String>,
|
||||||
|
) -> Result<(Option<CommandChild>, String), String> {
|
||||||
|
if let Some(url) = custom_url {
|
||||||
|
loop {
|
||||||
|
if check_server_health(&url).await {
|
||||||
|
println!("Connected to custom server: {}", url);
|
||||||
|
return Ok((None, url.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETRY: &str = "Retry";
|
||||||
|
|
||||||
|
let res = app.dialog()
|
||||||
|
.message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
|
||||||
|
.title("Connection Failed")
|
||||||
|
.buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
|
||||||
|
.blocking_show_with_result();
|
||||||
|
|
||||||
|
match res {
|
||||||
|
MessageDialogResult::Custom(name) if name == RETRY => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_port = get_sidecar_port();
|
||||||
|
let local_url = format!("http://127.0.0.1:{local_port}");
|
||||||
|
|
||||||
|
if !check_server_health(&local_url).await {
|
||||||
|
match spawn_local_server(app, local_port).await {
|
||||||
|
Ok(child) => Ok(Some(child)),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
.map(|child| (child, local_url))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild, String> {
|
||||||
|
let child = spawn_sidecar(app, port);
|
||||||
|
let url = format!("http://127.0.0.1:{port}");
|
||||||
|
|
||||||
|
let timestamp = Instant::now();
|
||||||
|
loop {
|
||||||
|
if timestamp.elapsed() > Duration::from_secs(7) {
|
||||||
|
break Err(format!(
|
||||||
|
"Failed to spawn OpenCode Server. Logs:\n{}",
|
||||||
|
get_logs(app.clone()).await.unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
if check_server_health(&url).await {
|
||||||
|
println!("Server ready after {:?}", timestamp.elapsed());
|
||||||
|
break Ok(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"icons/dev/icon.ico"
|
"icons/dev/icon.ico"
|
||||||
],
|
],
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["deb", "rpm", "dmg", "nsis", "app", "appimage"],
|
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
|
||||||
"externalBin": ["sidecars/opencode-cli"],
|
"externalBin": ["sidecars/opencode-cli"],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"entitlements": "./entitlements.plist"
|
"entitlements": "./entitlements.plist"
|
||||||
|
|||||||
@@ -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 { Suspense, createResource, ParentProps } from "solid-js"
|
import { Accessor, JSX, createResource } from "solid-js"
|
||||||
|
|
||||||
import { UPDATER_ENABLED } from "./updater"
|
import { UPDATER_ENABLED } from "./updater"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
@@ -257,6 +257,15 @@ const platform: Platform = {
|
|||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
fetch: tauriFetch,
|
fetch: tauriFetch,
|
||||||
|
|
||||||
|
getDefaultServerUrl: async () => {
|
||||||
|
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
setDefaultServerUrl: async (url: string | null) => {
|
||||||
|
await invoke("set_default_server_url", { url })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
createMenu()
|
createMenu()
|
||||||
@@ -273,35 +282,28 @@ render(() => {
|
|||||||
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
|
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
|
||||||
)}
|
)}
|
||||||
<AppBaseProviders>
|
<AppBaseProviders>
|
||||||
<ServerGate>
|
<ServerGate>{(serverUrl) => <AppInterface defaultUrl={serverUrl()} />}</ServerGate>
|
||||||
<AppInterface />
|
|
||||||
</ServerGate>
|
|
||||||
</AppBaseProviders>
|
</AppBaseProviders>
|
||||||
</PlatformProvider>
|
</PlatformProvider>
|
||||||
)
|
)
|
||||||
}, root!)
|
}, root!)
|
||||||
|
|
||||||
// Gate component that waits for the server to be ready
|
// Gate component that waits for the server to be ready
|
||||||
function ServerGate(props: ParentProps) {
|
function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element }) {
|
||||||
const [status] = createResource(async () => {
|
const [serverUrl] = createResource<string>(() => invoke("ensure_server_ready"))
|
||||||
if (window.__OPENCODE__?.serverReady) return
|
|
||||||
return await invoke("ensure_server_started")
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Not using suspense as not all components are compatible with it (undefined refs)
|
// Not using suspense as not all components are compatible with it (undefined refs)
|
||||||
<Show
|
<Show
|
||||||
when={status.state !== "pending"}
|
when={serverUrl.state !== "pending" && serverUrl()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||||
<Logo class="w-xl opacity-12 animate-pulse" />
|
<Logo class="w-xl opacity-12 animate-pulse" />
|
||||||
<div class="mt-8 text-14-regular text-text-weak">Starting server...</div>
|
<div class="mt-8 text-14-regular text-text-weak">Initializing...</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Trigger error boundary without rendering the returned value */}
|
{(serverUrl) => props.children(serverUrl)}
|
||||||
{(status(), null)}
|
|
||||||
{props.children}
|
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const host = process.env.TAURI_DEV_HOST
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [appPlugin],
|
plugins: [appPlugin],
|
||||||
|
publicDir: "../app/public",
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
// 1. prevent Vite from obscuring rust errors
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
|||||||
@@ -57,11 +57,10 @@ Example: `mintlify`
|
|||||||
Hex color codes for your global theme
|
Hex color codes for your global theme
|
||||||
<Expandable title="Colors">
|
<Expandable title="Colors">
|
||||||
<ResponseField name="primary" type="string" required>
|
<ResponseField name="primary" type="string" required>
|
||||||
The primary color. Used for most often for highlighted content, section headers, accents, in light mode
|
The primary color. Used most often for highlighted content, section headers, accents, in light mode
|
||||||
</ResponseField>
|
</ResponseField>
|
||||||
<ResponseField name="light" type="string">
|
<ResponseField name="light" type="string">
|
||||||
The primary color for dark mode. Used for most often for highlighted content, section headers, accents, in dark
|
The primary color for dark mode. Used most often for highlighted content, section headers, accents, in dark mode
|
||||||
mode
|
|
||||||
</ResponseField>
|
</ResponseField>
|
||||||
<ResponseField name="dark" type="string">
|
<ResponseField name="dark" type="string">
|
||||||
The primary color for important buttons
|
The primary color for important buttons
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -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.7"
|
version = "1.1.14"
|
||||||
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.7/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/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.7/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/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.7/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/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.7/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/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.7/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.1.7",
|
"version": "1.1.14",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -81,8 +81,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.70",
|
"@opentui/core": "0.1.72",
|
||||||
"@opentui/solid": "0.1.70",
|
"@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",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Todo } from "@/session/todo"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { LoadAPIKeyError } from "ai"
|
import { LoadAPIKeyError } from "ai"
|
||||||
import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||||
|
import { applyPatch } from "diff"
|
||||||
|
|
||||||
export namespace ACP {
|
export namespace ACP {
|
||||||
const log = Log.create({ service: "acp-agent" })
|
const log = Log.create({ service: "acp-agent" })
|
||||||
@@ -105,6 +106,22 @@ export namespace ACP {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
|
||||||
|
const metadata = permission.metadata || {}
|
||||||
|
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
|
||||||
|
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
|
||||||
|
|
||||||
|
const content = await Bun.file(filepath).text()
|
||||||
|
const newContent = getNewContent(content, diff)
|
||||||
|
|
||||||
|
if (newContent) {
|
||||||
|
this.connection.writeTextFile({
|
||||||
|
sessionId: sessionId,
|
||||||
|
path: filepath,
|
||||||
|
content: newContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
await this.config.sdk.permission.reply({
|
await this.config.sdk.permission.reply({
|
||||||
requestID: permission.id,
|
requestID: permission.id,
|
||||||
reply: res.outcome.optionId as "once" | "always" | "reject",
|
reply: res.outcome.optionId as "once" | "always" | "reject",
|
||||||
@@ -1095,4 +1112,13 @@ export namespace ACP {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
|
||||||
|
const result = applyPatch(fileOriginal, unifiedDiff)
|
||||||
|
if (result === false) {
|
||||||
|
log.error("Failed to apply unified diff (context mismatch)")
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Global } from "../global"
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
|
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
export const Oauth = z
|
export const Oauth = z
|
||||||
.object({
|
.object({
|
||||||
@@ -10,6 +12,7 @@ export namespace Auth {
|
|||||||
refresh: z.string(),
|
refresh: z.string(),
|
||||||
access: z.string(),
|
access: z.string(),
|
||||||
expires: z.number(),
|
expires: z.number(),
|
||||||
|
accountId: z.string().optional(),
|
||||||
enterpriseUrl: z.string().optional(),
|
enterpriseUrl: z.string().optional(),
|
||||||
})
|
})
|
||||||
.meta({ ref: "OAuth" })
|
.meta({ ref: "OAuth" })
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export const AuthLoginCommand = cmd({
|
|||||||
hint: {
|
hint: {
|
||||||
opencode: "recommended",
|
opencode: "recommended",
|
||||||
anthropic: "Claude Max or API key",
|
anthropic: "Claude Max or API key",
|
||||||
|
openai: "ChatGPT Plus/Pro or API key",
|
||||||
}[x.id],
|
}[x.id],
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
@@ -341,8 +342,6 @@ export const AuthLoginCommand = cmd({
|
|||||||
"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 environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
|
||||||
)
|
)
|
||||||
prompts.outro("Done")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === "opencode") {
|
if (provider === "opencode") {
|
||||||
|
|||||||
@@ -7,29 +7,36 @@ import { bootstrap } from "../bootstrap"
|
|||||||
import { Command } from "../../command"
|
import { Command } from "../../command"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { select } from "@clack/prompts"
|
import { select } from "@clack/prompts"
|
||||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import { Server } from "../../server/server"
|
import { Server } from "../../server/server"
|
||||||
import { Provider } from "../../provider/provider"
|
import { Provider } from "../../provider/provider"
|
||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
|
import { resolveNetworkOptions, withNetworkOptions } from "../network"
|
||||||
|
import { Locale } from "@/util/locale"
|
||||||
|
|
||||||
const TOOL: Record<string, [string, string]> = {
|
const TOOL_ICON: Record<string, string> = {
|
||||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
bash: "$",
|
||||||
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
codesearch: "◇",
|
||||||
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
edit: "←",
|
||||||
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
glob: "✱",
|
||||||
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
grep: "✱",
|
||||||
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
|
list: "→",
|
||||||
list: ["List", UI.Style.TEXT_INFO_BOLD],
|
patch: "%",
|
||||||
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
|
question: "→",
|
||||||
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
|
read: "→",
|
||||||
websearch: ["Search", UI.Style.TEXT_DIM_BOLD],
|
task: "◉",
|
||||||
|
todoread: "⚙",
|
||||||
|
todowrite: "⚙",
|
||||||
|
webfetch: "%",
|
||||||
|
websearch: "◈",
|
||||||
|
write: "←",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RunCommand = cmd({
|
export const RunCommand = cmd({
|
||||||
command: "run [message..]",
|
command: "run [message..]",
|
||||||
describe: "run opencode with a message",
|
describe: "run opencode with a message",
|
||||||
builder: (yargs: Argv) => {
|
builder: (yargs: Argv) => {
|
||||||
return yargs
|
return withNetworkOptions(yargs)
|
||||||
.positional("message", {
|
.positional("message", {
|
||||||
describe: "message to send",
|
describe: "message to send",
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -83,10 +90,6 @@ export const RunCommand = cmd({
|
|||||||
type: "string",
|
type: "string",
|
||||||
describe: "attach to a running opencode server (e.g., http://localhost:4096)",
|
describe: "attach to a running opencode server (e.g., http://localhost:4096)",
|
||||||
})
|
})
|
||||||
.option("port", {
|
|
||||||
type: "number",
|
|
||||||
describe: "port for the local server (defaults to random port if no value provided)",
|
|
||||||
})
|
|
||||||
.option("variant", {
|
.option("variant", {
|
||||||
type: "string",
|
type: "string",
|
||||||
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
|
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
|
||||||
@@ -134,16 +137,144 @@ export const RunCommand = cmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const execute = async (sdk: OpencodeClient, sessionID: string) => {
|
const execute = async (sdk: OpencodeClient, sessionID: string) => {
|
||||||
const printEvent = (color: string, type: string, title: string) => {
|
const normalizePath = (input?: string) => {
|
||||||
UI.println(
|
if (!input) return ""
|
||||||
color + `|`,
|
if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
|
||||||
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
|
return input
|
||||||
"",
|
|
||||||
UI.Style.TEXT_NORMAL + title,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputJsonEvent = (type: string, data: any) => {
|
const formatInput = (input: Record<string, unknown>, omit?: string[]) => {
|
||||||
|
const entries = Object.entries(input).filter(([key, value]) => {
|
||||||
|
if (omit?.includes(key)) return false
|
||||||
|
if (typeof value === "string") return true
|
||||||
|
if (typeof value === "number") return true
|
||||||
|
if (typeof value === "boolean") return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (entries.length === 0) return ""
|
||||||
|
return `[${entries.map(([key, value]) => `${key}=${value}`).join(", ")}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolLine = (part: ToolPart) => {
|
||||||
|
const state = part.state.status === "completed" ? part.state : undefined
|
||||||
|
const input = (state?.input ?? {}) as Record<string, unknown>
|
||||||
|
const meta = (state?.metadata ?? {}) as Record<string, unknown>
|
||||||
|
if (part.tool === "read") {
|
||||||
|
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||||
|
const detail = formatInput(input, ["filePath"])
|
||||||
|
if (!detail) return `Read ${normalizePath(filePath)}`
|
||||||
|
return `Read ${normalizePath(filePath)} ${detail}`
|
||||||
|
}
|
||||||
|
if (part.tool === "write") {
|
||||||
|
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||||
|
return `Write ${normalizePath(filePath)}`
|
||||||
|
}
|
||||||
|
if (part.tool === "edit") {
|
||||||
|
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||||
|
const detail = formatInput({ replaceAll: input.replaceAll })
|
||||||
|
if (!detail) return `Edit ${normalizePath(filePath)}`
|
||||||
|
return `Edit ${normalizePath(filePath)} ${detail}`
|
||||||
|
}
|
||||||
|
if (part.tool === "glob") {
|
||||||
|
const pattern = typeof input.pattern === "string" ? input.pattern : ""
|
||||||
|
const dir = typeof input.path === "string" ? normalizePath(input.path) : ""
|
||||||
|
const count = typeof meta.count === "number" ? meta.count : undefined
|
||||||
|
const parts = [`Glob "${pattern}"`]
|
||||||
|
if (dir) parts.push(`in ${dir}`)
|
||||||
|
if (count !== undefined) parts.push(`(${count} matches)`)
|
||||||
|
return parts.join(" ")
|
||||||
|
}
|
||||||
|
if (part.tool === "grep") {
|
||||||
|
const pattern = typeof input.pattern === "string" ? input.pattern : ""
|
||||||
|
const dir = typeof input.path === "string" ? normalizePath(input.path) : ""
|
||||||
|
const matches = typeof meta.matches === "number" ? meta.matches : undefined
|
||||||
|
const parts = [`Grep "${pattern}"`]
|
||||||
|
if (dir) parts.push(`in ${dir}`)
|
||||||
|
if (matches !== undefined) parts.push(`(${matches} matches)`)
|
||||||
|
return parts.join(" ")
|
||||||
|
}
|
||||||
|
if (part.tool === "list") {
|
||||||
|
const dir = typeof input.path === "string" ? normalizePath(input.path) : ""
|
||||||
|
if (!dir) return "List"
|
||||||
|
return `List ${dir}`
|
||||||
|
}
|
||||||
|
if (part.tool === "webfetch") {
|
||||||
|
const url = typeof input.url === "string" ? input.url : ""
|
||||||
|
if (!url) return "WebFetch"
|
||||||
|
return `WebFetch ${url}`
|
||||||
|
}
|
||||||
|
if (part.tool === "codesearch") {
|
||||||
|
const query = typeof input.query === "string" ? input.query : ""
|
||||||
|
const results = typeof meta.results === "number" ? meta.results : undefined
|
||||||
|
const parts = [`Exa Code Search "${query}"`]
|
||||||
|
if (results !== undefined) parts.push(`(${results} results)`)
|
||||||
|
return parts.join(" ")
|
||||||
|
}
|
||||||
|
if (part.tool === "websearch") {
|
||||||
|
const query = typeof input.query === "string" ? input.query : ""
|
||||||
|
const results = typeof meta.numResults === "number" ? meta.numResults : undefined
|
||||||
|
const parts = [`Exa Web Search "${query}"`]
|
||||||
|
if (results !== undefined) parts.push(`(${results} results)`)
|
||||||
|
return parts.join(" ")
|
||||||
|
}
|
||||||
|
if (part.tool === "task") {
|
||||||
|
const desc = typeof input.description === "string" ? input.description : "Task"
|
||||||
|
const agent = typeof input.subagent_type === "string" ? input.subagent_type : "Task"
|
||||||
|
return `${agent} Task "${desc}"`
|
||||||
|
}
|
||||||
|
if (part.tool === "todowrite" || part.tool === "todoread") {
|
||||||
|
const count = Array.isArray(input.todos) ? input.todos.length : 0
|
||||||
|
if (count) return `Todos (${count})`
|
||||||
|
return "Todos"
|
||||||
|
}
|
||||||
|
if (part.tool === "question") {
|
||||||
|
const count = Array.isArray(input.questions) ? input.questions.length : 0
|
||||||
|
return `Asked ${count} question${count === 1 ? "" : "s"}`
|
||||||
|
}
|
||||||
|
if (part.tool === "patch") {
|
||||||
|
return "Patch"
|
||||||
|
}
|
||||||
|
const detail = formatInput(input)
|
||||||
|
if (!detail) return part.tool
|
||||||
|
return `${part.tool} ${detail}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const printTool = (part: ToolPart) => {
|
||||||
|
if (part.tool === "bash") {
|
||||||
|
const state = part.state.status === "completed" ? part.state : undefined
|
||||||
|
if (!state) return
|
||||||
|
UI.empty()
|
||||||
|
const input = (state.input ?? {}) as Record<string, unknown>
|
||||||
|
const meta = (state.metadata ?? {}) as Record<string, unknown>
|
||||||
|
const desc = typeof input.description === "string" ? input.description : undefined
|
||||||
|
const title = desc ?? state.title ?? "Shell"
|
||||||
|
UI.println(UI.Style.TEXT_DIM + "# " + title)
|
||||||
|
const command = typeof input.command === "string" ? input.command : ""
|
||||||
|
if (command) UI.println(UI.Style.TEXT_NORMAL + "$ " + command)
|
||||||
|
const output = typeof state.output === "string" ? state.output.trimEnd() : undefined
|
||||||
|
const metaOutput = typeof meta.output === "string" ? meta.output.trimEnd() : undefined
|
||||||
|
const result = output ?? metaOutput
|
||||||
|
if (result) UI.println(UI.Style.TEXT_NORMAL + result)
|
||||||
|
UI.empty()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const icon = TOOL_ICON[part.tool] ?? "⚙"
|
||||||
|
const line = toolLine(part)
|
||||||
|
UI.println(UI.Style.TEXT_NORMAL + icon, UI.Style.TEXT_NORMAL + line)
|
||||||
|
}
|
||||||
|
|
||||||
|
const printUserMessage = () => {
|
||||||
|
if (args.format === "json") return
|
||||||
|
const trimmed = message.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
const single = trimmed.replace(/\s+/g, " ")
|
||||||
|
UI.println(UI.Style.TEXT_NORMAL_BOLD + "▌", UI.Style.TEXT_NORMAL + single)
|
||||||
|
UI.empty()
|
||||||
|
userPrinted = true
|
||||||
|
printHeader()
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputJsonEvent = (type: string, data: Record<string, unknown>) => {
|
||||||
if (args.format === "json") {
|
if (args.format === "json") {
|
||||||
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
||||||
return true
|
return true
|
||||||
@@ -152,6 +283,19 @@ export const RunCommand = cmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const events = await sdk.event.subscribe()
|
const events = await sdk.event.subscribe()
|
||||||
|
let header: { agent: string; modelID: string } | undefined
|
||||||
|
let headerPrinted = false
|
||||||
|
let userPrinted = false
|
||||||
|
const printHeader = () => {
|
||||||
|
if (!process.stdout.isTTY) return
|
||||||
|
if (!header || headerPrinted) return
|
||||||
|
UI.empty()
|
||||||
|
UI.println(
|
||||||
|
UI.Style.TEXT_NORMAL + "▣ " + Locale.titlecase(header.agent) + UI.Style.TEXT_DIM + " · " + header.modelID,
|
||||||
|
)
|
||||||
|
UI.empty()
|
||||||
|
headerPrinted = true
|
||||||
|
}
|
||||||
let errorMsg: string | undefined
|
let errorMsg: string | undefined
|
||||||
|
|
||||||
const eventProcessor = (async () => {
|
const eventProcessor = (async () => {
|
||||||
@@ -162,15 +306,7 @@ export const RunCommand = cmd({
|
|||||||
|
|
||||||
if (part.type === "tool" && part.state.status === "completed") {
|
if (part.type === "tool" && part.state.status === "completed") {
|
||||||
if (outputJsonEvent("tool_use", { part })) continue
|
if (outputJsonEvent("tool_use", { part })) continue
|
||||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
printTool(part as ToolPart)
|
||||||
const title =
|
|
||||||
part.state.title ||
|
|
||||||
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
|
|
||||||
printEvent(color, tool, title)
|
|
||||||
if (part.tool === "bash" && part.state.output?.trim()) {
|
|
||||||
UI.println()
|
|
||||||
UI.println(part.state.output)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.type === "step-start") {
|
if (part.type === "step-start") {
|
||||||
@@ -184,9 +320,19 @@ export const RunCommand = cmd({
|
|||||||
if (part.type === "text" && part.time?.end) {
|
if (part.type === "text" && part.time?.end) {
|
||||||
if (outputJsonEvent("text", { part })) continue
|
if (outputJsonEvent("text", { part })) continue
|
||||||
const isPiped = !process.stdout.isTTY
|
const isPiped = !process.stdout.isTTY
|
||||||
if (!isPiped) UI.println()
|
if (!isPiped) UI.empty()
|
||||||
|
if (!isPiped) UI.empty()
|
||||||
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
|
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
|
||||||
if (!isPiped) UI.println()
|
if (!isPiped) UI.empty()
|
||||||
|
if (!isPiped) UI.empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "message.updated") {
|
||||||
|
const info = event.properties.info
|
||||||
|
if (info.sessionID === sessionID && info.role === "assistant") {
|
||||||
|
header = { agent: info.agent, modelID: info.modelID }
|
||||||
|
if (userPrinted) printHeader()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +397,8 @@ export const RunCommand = cmd({
|
|||||||
return args.agent
|
return args.agent
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
printUserMessage()
|
||||||
|
|
||||||
if (args.command) {
|
if (args.command) {
|
||||||
await sdk.session.command({
|
await sdk.session.command({
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -339,7 +487,8 @@ export const RunCommand = cmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const server = Server.listen({ port: args.port ?? 0, hostname: "127.0.0.1" })
|
const opts = await resolveNetworkOptions(args)
|
||||||
|
const server = Server.listen(opts)
|
||||||
const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` })
|
const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` })
|
||||||
|
|
||||||
if (args.command) {
|
if (args.command) {
|
||||||
|
|||||||
@@ -97,7 +97,16 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tui(input: { url: string; args: Args; directory?: string; onExit?: () => Promise<void> }) {
|
import type { EventSource } from "./context/sdk"
|
||||||
|
|
||||||
|
export function tui(input: {
|
||||||
|
url: string
|
||||||
|
args: Args
|
||||||
|
directory?: string
|
||||||
|
fetch?: typeof fetch
|
||||||
|
events?: EventSource
|
||||||
|
onExit?: () => Promise<void>
|
||||||
|
}) {
|
||||||
// promise to prevent immediate exit
|
// promise to prevent immediate exit
|
||||||
return new Promise<void>(async (resolve) => {
|
return new Promise<void>(async (resolve) => {
|
||||||
const mode = await getTerminalBackgroundColor()
|
const mode = await getTerminalBackgroundColor()
|
||||||
@@ -117,7 +126,12 @@ export function tui(input: { url: string; args: Args; directory?: string; onExit
|
|||||||
<KVProvider>
|
<KVProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<RouteProvider>
|
<RouteProvider>
|
||||||
<SDKProvider url={input.url} directory={input.directory}>
|
<SDKProvider
|
||||||
|
url={input.url}
|
||||||
|
directory={input.directory}
|
||||||
|
fetch={input.fetch}
|
||||||
|
events={input.events}
|
||||||
|
>
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
<ThemeProvider mode={mode}>
|
<ThemeProvider mode={mode}>
|
||||||
<LocalProvider>
|
<LocalProvider>
|
||||||
@@ -592,15 +606,6 @@ function App() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on(Installation.Event.Updated.type, (evt) => {
|
|
||||||
toast.show({
|
|
||||||
variant: "success",
|
|
||||||
title: "Update Complete",
|
|
||||||
message: `OpenCode updated to v${evt.properties.version}`,
|
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
|
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
|
||||||
toast.show({
|
toast.show({
|
||||||
variant: "info",
|
variant: "info",
|
||||||
|
|||||||
@@ -33,76 +33,82 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
const q = query()
|
const q = query()
|
||||||
const favorites = showExtra() ? local.model.favorite() : []
|
const needle = q.trim()
|
||||||
|
const showSections = showExtra() && needle.length === 0
|
||||||
|
const favorites = connected() ? local.model.favorite() : []
|
||||||
const recents = local.model.recent()
|
const recents = local.model.recent()
|
||||||
|
|
||||||
const recentList = showExtra()
|
const recentList = showSections
|
||||||
? recents.filter(
|
? recents.filter(
|
||||||
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
|
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const favoriteOptions = favorites.flatMap((item) => {
|
const favoriteOptions = showSections
|
||||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
? favorites.flatMap((item) => {
|
||||||
if (!provider) return []
|
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||||
const model = provider.models[item.modelID]
|
if (!provider) return []
|
||||||
if (!model) return []
|
const model = provider.models[item.modelID]
|
||||||
return [
|
if (!model) return []
|
||||||
{
|
return [
|
||||||
key: item,
|
{
|
||||||
value: {
|
key: item,
|
||||||
providerID: provider.id,
|
value: {
|
||||||
modelID: model.id,
|
|
||||||
},
|
|
||||||
title: model.name ?? item.modelID,
|
|
||||||
description: provider.name,
|
|
||||||
category: "Favorites",
|
|
||||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
|
||||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
|
||||||
onSelect: () => {
|
|
||||||
dialog.clear()
|
|
||||||
local.model.set(
|
|
||||||
{
|
|
||||||
providerID: provider.id,
|
providerID: provider.id,
|
||||||
modelID: model.id,
|
modelID: model.id,
|
||||||
},
|
},
|
||||||
{ recent: true },
|
title: model.name ?? item.modelID,
|
||||||
)
|
description: provider.name,
|
||||||
},
|
category: "Favorites",
|
||||||
},
|
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||||
]
|
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||||
})
|
onSelect: () => {
|
||||||
|
dialog.clear()
|
||||||
|
local.model.set(
|
||||||
|
{
|
||||||
|
providerID: provider.id,
|
||||||
|
modelID: model.id,
|
||||||
|
},
|
||||||
|
{ recent: true },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
const recentOptions = recentList.flatMap((item) => {
|
const recentOptions = showSections
|
||||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
? recentList.flatMap((item) => {
|
||||||
if (!provider) return []
|
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||||
const model = provider.models[item.modelID]
|
if (!provider) return []
|
||||||
if (!model) return []
|
const model = provider.models[item.modelID]
|
||||||
return [
|
if (!model) return []
|
||||||
{
|
return [
|
||||||
key: item,
|
{
|
||||||
value: {
|
key: item,
|
||||||
providerID: provider.id,
|
value: {
|
||||||
modelID: model.id,
|
|
||||||
},
|
|
||||||
title: model.name ?? item.modelID,
|
|
||||||
description: provider.name,
|
|
||||||
category: "Recent",
|
|
||||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
|
||||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
|
||||||
onSelect: () => {
|
|
||||||
dialog.clear()
|
|
||||||
local.model.set(
|
|
||||||
{
|
|
||||||
providerID: provider.id,
|
providerID: provider.id,
|
||||||
modelID: model.id,
|
modelID: model.id,
|
||||||
},
|
},
|
||||||
{ recent: true },
|
title: model.name ?? item.modelID,
|
||||||
)
|
description: provider.name,
|
||||||
},
|
category: "Recent",
|
||||||
},
|
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||||
]
|
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||||
})
|
onSelect: () => {
|
||||||
|
dialog.clear()
|
||||||
|
local.model.set(
|
||||||
|
{
|
||||||
|
providerID: provider.id,
|
||||||
|
modelID: model.id,
|
||||||
|
},
|
||||||
|
{ recent: true },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
const providerOptions = pipe(
|
const providerOptions = pipe(
|
||||||
sync.data.provider,
|
sync.data.provider,
|
||||||
@@ -145,6 +151,7 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
filter((x) => {
|
filter((x) => {
|
||||||
|
if (!showSections) return true
|
||||||
const value = x.value
|
const value = x.value
|
||||||
const inFavorites = favorites.some(
|
const inFavorites = favorites.some(
|
||||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||||
@@ -177,16 +184,11 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
// Apply fuzzy filtering to each section separately, maintaining section order
|
// Search shows a single merged list (favorites inline)
|
||||||
if (q) {
|
if (needle) {
|
||||||
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj)
|
const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
|
||||||
const filteredRecents = fuzzysort
|
const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj)
|
||||||
.go(q, recentOptions, { keys: ["title"] })
|
return [...filteredProviders, ...filteredPopular]
|
||||||
.map((x) => x.obj)
|
|
||||||
.slice(0, 5)
|
|
||||||
const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
|
|
||||||
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
|
|
||||||
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
|
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function createDialogProviderOptions() {
|
|||||||
description: {
|
description: {
|
||||||
opencode: "(Recommended)",
|
opencode: "(Recommended)",
|
||||||
anthropic: "(Claude Max or API key)",
|
anthropic: "(Claude Max or API key)",
|
||||||
|
openai: "(ChatGPT Plus/Pro or API key)",
|
||||||
}[provider.id],
|
}[provider.id],
|
||||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
|
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
|
||||||
async onSelect() {
|
async onSelect() {
|
||||||
@@ -111,8 +112,7 @@ function AutoMethod(props: AutoMethodProps) {
|
|||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
|
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
|
||||||
const code =
|
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.url
|
||||||
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.instructions
|
|
||||||
Clipboard.copy(code)
|
Clipboard.copy(code)
|
||||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { createMemo, createSignal, For } from "solid-js"
|
|
||||||
import { useTheme } from "@tui/context/theme"
|
|
||||||
import { useKeybind } from "@tui/context/keybind"
|
|
||||||
import { TIPS } from "./tips"
|
|
||||||
import { EmptyBorder } from "./border"
|
|
||||||
|
|
||||||
type TipPart = { text: string; highlight: boolean }
|
|
||||||
|
|
||||||
function parseTip(tip: string): TipPart[] {
|
|
||||||
const parts: TipPart[] = []
|
|
||||||
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
|
|
||||||
let lastIndex = 0
|
|
||||||
let match
|
|
||||||
|
|
||||||
while ((match = regex.exec(tip)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push({ text: tip.slice(lastIndex, match.index), highlight: false })
|
|
||||||
}
|
|
||||||
parts.push({ text: match[1], highlight: true })
|
|
||||||
lastIndex = regex.lastIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < tip.length) {
|
|
||||||
parts.push({ text: tip.slice(lastIndex), highlight: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
|
|
||||||
const [tipIndex, setTipIndex] = createSignal(Math.floor(Math.random() * TIPS.length))
|
|
||||||
|
|
||||||
export function randomizeTip() {
|
|
||||||
setTipIndex(Math.floor(Math.random() * TIPS.length))
|
|
||||||
}
|
|
||||||
|
|
||||||
const BOX_WIDTH = 42
|
|
||||||
const TITLE = " 🅘 Did you know? "
|
|
||||||
|
|
||||||
export function DidYouKnow() {
|
|
||||||
const { theme } = useTheme()
|
|
||||||
const keybind = useKeybind()
|
|
||||||
|
|
||||||
const tipParts = createMemo(() => parseTip(TIPS[tipIndex()]))
|
|
||||||
|
|
||||||
const dashes = createMemo(() => {
|
|
||||||
// ╭─ + title + ─...─ + ╮ = BOX_WIDTH
|
|
||||||
// 1 + 1 + title.length + dashes + 1 = BOX_WIDTH
|
|
||||||
return Math.max(0, BOX_WIDTH - 2 - TITLE.length - 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box position="absolute" bottom={3} right={2} width={BOX_WIDTH}>
|
|
||||||
<text>
|
|
||||||
<span style={{ fg: theme.border }}>╭─</span>
|
|
||||||
<span style={{ fg: theme.text }}>{TITLE}</span>
|
|
||||||
<span style={{ fg: theme.border }}>{"─".repeat(dashes())}╮</span>
|
|
||||||
</text>
|
|
||||||
<box
|
|
||||||
border={["left", "right", "bottom"]}
|
|
||||||
borderColor={theme.border}
|
|
||||||
customBorderChars={{
|
|
||||||
...EmptyBorder,
|
|
||||||
bottomLeft: "╰",
|
|
||||||
bottomRight: "╯",
|
|
||||||
horizontal: "─",
|
|
||||||
vertical: "│",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
|
||||||
<text>
|
|
||||||
<For each={tipParts()}>
|
|
||||||
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
|
|
||||||
</For>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" justifyContent="flex-end">
|
|
||||||
<text>
|
|
||||||
<span style={{ fg: theme.text }}>{keybind.print("tips_toggle")}</span>
|
|
||||||
<span style={{ fg: theme.textMuted }}> hide tips</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
|
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { firstBy } from "remeda"
|
import { firstBy } from "remeda"
|
||||||
import { createMemo, createResource, createEffect, onMount, onCleanup, For, Show, createSignal } from "solid-js"
|
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
@@ -686,7 +686,7 @@ export function Autocomplete(props: {
|
|||||||
height={height()}
|
height={height()}
|
||||||
scrollbarOptions={{ visible: false }}
|
scrollbarOptions={{ visible: false }}
|
||||||
>
|
>
|
||||||
<For
|
<Index
|
||||||
each={options()}
|
each={options()}
|
||||||
fallback={
|
fallback={
|
||||||
<box paddingLeft={1} paddingRight={1}>
|
<box paddingLeft={1} paddingRight={1}>
|
||||||
@@ -698,20 +698,22 @@ export function Autocomplete(props: {
|
|||||||
<box
|
<box
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={index() === store.selected ? theme.primary : undefined}
|
backgroundColor={index === store.selected ? theme.primary : undefined}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
|
onMouseOver={() => moveTo(index)}
|
||||||
|
onMouseUp={() => select()}
|
||||||
>
|
>
|
||||||
<text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
|
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
|
||||||
{option.display}
|
{option().display}
|
||||||
</text>
|
</text>
|
||||||
<Show when={option.description}>
|
<Show when={option().description}>
|
||||||
<text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
|
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
|
||||||
{option.description}
|
{option().description}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Index>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
const fileStyleId = syntax().getStyleId("extmark.file")!
|
const fileStyleId = syntax().getStyleId("extmark.file")!
|
||||||
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
||||||
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||||
let promptPartTypeId: number
|
let promptPartTypeId = 0
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||||
input.insertText(evt.properties.text)
|
input.insertText(evt.properties.text)
|
||||||
@@ -310,15 +310,44 @@ export function Prompt(props: PromptProps) {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const ref: PromptRef = {
|
||||||
|
get focused() {
|
||||||
|
return input.focused
|
||||||
|
},
|
||||||
|
get current() {
|
||||||
|
return store.prompt
|
||||||
|
},
|
||||||
|
focus() {
|
||||||
|
input.focus()
|
||||||
|
},
|
||||||
|
blur() {
|
||||||
|
input.blur()
|
||||||
|
},
|
||||||
|
set(prompt) {
|
||||||
|
input.setText(prompt.input)
|
||||||
|
setStore("prompt", prompt)
|
||||||
|
restoreExtmarksFromParts(prompt.parts)
|
||||||
|
input.gotoBufferEnd()
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
input.clear()
|
||||||
|
input.extmarks.clear()
|
||||||
|
setStore("prompt", {
|
||||||
|
input: "",
|
||||||
|
parts: [],
|
||||||
|
})
|
||||||
|
setStore("extmarkToPartIndex", new Map())
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
submit()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.visible !== false) input?.focus()
|
if (props.visible !== false) input?.focus()
|
||||||
if (props.visible === false) input?.blur()
|
if (props.visible === false) input?.blur()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
|
||||||
})
|
|
||||||
|
|
||||||
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
setStore("extmarkToPartIndex", new Map())
|
setStore("extmarkToPartIndex", new Map())
|
||||||
@@ -452,39 +481,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
props.ref?.({
|
|
||||||
get focused() {
|
|
||||||
return input.focused
|
|
||||||
},
|
|
||||||
get current() {
|
|
||||||
return store.prompt
|
|
||||||
},
|
|
||||||
focus() {
|
|
||||||
input.focus()
|
|
||||||
},
|
|
||||||
blur() {
|
|
||||||
input.blur()
|
|
||||||
},
|
|
||||||
set(prompt) {
|
|
||||||
input.setText(prompt.input)
|
|
||||||
setStore("prompt", prompt)
|
|
||||||
restoreExtmarksFromParts(prompt.parts)
|
|
||||||
input.gotoBufferEnd()
|
|
||||||
},
|
|
||||||
reset() {
|
|
||||||
input.clear()
|
|
||||||
input.extmarks.clear()
|
|
||||||
setStore("prompt", {
|
|
||||||
input: "",
|
|
||||||
parts: [],
|
|
||||||
})
|
|
||||||
setStore("extmarkToPartIndex", new Map())
|
|
||||||
},
|
|
||||||
submit() {
|
|
||||||
submit()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
if (autocomplete?.visible) return
|
if (autocomplete?.visible) return
|
||||||
@@ -916,12 +912,15 @@ export function Prompt(props: PromptProps) {
|
|||||||
// Force layout update and render for the pasted content
|
// Force layout update and render for the pasted content
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
input.getLayoutNode().markDirty()
|
input.getLayoutNode().markDirty()
|
||||||
input.gotoBufferEnd()
|
|
||||||
renderer.requestRender()
|
renderer.requestRender()
|
||||||
}, 0)
|
}, 0)
|
||||||
}}
|
}}
|
||||||
ref={(r: TextareaRenderable) => {
|
ref={(r: TextareaRenderable) => {
|
||||||
input = r
|
input = r
|
||||||
|
if (promptPartTypeId === 0) {
|
||||||
|
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
||||||
|
}
|
||||||
|
props.ref?.(ref)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
input.cursorColor = theme.text
|
input.cursorColor = theme.text
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|||||||
+149
-102
@@ -1,103 +1,150 @@
|
|||||||
export const TIPS = [
|
import { createMemo, createSignal, For } from "solid-js"
|
||||||
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files to your prompt.",
|
import { useTheme } from "@tui/context/theme"
|
||||||
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight}).",
|
|
||||||
"Press {highlight}Tab{/highlight} to cycle between Build (full access) and Plan (read-only) agents.",
|
type TipPart = { text: string; highlight: boolean }
|
||||||
"Use {highlight}/undo{/highlight} to revert the last message and any file changes made by OpenCode.",
|
|
||||||
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes.",
|
function parse(tip: string): TipPart[] {
|
||||||
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai.",
|
const parts: TipPart[] = []
|
||||||
"Drag and drop images into the terminal to add them as context for your prompts.",
|
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
|
||||||
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard directly into the prompt.",
|
const found = Array.from(tip.matchAll(regex))
|
||||||
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor.",
|
const state = found.reduce(
|
||||||
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase structure.",
|
(acc, match) => {
|
||||||
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models.",
|
const start = match.index ?? 0
|
||||||
"Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to preview and switch between 50+ built-in themes.",
|
if (start > acc.index) {
|
||||||
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session.",
|
acc.parts.push({ text: tip.slice(acc.index, start), highlight: false })
|
||||||
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations.",
|
}
|
||||||
"Run {highlight}/compact{/highlight} to summarize long sessions when approaching context limits.",
|
acc.parts.push({ text: match[1], highlight: true })
|
||||||
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown.",
|
acc.index = start + match[0].length
|
||||||
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard.",
|
return acc
|
||||||
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands.",
|
},
|
||||||
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers.",
|
{ parts, index: 0 },
|
||||||
"The default leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions.",
|
)
|
||||||
"Press {highlight}F2{/highlight} to quickly switch between recently used models.",
|
|
||||||
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel.",
|
if (state.index < tip.length) {
|
||||||
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history.",
|
parts.push({ text: tip.slice(state.index), highlight: false })
|
||||||
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation.",
|
}
|
||||||
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message.",
|
|
||||||
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt.",
|
return parts
|
||||||
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
|
}
|
||||||
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
|
|
||||||
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
|
export function Tips() {
|
||||||
"Use {highlight}@<agent-name>{/highlight} in prompts to invoke specialized subagents.",
|
const theme = useTheme().theme
|
||||||
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
|
const parts = parse(TIPS[Math.floor(Math.random() * TIPS.length)])
|
||||||
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
|
|
||||||
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
|
return (
|
||||||
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor.",
|
<box flexDirection="row" maxWidth="100%">
|
||||||
"Configure {highlight}model{/highlight} in config to set your default model.",
|
<text flexShrink={0} style={{ fg: theme.warning }}>
|
||||||
"Override any keybind in config via the {highlight}keybinds{/highlight} section.",
|
● Tip{" "}
|
||||||
"Set any keybind to {highlight}none{/highlight} to disable it completely.",
|
</text>
|
||||||
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section.",
|
<text flexShrink={1}>
|
||||||
"OpenCode auto-handles OAuth for remote MCP servers requiring auth.",
|
<For each={parts}>
|
||||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts.",
|
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
|
||||||
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input.",
|
</For>
|
||||||
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight}).",
|
</text>
|
||||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas.",
|
</box>
|
||||||
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools.",
|
)
|
||||||
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions.',
|
}
|
||||||
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands.',
|
|
||||||
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing.',
|
const TIPS = [
|
||||||
"OpenCode auto-formats files using prettier, gofmt, ruff, and more.",
|
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files",
|
||||||
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting.',
|
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight})",
|
||||||
"Define custom formatter commands with file extensions in config.",
|
"Press {highlight}Tab{/highlight} to cycle between Build and Plan agents",
|
||||||
"OpenCode uses LSP servers for intelligent code analysis.",
|
"Use {highlight}/undo{/highlight} to revert the last message and file changes",
|
||||||
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools.",
|
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes",
|
||||||
"Tool definitions can invoke scripts written in Python, Go, etc.",
|
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai",
|
||||||
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks.",
|
"Drag and drop images into the terminal to add them as context",
|
||||||
"Use plugins to send OS notifications when sessions complete.",
|
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard into the prompt",
|
||||||
"Create a plugin to prevent OpenCode from reading sensitive files.",
|
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor",
|
||||||
"Use {highlight}opencode run{/highlight} for non-interactive scripting.",
|
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase",
|
||||||
"Use {highlight}opencode run --continue{/highlight} to resume the last session.",
|
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models",
|
||||||
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI.",
|
"Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between 50+ built-in themes",
|
||||||
"Use {highlight}--format json{/highlight} for machine-readable output in scripts.",
|
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session",
|
||||||
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode.",
|
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations",
|
||||||
"Use {highlight}opencode run --attach{/highlight} to connect to a running server for faster runs.",
|
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
|
||||||
"Run {highlight}opencode upgrade{/highlight} to update to the latest version.",
|
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown",
|
||||||
"Run {highlight}opencode auth list{/highlight} to see all configured providers.",
|
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard",
|
||||||
"Run {highlight}opencode agent create{/highlight} for guided agent creation.",
|
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands",
|
||||||
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions.",
|
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers",
|
||||||
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow.",
|
"The leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions",
|
||||||
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs.",
|
"Press {highlight}F2{/highlight} to quickly switch between recently used models",
|
||||||
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews.",
|
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel",
|
||||||
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors.',
|
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history",
|
||||||
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory.",
|
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation",
|
||||||
"Themes support dark/light variants for both modes.",
|
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message",
|
||||||
"Reference ANSI colors 0-255 in custom themes.",
|
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt",
|
||||||
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config.",
|
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field",
|
||||||
"Use {highlight}{file:path}{/highlight} to include file contents in config values.",
|
"Press {highlight}Escape{/highlight} to stop the AI mid-response",
|
||||||
"Use {highlight}instructions{/highlight} in config to load additional rules files.",
|
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
|
||||||
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative).",
|
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
|
||||||
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request.",
|
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
|
||||||
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools.',
|
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings",
|
||||||
'Use {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server.',
|
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config",
|
||||||
"Override global tool settings per agent configuration.",
|
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
|
||||||
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions.',
|
"Configure {highlight}model{/highlight} in config to set your default model",
|
||||||
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing.',
|
"Override any keybind in config via the {highlight}keybinds{/highlight} section",
|
||||||
"Run {highlight}/unshare{/highlight} to remove a session from public access.",
|
"Set any keybind to {highlight}none{/highlight} to disable it completely",
|
||||||
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops.",
|
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
|
||||||
"Permission {highlight}external_directory{/highlight} protects files outside project.",
|
"OpenCode auto-handles OAuth for remote MCP servers requiring auth",
|
||||||
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration.",
|
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts",
|
||||||
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr.",
|
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input",
|
||||||
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages.",
|
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})",
|
||||||
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages.",
|
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas",
|
||||||
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.",
|
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools",
|
||||||
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.",
|
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions',
|
||||||
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).",
|
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
|
||||||
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use.",
|
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing',
|
||||||
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.",
|
"OpenCode auto-formats files using prettier, gofmt, ruff, and more",
|
||||||
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.",
|
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting',
|
||||||
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.",
|
"Define custom formatter commands with file extensions in config",
|
||||||
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog.",
|
"OpenCode uses LSP servers for intelligent code analysis",
|
||||||
"Use {highlight}/details{/highlight} to toggle tool execution details visibility.",
|
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools",
|
||||||
"Use {highlight}/rename{/highlight} to rename the current session.",
|
"Tool definitions can invoke scripts written in Python, Go, etc",
|
||||||
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell.",
|
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks",
|
||||||
|
"Use plugins to send OS notifications when sessions complete",
|
||||||
|
"Create a plugin to prevent OpenCode from reading sensitive files",
|
||||||
|
"Use {highlight}opencode run{/highlight} for non-interactive scripting",
|
||||||
|
"Use {highlight}opencode run --continue{/highlight} to resume the last session",
|
||||||
|
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI",
|
||||||
|
"Use {highlight}--format json{/highlight} for machine-readable output in scripts",
|
||||||
|
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode",
|
||||||
|
"Use {highlight}opencode run --attach{/highlight} to connect to a running server",
|
||||||
|
"Run {highlight}opencode upgrade{/highlight} to update to the latest version",
|
||||||
|
"Run {highlight}opencode auth list{/highlight} to see all configured providers",
|
||||||
|
"Run {highlight}opencode agent create{/highlight} for guided agent creation",
|
||||||
|
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions",
|
||||||
|
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow",
|
||||||
|
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs",
|
||||||
|
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews",
|
||||||
|
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors',
|
||||||
|
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory",
|
||||||
|
"Themes support dark/light variants for both modes",
|
||||||
|
"Reference ANSI colors 0-255 in custom themes",
|
||||||
|
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config",
|
||||||
|
"Use {highlight}{file:path}{/highlight} to include file contents in config values",
|
||||||
|
"Use {highlight}instructions{/highlight} in config to load additional rules files",
|
||||||
|
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
|
||||||
|
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request",
|
||||||
|
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools',
|
||||||
|
'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
|
||||||
|
"Override global tool settings per agent configuration",
|
||||||
|
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions',
|
||||||
|
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing',
|
||||||
|
"Run {highlight}/unshare{/highlight} to remove a session from public access",
|
||||||
|
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops",
|
||||||
|
"Permission {highlight}external_directory{/highlight} protects files outside project",
|
||||||
|
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration",
|
||||||
|
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr",
|
||||||
|
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
|
||||||
|
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
|
||||||
|
"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",
|
||||||
|
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
|
||||||
|
"Use {highlight}/details{/highlight} to toggle tool execution details visibility",
|
||||||
|
"Use {highlight}/rename{/highlight} to rename the current session",
|
||||||
|
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell",
|
||||||
]
|
]
|
||||||
@@ -8,13 +8,13 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
|||||||
name: "KV",
|
name: "KV",
|
||||||
init: () => {
|
init: () => {
|
||||||
const [ready, setReady] = createSignal(false)
|
const [ready, setReady] = createSignal(false)
|
||||||
const [kvStore, setKvStore] = createStore<Record<string, any>>()
|
const [store, setStore] = createStore<Record<string, any>>()
|
||||||
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
|
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
|
||||||
|
|
||||||
file
|
file
|
||||||
.json()
|
.json()
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
setKvStore(x)
|
setStore(x)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -25,8 +25,11 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
|||||||
get ready() {
|
get ready() {
|
||||||
return ready()
|
return ready()
|
||||||
},
|
},
|
||||||
|
get store() {
|
||||||
|
return store
|
||||||
|
},
|
||||||
signal<T>(name: string, defaultValue: T) {
|
signal<T>(name: string, defaultValue: T) {
|
||||||
if (kvStore[name] === undefined) setKvStore(name, defaultValue)
|
if (store[name] === undefined) setStore(name, defaultValue)
|
||||||
return [
|
return [
|
||||||
function () {
|
function () {
|
||||||
return result.get(name)
|
return result.get(name)
|
||||||
@@ -37,11 +40,11 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
|||||||
] as const
|
] as const
|
||||||
},
|
},
|
||||||
get(key: string, defaultValue?: any) {
|
get(key: string, defaultValue?: any) {
|
||||||
return kvStore[key] ?? defaultValue
|
return store[key] ?? defaultValue
|
||||||
},
|
},
|
||||||
set(key: string, value: any) {
|
set(key: string, value: any) {
|
||||||
setKvStore(key, value)
|
setStore(key, value)
|
||||||
Bun.write(file, JSON.stringify(kvStore, null, 2))
|
Bun.write(file, JSON.stringify(store, null, 2))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -3,21 +3,66 @@ import { createSimpleContext } from "./helper"
|
|||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
|
|
||||||
|
export type EventSource = {
|
||||||
|
on: (handler: (event: Event) => void) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
init: (props: { url: string; directory?: string }) => {
|
init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => {
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: props.url,
|
baseUrl: props.url,
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
directory: props.directory,
|
directory: props.directory,
|
||||||
|
fetch: props.fetch,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
let queue: Event[] = []
|
||||||
|
let timer: Timer | undefined
|
||||||
|
let last = 0
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (queue.length === 0) return
|
||||||
|
const events = queue
|
||||||
|
queue = []
|
||||||
|
timer = undefined
|
||||||
|
last = Date.now()
|
||||||
|
// Batch all event emissions so all store updates result in a single render
|
||||||
|
batch(() => {
|
||||||
|
for (const event of events) {
|
||||||
|
emitter.emit(event.type, event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEvent = (event: Event) => {
|
||||||
|
queue.push(event)
|
||||||
|
const elapsed = Date.now() - last
|
||||||
|
|
||||||
|
if (timer) return
|
||||||
|
// If we just flushed recently (within 16ms), batch this with future events
|
||||||
|
// Otherwise, process immediately to avoid latency
|
||||||
|
if (elapsed < 16) {
|
||||||
|
timer = setTimeout(flush, 16)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
// If an event source is provided, use it instead of SSE
|
||||||
|
if (props.events) {
|
||||||
|
const unsub = props.events.on(handleEvent)
|
||||||
|
onCleanup(unsub)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to SSE
|
||||||
while (true) {
|
while (true) {
|
||||||
if (abort.signal.aborted) break
|
if (abort.signal.aborted) break
|
||||||
const events = await sdk.event.subscribe(
|
const events = await sdk.event.subscribe(
|
||||||
@@ -26,36 +71,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
let queue: Event[] = []
|
|
||||||
let timer: Timer | undefined
|
|
||||||
let last = 0
|
|
||||||
|
|
||||||
const flush = () => {
|
|
||||||
if (queue.length === 0) return
|
|
||||||
const events = queue
|
|
||||||
queue = []
|
|
||||||
timer = undefined
|
|
||||||
last = Date.now()
|
|
||||||
// Batch all event emissions so all store updates result in a single render
|
|
||||||
batch(() => {
|
|
||||||
for (const event of events) {
|
|
||||||
emitter.emit(event.type, event)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
queue.push(event)
|
handleEvent(event)
|
||||||
const elapsed = Date.now() - last
|
|
||||||
|
|
||||||
if (timer) continue
|
|
||||||
// If we just flushed recently (within 16ms), batch this with future events
|
|
||||||
// Otherwise, process immediately to avoid latency
|
|
||||||
if (elapsed < 16) {
|
|
||||||
timer = setTimeout(flush, 16)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush any remaining events
|
// Flush any remaining events
|
||||||
@@ -68,6 +86,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
abort.abort()
|
abort.abort()
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
})
|
})
|
||||||
|
|
||||||
return { client: sdk, event: emitter, url: props.url }
|
return { client: sdk, event: emitter, url: props.url }
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||||
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
|
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
|
import { useKeybind } from "@tui/context/keybind"
|
||||||
import { Logo } from "../component/logo"
|
import { Logo } from "../component/logo"
|
||||||
import { DidYouKnow, randomizeTip } from "../component/did-you-know"
|
import { Tips } from "../component/tips"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { useSync } from "../context/sync"
|
import { useSync } from "../context/sync"
|
||||||
import { Toast } from "../ui/toast"
|
import { Toast } from "../ui/toast"
|
||||||
@@ -36,7 +37,6 @@ export function Home() {
|
|||||||
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
|
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
|
||||||
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
|
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
|
||||||
const showTips = createMemo(() => {
|
const showTips = createMemo(() => {
|
||||||
return false
|
|
||||||
// Don't show tips for first-time users
|
// Don't show tips for first-time users
|
||||||
if (isFirstTimeUser()) return false
|
if (isFirstTimeUser()) return false
|
||||||
return !tipsHidden()
|
return !tipsHidden()
|
||||||
@@ -77,7 +77,6 @@ export function Home() {
|
|||||||
let prompt: PromptRef
|
let prompt: PromptRef
|
||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
randomizeTip()
|
|
||||||
if (once) return
|
if (once) return
|
||||||
if (route.initialPrompt) {
|
if (route.initialPrompt) {
|
||||||
prompt.set(route.initialPrompt)
|
prompt.set(route.initialPrompt)
|
||||||
@@ -90,6 +89,8 @@ export function Home() {
|
|||||||
})
|
})
|
||||||
const directory = useDirectory()
|
const directory = useDirectory()
|
||||||
|
|
||||||
|
const keybind = useKeybind()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
|
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
@@ -103,13 +104,13 @@ export function Home() {
|
|||||||
hint={Hint}
|
hint={Hint}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
<Show when={showTips()}>
|
||||||
|
<box width="100%" maxWidth={75} paddingTop={2} alignItems="center">
|
||||||
|
<Tips />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
<Toast />
|
<Toast />
|
||||||
</box>
|
</box>
|
||||||
<Show when={!isFirstTimeUser()}>
|
|
||||||
<Show when={showTips()}>
|
|
||||||
<DidYouKnow />
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
|
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
|
||||||
<text fg={theme.textMuted}>{directory()}</text>
|
<text fg={theme.textMuted}>{directory()}</text>
|
||||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { type Accessor, createMemo, Match, Show, Switch } from "solid-js"
|
import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js"
|
||||||
import { useRouteData } from "@tui/context/route"
|
import { useRouteData } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { pipe, sumBy } from "remeda"
|
import { pipe, sumBy } from "remeda"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder } from "@tui/component/border"
|
||||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
||||||
|
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||||
import { useKeybind } from "../../context/keybind"
|
import { useKeybind } from "../../context/keybind"
|
||||||
|
|
||||||
const Title = (props: { session: Accessor<Session> }) => {
|
const Title = (props: { session: Accessor<Session> }) => {
|
||||||
@@ -59,6 +60,8 @@ export function Header() {
|
|||||||
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
|
const command = useCommandDialog()
|
||||||
|
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
@@ -79,15 +82,36 @@ export function Header() {
|
|||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<b>Subagent session</b>
|
<b>Subagent session</b>
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.text}>
|
<box
|
||||||
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
|
onMouseOver={() => setHover("parent")}
|
||||||
</text>
|
onMouseOut={() => setHover(null)}
|
||||||
<text fg={theme.text}>
|
onMouseUp={() => command.trigger("session.parent")}
|
||||||
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
|
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
|
||||||
</text>
|
>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
|
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
|
||||||
</text>
|
</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
onMouseOver={() => setHover("prev")}
|
||||||
|
onMouseOut={() => setHover(null)}
|
||||||
|
onMouseUp={() => command.trigger("session.child.previous")}
|
||||||
|
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
|
||||||
|
>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
onMouseOver={() => setHover("next")}
|
||||||
|
onMouseOut={() => setHover(null)}
|
||||||
|
onMouseUp={() => command.trigger("session.child.next")}
|
||||||
|
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
|
||||||
|
>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
<box flexGrow={1} flexShrink={1} />
|
<box flexGrow={1} flexShrink={1} />
|
||||||
<ContextInfo context={context} cost={cost} />
|
<ContextInfo context={context} cost={cost} />
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user