Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef0b5e3dcb | ||
|
|
b7262b8527 | ||
|
|
1f44c7f750 | ||
|
|
7dba570195 | ||
|
|
3d04ba26a3 | ||
|
|
3660e2c481 | ||
|
|
06ca45189b | ||
|
|
674febcf60 | ||
|
|
582d9a9622 | ||
|
|
d525fbf829 | ||
|
|
69a499f807 | ||
|
|
37e564139f | ||
|
|
ee8b81269b | ||
|
|
53998a2fed | ||
|
|
af7b9e77d1 | ||
|
|
77c65b18b5 | ||
|
|
c9dfe6d964 | ||
|
|
03f7f18260 | ||
|
|
2db76fc6dd | ||
|
|
7269c2316d | ||
|
|
1e0596bc46 | ||
|
|
3ebec2435a | ||
|
|
b90c0b5fac | ||
|
|
3b1ab444fd | ||
|
|
234db24f1f | ||
|
|
04546c0873 | ||
|
|
f51bd91af4 | ||
|
|
ebca25462e | ||
|
|
01b9148c04 | ||
|
|
d3e080894c | ||
|
|
ee9aa24a55 | ||
|
|
16e2bded5b | ||
|
|
9fb49ab87b | ||
|
|
8d6a03cc89 | ||
|
|
71b04ffa99 | ||
|
|
678ca757c9 | ||
|
|
272349b8da | ||
|
|
fe94bb8e50 | ||
|
|
ba8bc1b8b4 | ||
|
|
8a9a474df6 | ||
|
|
52e2b40610 | ||
|
|
ee1ff8cc07 | ||
|
|
434c0ff0d7 | ||
|
|
7a7060ef15 | ||
|
|
f9af9fc221 | ||
|
|
1bf1b93404 | ||
|
|
bc6f4aed2b | ||
|
|
2af3f19397 | ||
|
|
9275665868 | ||
|
|
09bb819064 | ||
|
|
6f0028644e | ||
|
|
aec44abcf6 | ||
|
|
b41e573886 | ||
|
|
737ddab300 | ||
|
|
90fc3ddb02 | ||
|
|
15d7eebb92 | ||
|
|
33301c94df | ||
|
|
d341d26e37 | ||
|
|
d49b1b25d1 | ||
|
|
25eb100210 | ||
|
|
9886353715 | ||
|
|
f501501791 | ||
|
|
c103052f93 | ||
|
|
68039d4c71 | ||
|
|
d3566d3b1a | ||
|
|
b275e18d28 | ||
|
|
af9a1797b5 | ||
|
|
29b3e40ddb | ||
|
|
c49f5939a2 | ||
|
|
63862b1609 | ||
|
|
1cf1e88b52 | ||
|
|
d06afd87e5 | ||
|
|
9fb6e81007 | ||
|
|
3ac82227f1 | ||
|
|
c1f9249c84 | ||
|
|
9bb66946db | ||
|
|
adcdbbddc7 | ||
|
|
662435c5bb | ||
|
|
36c1a05eaa | ||
|
|
5708e3bf1e | ||
|
|
0da1ed3fc8 | ||
|
|
d5179c8b63 | ||
|
|
bd0a4f7bbe | ||
|
|
3d43214075 | ||
|
|
178a14ce3e | ||
|
|
8e1010dc3f | ||
|
|
9c82f1f5e9 | ||
|
|
e5a651eef7 | ||
|
|
d26605aa56 | ||
|
|
5cc0d337b1 | ||
|
|
902763b47d | ||
|
|
55d07a139c | ||
|
|
05232ead93 | ||
|
|
7652a96064 | ||
|
|
901aae09f7 | ||
|
|
f95799f17c | ||
|
|
99a6c5e44d | ||
|
|
07bb75f086 | ||
|
|
66eb846e6f | ||
|
|
34f11c699e | ||
|
|
7a32fec008 | ||
|
|
37a6b5177e | ||
|
|
573ffe186b | ||
|
|
0f7ff3fcb1 | ||
|
|
2c3aa330b9 | ||
|
|
47b2fb79dc | ||
|
|
6deaf54bb3 | ||
|
|
d549cd3213 | ||
|
|
93e52f7ecf | ||
|
|
88f12b0822 | ||
|
|
54af7f9e18 | ||
|
|
be685e95a3 | ||
|
|
dc2ab75fca | ||
|
|
f1324e886f | ||
|
|
c47fde2ca4 | ||
|
|
f42e1c6375 | ||
|
|
f68374ad22 | ||
|
|
5e86c9b791 | ||
|
|
94658c31c5 | ||
|
|
9fd672a1cb | ||
|
|
10523c4372 | ||
|
|
d1cd7d0344 | ||
|
|
06ac1be226 | ||
|
|
05489bc843 | ||
|
|
3f02eecf22 | ||
|
|
f5ca78ed7b | ||
|
|
894cbaa51e | ||
|
|
8b70b89fde | ||
|
|
f9dbc586dc | ||
|
|
ffeef63ca1 | ||
|
|
4da58294d9 | ||
|
|
fa2e88f49b | ||
|
|
28e765ef0a | ||
|
|
bfbcb5f200 | ||
|
|
89492b3002 | ||
|
|
2663415d47 | ||
|
|
51be67cc14 | ||
|
|
92a1943771 | ||
|
|
1e15fc273a | ||
|
|
104a895a71 | ||
|
|
f98e730405 | ||
|
|
b12bef05d3 | ||
|
|
2f1d001cc5 | ||
|
|
65d0b3ed6d | ||
|
|
22a34d7958 | ||
|
|
cb4401ec92 | ||
|
|
febf467b03 | ||
|
|
d55a2fd56c | ||
|
|
40f577e5e7 | ||
|
|
9e49870118 | ||
|
|
fe38e3ab02 | ||
|
|
0170577743 | ||
|
|
7de6ea5922 | ||
|
|
2fe7d13e69 | ||
|
|
1bc3c98ae7 | ||
|
|
55787f2caa | ||
|
|
7df61a74a0 | ||
|
|
4f23110880 | ||
|
|
041353f4ff | ||
|
|
c72f8b17c6 | ||
|
|
eb304f4115 | ||
|
|
5565f14ef5 | ||
|
|
10a4455c6f | ||
|
|
5ded6d6ad7 | ||
|
|
849a38c30c | ||
|
|
68050ab802 | ||
|
|
91d01fd4cc | ||
|
|
9beb0f8512 | ||
|
|
d4cb47eadc | ||
|
|
261ff416a9 | ||
|
|
d0a70cb217 | ||
|
|
20fc56d020 | ||
|
|
a57ae3ec93 | ||
|
|
30f9fa12d9 | ||
|
|
d473d4ffc8 | ||
|
|
af50596529 | ||
|
|
3823d8d50e | ||
|
|
7a926b32ce | ||
|
|
a5ede68241 | ||
|
|
60dc38050d | ||
|
|
31d0caee38 | ||
|
|
2a7ab45605 | ||
|
|
019054dd1e | ||
|
|
a018a15f32 | ||
|
|
e630d680dd | ||
|
|
9e392f25a6 | ||
|
|
2cc4e6ad7c | ||
|
|
70d8d1ab1e | ||
|
|
342aa27e03 | ||
|
|
e1aed0cd01 |
@@ -5,6 +5,8 @@ runs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version-file: package.json
|
||||||
|
|
||||||
- name: Cache ~/.bun
|
- name: Cache ~/.bun
|
||||||
id: cache-bun
|
id: cache-bun
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: Add opentui label
|
- name: Auto-label and assign issues
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -20,21 +20,36 @@ jobs:
|
|||||||
const title = issue.title;
|
const title = issue.title;
|
||||||
const description = issue.body || '';
|
const description = issue.body || '';
|
||||||
|
|
||||||
// Check for version patterns like v1.0.x or 1.0.x
|
// Check for "opencode web" or desktop keywords
|
||||||
const versionPattern = /\b[v]?1\.0\.[x\d]\b/i;
|
const webPattern = /(opencode web|\bdesktop\b)/i;
|
||||||
|
const isWebRelated = webPattern.test(title) || webPattern.test(description);
|
||||||
|
|
||||||
if (versionPattern.test(title) || versionPattern.test(description)) {
|
// Check for version patterns like v1.0.x or 1.0.x
|
||||||
|
const versionPattern = /[v]?1\.0\./i;
|
||||||
|
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
|
||||||
|
|
||||||
|
if (isWebRelated) {
|
||||||
|
// Add web label
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
labels: ['web']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign to adamdotdevin
|
||||||
|
await github.rest.issues.addAssignees({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
assignees: ['adamdotdevin']
|
||||||
|
});
|
||||||
|
} else if (isVersionRelated) {
|
||||||
|
// Only add opentui if NOT web-related
|
||||||
await github.rest.issues.addLabels({
|
await github.rest.issues.addLabels({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issue_number: issue.number,
|
issue_number: issue.number,
|
||||||
labels: ['opentui']
|
labels: ['opentui']
|
||||||
});
|
});
|
||||||
|
|
||||||
await github.rest.issues.addAssignees({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: issue.number,
|
|
||||||
assignees: ['thdxr']
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,4 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
with:
|
with:
|
||||||
model: opencode/kimi-k2
|
model: opencode/glm-4.6
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
*~
|
||||||
openapi.json
|
openapi.json
|
||||||
playground
|
playground
|
||||||
tmp
|
tmp
|
||||||
dist
|
dist
|
||||||
.turbo
|
.turbo
|
||||||
|
**/.serena
|
||||||
|
.serena/
|
||||||
|
|||||||
@@ -19,5 +19,5 @@ For anything in the packages/app use the ignore: prefix.
|
|||||||
prefer to explain WHY something was done from an end user perspective instead of
|
prefer to explain WHY something was done from an end user perspective instead of
|
||||||
WHAT was done.
|
WHAT was done.
|
||||||
|
|
||||||
do not do generic messages like "improvied agent experience" be very specific
|
do not do generic messages like "improved agent experience" be very specific
|
||||||
about what user facing changes were made
|
about what user facing changes were made
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
## Tool Calling
|
## Tool Calling
|
||||||
|
|
||||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environnement:
|
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environment:
|
||||||
|
|
||||||
json
|
json
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-3
@@ -26,7 +26,7 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
|
|||||||
|
|
||||||
## Developing OpenCode
|
## Developing OpenCode
|
||||||
|
|
||||||
- Requirements: Bun 1.3+, Go 1.24.x.
|
- Requirements: Bun 1.3+
|
||||||
- Install dependencies and start the dev server from the repo root:
|
- Install dependencies and start the dev server from the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -36,11 +36,11 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
|
|||||||
|
|
||||||
- Core pieces:
|
- Core pieces:
|
||||||
- `packages/opencode`: OpenCode core business logic & server.
|
- `packages/opencode`: OpenCode core business logic & server.
|
||||||
- `packages/tui`: The TUI code, written in Go (will be removed soon in favor of [opentui](https://github.com/sst/opentui))
|
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
|
||||||
- `packages/plugin`: Source for `@opencode-ai/plugin`
|
- `packages/plugin`: Source for `@opencode-ai/plugin`
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> After touching `packages/opencode/src/server/server.ts`, the OpenCode team must regenerate the Stainless SDK before any client updates merge.
|
> After touching `packages/opencode/src/server/server.ts`, run "./packages/sdk/js/script/build.ts" to regenerate the JS sdk.
|
||||||
|
|
||||||
## Pull Request Expectations
|
## Pull Request Expectations
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ curl -fsSL https://opencode.ai/install | bash
|
|||||||
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
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 sst/tap/opencode # macOS and Linux
|
brew install opencode # macOS and Linux
|
||||||
paru -S opencode-bin # Arch Linux
|
paru -S opencode-bin # Arch Linux
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -126,3 +126,8 @@
|
|||||||
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
|
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
|
||||||
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
|
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
|
||||||
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
|
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
|
||||||
|
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
|
||||||
|
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
|
||||||
|
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
|
||||||
|
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
|
||||||
|
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"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",
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"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",
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"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",
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -121,6 +121,7 @@
|
|||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "0.15.3",
|
"@solidjs/router": "0.15.3",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
@@ -150,7 +151,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
@@ -166,14 +167,14 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.4.9",
|
"@agentclientprotocol/sdk": "0.5.1",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
@@ -184,8 +185,8 @@
|
|||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opentui/core": "0.0.0-20251031-fc297165",
|
"@opentui/core": "0.1.33",
|
||||||
"@opentui/solid": "0.0.0-20251031-fc297165",
|
"@opentui/solid": "0.1.33",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/precision-diffs": "catalog:",
|
"@pierre/precision-diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -199,7 +200,7 @@
|
|||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
"hono": "catalog:",
|
"hono": "catalog:",
|
||||||
"hono-openapi": "1.0.7",
|
"hono-openapi": "1.1.1",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"minimatch": "10.0.3",
|
"minimatch": "10.0.3",
|
||||||
@@ -243,7 +244,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -263,7 +264,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.81.0",
|
"@hey-api/openapi-ts": "0.81.0",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -274,7 +275,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -287,7 +288,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -317,7 +318,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -364,7 +365,7 @@
|
|||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/precision-diffs": "0.4.1",
|
"@pierre/precision-diffs": "0.4.2",
|
||||||
"@solidjs/meta": "0.29.4",
|
"@solidjs/meta": "0.29.4",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
@@ -401,7 +402,7 @@
|
|||||||
|
|
||||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||||
|
|
||||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.4.9", "", { "dependencies": { "zod": "^3.0.0" } }, "sha512-ExwH828LaTGoTTjxuw49l+fwOLA+Yx0+qkWn1TcHMOsY5mVI9CUfkj7ZDhv2klgZ7mJeT+lxX/Dn/KINv1AkNQ=="],
|
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.5.1", "", { "dependencies": { "zod": "^3.0.0" } }, "sha512-9bq2TgjhLBSUSC5jE04MEe+Hqw8YePzKghhYZ9QcjOyonY3q2oJfX6GoSO83hURpEnsqEPIrex6VZN3+61fBJg=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@2.2.10", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icLGO7Q0NinnHIPgT+y1QjHVwH4HwV+brWbvM+FfCG2Afpa89PyKa3Ret91kGjZpBgM/xnj1B7K5eM+rRlsXQA=="],
|
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@2.2.10", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icLGO7Q0NinnHIPgT+y1QjHVwH4HwV+brWbvM+FfCG2Afpa89PyKa3Ret91kGjZpBgM/xnj1B7K5eM+rRlsXQA=="],
|
||||||
|
|
||||||
@@ -961,21 +962,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.0.0-20251031-fc297165", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.0.0-20251031-fc297165", "@opentui/core-darwin-x64": "0.0.0-20251031-fc297165", "@opentui/core-linux-arm64": "0.0.0-20251031-fc297165", "@opentui/core-linux-x64": "0.0.0-20251031-fc297165", "@opentui/core-win32-arm64": "0.0.0-20251031-fc297165", "@opentui/core-win32-x64": "0.0.0-20251031-fc297165", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-xtUF/uJF04d1wl4f7vsRNsDN8P9uK9Mcx1SAcm79wAN90VPNB4j2G0s7qlt8SD4zB0iWPjXICqJidjRzrQ3QVg=="],
|
"@opentui/core": ["@opentui/core@0.1.33", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.33", "@opentui/core-darwin-x64": "0.1.33", "@opentui/core-linux-arm64": "0.1.33", "@opentui/core-linux-x64": "0.1.33", "@opentui/core-win32-arm64": "0.1.33", "@opentui/core-win32-x64": "0.1.33", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vwHdrPIqnsY6YnG2JTNhenHSsx+HUPYrQTBZdmEfCj9ROGVzKgUKbSDH1xGK2OtSNRb2KVBg4XaMpq0bie6afQ=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20251031-fc297165", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SD5AiofTfOT+JBx7tcBcd6BdD9sc+RPkHbhIJeqkw5V/GJ4OjyUW3m2kyR9iTs1nLMbKD5o9gyVXpLig4KmFiQ=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.33", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JBvzcP2V7fT9KxFAMenHRd/t72qPP5IL5kzge2uok1T7t2nw3Wa+CWI5s6FYP42p2b1W9qZkv5Fno5gA7OAYuQ=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20251031-fc297165", "", { "os": "darwin", "cpu": "x64" }, "sha512-uhzxSvmfeK7vv8uNdhl8Mn2yMnjOVqdjZTOIV2aI8H9SCp8cmnzuLA8FXFO+BW6kgxsg6LbVdp4d4jDCgwtKLQ=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.33", "", { "os": "darwin", "cpu": "x64" }, "sha512-x7DY6VCkAky10z/2o4UkkuNW/nIvoX7uAh3dJOHWZCLbiKywSFvFk3QZVVcH5BMk4tOOophYTzika4s4HpaeMg=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20251031-fc297165", "", { "os": "linux", "cpu": "arm64" }, "sha512-qGjjk/QTrAyqwzPC+6NhqiQZ31k3GxufbtccF8Yqan0GLuA6GrKcU72IcPwVA5t/6VIXaLkJZyFfub7CoO1D/g=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBc1EdkVxsLBtqGjXM2BYpBJLa57ogcrSADSZbc5cQkPu0muSGzUwBbVnVZJUjWEfk6n5jcd4dDmLezVoQga0A=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20251031-fc297165", "", { "os": "linux", "cpu": "x64" }, "sha512-gre61Sxc9yX8lrqGNXz5fyE7xJHfkgDi8smGPE2OVP8HmXh0Rn1tXMzFywweEs9MELP3kdQ0VhimYJWkp8FyWg=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.33", "", { "os": "linux", "cpu": "x64" }, "sha512-3oVL5mrLlKLUc1lc4v7xS3BJ9N7PnnimbGwAvlnVpfaAygotAs1XkPcjsUe6ItMnSJyi0FWiDHUE2+GiDtM5Nw=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20251031-fc297165", "", { "os": "win32", "cpu": "arm64" }, "sha512-44jsq/Ea+jIjZDXyt0w23/DkvwniQFPRB1tocGp6VrOHyHKa0IPHAQ+iuM0felbnmdMUFYyTyh1iOfAcuZyaaA=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.33", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q68v7wssE+r0OG1KIGfi7m3fnu8KOK4ZNg9ML6EwE47VF9/bqgUe+6fPiXh5mmHzTwof7nAOdXCf052av5/upQ=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20251031-fc297165", "", { "os": "win32", "cpu": "x64" }, "sha512-L20tCPrLFMCuX4lC2JTcixiCGFNM5RTHQwKLRcxcsSdKBr6a/7ztOG2a/2RNWkrrlbwTrUREVXH4Ivk3EOuStw=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.33", "", { "os": "win32", "cpu": "x64" }, "sha512-PvuchmUnbMCUXXMzfle/WTzhNGIdJ6RGCCoclx3YVUyNUVuUicPf42OEV+td2m81/Hr3CgcLn98HYX1TLIzPrw=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.0.0-20251031-fc297165", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20251031-fc297165", "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-9u7ULKztDG1SvvU/wNCTFL7JYNPkG+pevcEU3JA7M2uUTIWrvKf/rD13lxtfVe7/yfxcY69SMRlaJGWpfxud5w=="],
|
"@opentui/solid": ["@opentui/solid@0.1.33", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.33", "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-bWSALdGJ2j51zwZ2gK1ZIBxFgauHq+V1ejEnyd4XamYMdWfpAKU+AUWDVLbpx1T9XG1oAnycJZfYX7BsZdVOOg=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
@@ -1033,7 +1034,7 @@
|
|||||||
|
|
||||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
|
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
|
||||||
|
|
||||||
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.4.1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-AoozHakINGyNJFgbYc/1PlDK0yunrAxbtXEMBe9fdu8RLkNjVtYRTLw7EF2mM/YuVoVRjj2HT/2VJ4a2rMyDOA=="],
|
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.4.2", "", { "dependencies": { "@shikijs/core": "3.14.0", "@shikijs/transformers": "3.14.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.14.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-C6LbruH24BCp4awI47D5iMtVaZZD6GkzqBoDw+Sfu7DB3hC9y/rZr1C2BD7AUzAKwByTfFnh16Zp11ipfPqLKw=="],
|
||||||
|
|
||||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
@@ -1309,6 +1310,8 @@
|
|||||||
|
|
||||||
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw=="],
|
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw=="],
|
||||||
|
|
||||||
|
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
|
||||||
|
|
||||||
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-IWoptVc0SWYgmpBPpCMehS5b07+tpFcvw15tOQ3QbXedSYn6KP8zCjPkHNzMxcOvOicTneleeZDP7lqmz+PQ6g=="],
|
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-IWoptVc0SWYgmpBPpCMehS5b07+tpFcvw15tOQ3QbXedSYn6KP8zCjPkHNzMxcOvOicTneleeZDP7lqmz+PQ6g=="],
|
||||||
|
|
||||||
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
|
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
|
||||||
@@ -2221,7 +2224,7 @@
|
|||||||
|
|
||||||
"hono": ["hono@4.7.10", "", {}, "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="],
|
"hono": ["hono@4.7.10", "", {}, "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="],
|
||||||
|
|
||||||
"hono-openapi": ["hono-openapi@1.0.7", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.1", "@standard-community/standard-openapi": "^0.2.4", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-rMn+nn4/HMisyi549L3zT7WCmVvmpiKsyt790GcGfqvJf9mJfhq6txw09l0IhSBxpJpA0pXVKxFijcsnGfshUA=="],
|
"hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="],
|
||||||
|
|
||||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||||
|
|
||||||
@@ -3725,11 +3728,11 @@
|
|||||||
|
|
||||||
"@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
|
"@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
|
||||||
|
|
||||||
"@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
|
"@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-qRSeuP5vlYHCNUIrpEBQFO7vSkR7jn7Kv+5X3FO/zBKVDGQbcnlScD3XhkrHi/R8Ltz0kEjvFR9Szp/XMRbFMw=="],
|
||||||
|
|
||||||
"@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="],
|
"@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.14.0", "", { "dependencies": { "@shikijs/core": "3.14.0", "@shikijs/types": "3.14.0" } }, "sha512-i67zQnY9wLMMnKasonVW1L9fKneSLZDj1ePsA4o0AZWU4uUobmJY9baRDa36z+a9/g0aG76/2tybQvm4hrwxIQ=="],
|
||||||
|
|
||||||
"@pierre/precision-diffs/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
|
"@pierre/precision-diffs/shiki": ["shiki@3.14.0", "", { "dependencies": { "@shikijs/core": "3.14.0", "@shikijs/engine-javascript": "3.14.0", "@shikijs/engine-oniguruma": "3.14.0", "@shikijs/langs": "3.14.0", "@shikijs/themes": "3.14.0", "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-J0yvpLI7LSig3Z3acIuDLouV5UCKQqu8qOArwMx+/yPVC3WRMgrP67beaG8F+j4xfEWE0eVC4GeBCIXeOPra1g=="],
|
||||||
|
|
||||||
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||||
|
|
||||||
@@ -4327,19 +4330,19 @@
|
|||||||
|
|
||||||
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
"@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.14.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ=="],
|
||||||
|
|
||||||
"@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
"@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.14.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ=="],
|
||||||
|
|
||||||
"@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
|
"@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-3v1kAXI2TsWQuwv86cREH/+FK9Pjw3dorVEykzQDhwrZj0lwsHYlfyARaKmn6vr5Gasf8aeVpb8JkzeWspxOLQ=="],
|
||||||
|
|
||||||
"@pierre/precision-diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
|
"@pierre/precision-diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-TNcYTYMbJyy+ZjzWtt0bG5y4YyMIWC2nyePz+CFMWqm+HnZZyy9SWMgo8Z6KBJVIZnx8XUXS8U2afO6Y0g1Oug=="],
|
||||||
|
|
||||||
"@pierre/precision-diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="],
|
"@pierre/precision-diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0" } }, "sha512-DIB2EQY7yPX1/ZH7lMcwrK5pl+ZkP/xoSpUzg9YC8R+evRCCiSQ7yyrvEyBsMnfZq4eBzLzBlugMyTAf13+pzg=="],
|
||||||
|
|
||||||
"@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="],
|
"@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0" } }, "sha512-fAo/OnfWckNmv4uBoUu6dSlkcBc+SA1xzj5oUSaz5z3KqHtEbUypg/9xxgJARtM6+7RVm0Q6Xnty41xA1ma1IA=="],
|
||||||
|
|
||||||
"@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
"@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/types@3.14.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ=="],
|
||||||
|
|
||||||
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||||
|
|
||||||
|
|||||||
+48
-15
@@ -152,6 +152,9 @@ try {
|
|||||||
return session.id.slice(-8)
|
return session.id.slice(-8)
|
||||||
})()
|
})()
|
||||||
console.log("opencode session", session.id)
|
console.log("opencode session", session.id)
|
||||||
|
if (shareId) {
|
||||||
|
console.log("Share link:", `${useShareUrl()}/s/${shareId}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle 3 cases
|
// Handle 3 cases
|
||||||
// 1. Issue
|
// 1. Issue
|
||||||
@@ -168,7 +171,9 @@ try {
|
|||||||
const summary = await summarize(response)
|
const summary = await summarize(response)
|
||||||
await pushToLocalBranch(summary)
|
await pushToLocalBranch(summary)
|
||||||
}
|
}
|
||||||
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
|
const hasShared = prData.comments.nodes.some((c) =>
|
||||||
|
c.body.includes(`${useShareUrl()}/s/${shareId}`),
|
||||||
|
)
|
||||||
await updateComment(`${response}${footer({ image: !hasShared })}`)
|
await updateComment(`${response}${footer({ image: !hasShared })}`)
|
||||||
}
|
}
|
||||||
// Fork PR
|
// Fork PR
|
||||||
@@ -180,7 +185,9 @@ try {
|
|||||||
const summary = await summarize(response)
|
const summary = await summarize(response)
|
||||||
await pushToForkBranch(summary, prData)
|
await pushToForkBranch(summary, prData)
|
||||||
}
|
}
|
||||||
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
|
const hasShared = prData.comments.nodes.some((c) =>
|
||||||
|
c.body.includes(`${useShareUrl()}/s/${shareId}`),
|
||||||
|
)
|
||||||
await updateComment(`${response}${footer({ image: !hasShared })}`)
|
await updateComment(`${response}${footer({ image: !hasShared })}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,7 +368,9 @@ async function getAccessToken() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const responseJson = (await response.json()) as { error?: string }
|
const responseJson = (await response.json()) as { error?: string }
|
||||||
throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`)
|
throw new Error(
|
||||||
|
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseJson = (await response.json()) as { token: string }
|
const responseJson = (await response.json()) as { token: string }
|
||||||
@@ -402,8 +411,12 @@ async function getUserPrompt() {
|
|||||||
// ie. <img alt="Image" src="https://github.com/user-attachments/assets/xxxx" />
|
// ie. <img alt="Image" src="https://github.com/user-attachments/assets/xxxx" />
|
||||||
// ie. [api.json](https://github.com/user-attachments/files/21433810/api.json)
|
// ie. [api.json](https://github.com/user-attachments/files/21433810/api.json)
|
||||||
// ie. 
|
// ie. 
|
||||||
const mdMatches = prompt.matchAll(/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi)
|
const mdMatches = prompt.matchAll(
|
||||||
const tagMatches = prompt.matchAll(/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi)
|
/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi,
|
||||||
|
)
|
||||||
|
const tagMatches = prompt.matchAll(
|
||||||
|
/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi,
|
||||||
|
)
|
||||||
const matches = [...mdMatches, ...tagMatches].sort((a, b) => a.index - b.index)
|
const matches = [...mdMatches, ...tagMatches].sort((a, b) => a.index - b.index)
|
||||||
console.log("Images", JSON.stringify(matches, null, 2))
|
console.log("Images", JSON.stringify(matches, null, 2))
|
||||||
|
|
||||||
@@ -430,7 +443,8 @@ async function getUserPrompt() {
|
|||||||
|
|
||||||
// Replace img tag with file path, ie. @image.png
|
// Replace img tag with file path, ie. @image.png
|
||||||
const replacement = `@${filename}`
|
const replacement = `@${filename}`
|
||||||
prompt = prompt.slice(0, start + offset) + replacement + prompt.slice(start + offset + tag.length)
|
prompt =
|
||||||
|
prompt.slice(0, start + offset) + replacement + prompt.slice(start + offset + tag.length)
|
||||||
offset += replacement.length - tag.length
|
offset += replacement.length - tag.length
|
||||||
|
|
||||||
const contentType = res.headers.get("content-type")
|
const contentType = res.headers.get("content-type")
|
||||||
@@ -498,7 +512,12 @@ async function subscribeSessionEvents() {
|
|||||||
? JSON.stringify(part.state.input)
|
? JSON.stringify(part.state.input)
|
||||||
: "Unknown"
|
: "Unknown"
|
||||||
console.log()
|
console.log()
|
||||||
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
|
console.log(
|
||||||
|
color + `|`,
|
||||||
|
"\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`,
|
||||||
|
"",
|
||||||
|
"\x1b[0m" + title,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
@@ -710,7 +729,8 @@ async function assertPermissions() {
|
|||||||
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
|
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
|
if (!["admin", "write"].includes(permission))
|
||||||
|
throw new Error(`User ${actor} does not have write permissions`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateComment(body: string) {
|
async function updateComment(body: string) {
|
||||||
@@ -730,12 +750,13 @@ async function updateComment(body: string) {
|
|||||||
async function createPR(base: string, branch: string, title: string, body: string) {
|
async function createPR(base: string, branch: string, title: string, body: string) {
|
||||||
console.log("Creating pull request...")
|
console.log("Creating pull request...")
|
||||||
const { repo } = useContext()
|
const { repo } = useContext()
|
||||||
|
const truncatedTitle = title.length > 256 ? title.slice(0, 253) + "..." : title
|
||||||
const pr = await octoRest.rest.pulls.create({
|
const pr = await octoRest.rest.pulls.create({
|
||||||
owner: repo.owner,
|
owner: repo.owner,
|
||||||
repo: repo.repo,
|
repo: repo.repo,
|
||||||
head: branch,
|
head: branch,
|
||||||
base,
|
base,
|
||||||
title,
|
title: truncatedTitle,
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
return pr.data.number
|
return pr.data.number
|
||||||
@@ -753,7 +774,9 @@ function footer(opts?: { image?: boolean }) {
|
|||||||
|
|
||||||
return `<a href="${useShareUrl()}/s/${shareId}"><img width="200" alt="${titleAlt}" src="https://social-cards.sst.dev/opencode-share/${title64}.png?model=${providerID}/${modelID}&version=${session.version}&id=${shareId}" /></a>\n`
|
return `<a href="${useShareUrl()}/s/${shareId}"><img width="200" alt="${titleAlt}" src="https://social-cards.sst.dev/opencode-share/${title64}.png?model=${providerID}/${modelID}&version=${session.version}&id=${shareId}" /></a>\n`
|
||||||
})()
|
})()
|
||||||
const shareUrl = shareId ? `[opencode session](${useShareUrl()}/s/${shareId}) | ` : ""
|
const shareUrl = shareId
|
||||||
|
? `[opencode session](${useShareUrl()}/s/${shareId}) | `
|
||||||
|
: ""
|
||||||
return `\n\n${image}${shareUrl}[github run](${useEnvRunUrl()})`
|
return `\n\n${image}${shareUrl}[github run](${useEnvRunUrl()})`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,9 +959,13 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
|
|||||||
})
|
})
|
||||||
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
|
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
|
||||||
|
|
||||||
const files = (pr.files.nodes || []).map((f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`)
|
const files = (pr.files.nodes || []).map(
|
||||||
|
(f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`,
|
||||||
|
)
|
||||||
const reviewData = (pr.reviews.nodes || []).map((r) => {
|
const reviewData = (pr.reviews.nodes || []).map((r) => {
|
||||||
const comments = (r.comments.nodes || []).map((c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`)
|
const comments = (r.comments.nodes || []).map(
|
||||||
|
(c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`,
|
||||||
|
)
|
||||||
return [
|
return [
|
||||||
`- ${r.author.login} at ${r.submittedAt}:`,
|
`- ${r.author.login} at ${r.submittedAt}:`,
|
||||||
` - Review body: ${r.body}`,
|
` - Review body: ${r.body}`,
|
||||||
@@ -960,9 +987,15 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
|
|||||||
`Deletions: ${pr.deletions}`,
|
`Deletions: ${pr.deletions}`,
|
||||||
`Total Commits: ${pr.commits.totalCount}`,
|
`Total Commits: ${pr.commits.totalCount}`,
|
||||||
`Changed Files: ${pr.files.nodes.length} files`,
|
`Changed Files: ${pr.files.nodes.length} files`,
|
||||||
...(comments.length > 0 ? ["<pull_request_comments>", ...comments, "</pull_request_comments>"] : []),
|
...(comments.length > 0
|
||||||
...(files.length > 0 ? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"] : []),
|
? ["<pull_request_comments>", ...comments, "</pull_request_comments>"]
|
||||||
...(reviewData.length > 0 ? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"] : []),
|
: []),
|
||||||
|
...(files.length > 0
|
||||||
|
? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"]
|
||||||
|
: []),
|
||||||
|
...(reviewData.length > 0
|
||||||
|
? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"]
|
||||||
|
: []),
|
||||||
"</pull_request>",
|
"</pull_request>",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -61,7 +61,13 @@ export const auth = new sst.cloudflare.Worker("AuthApi", {
|
|||||||
domain: `auth.${domain}`,
|
domain: `auth.${domain}`,
|
||||||
handler: "packages/console/function/src/auth.ts",
|
handler: "packages/console/function/src/auth.ts",
|
||||||
url: true,
|
url: true,
|
||||||
link: [database, authStorage, GITHUB_CLIENT_ID_CONSOLE, GITHUB_CLIENT_SECRET_CONSOLE, GOOGLE_CLIENT_ID],
|
link: [
|
||||||
|
database,
|
||||||
|
authStorage,
|
||||||
|
GITHUB_CLIENT_ID_CONSOLE,
|
||||||
|
GITHUB_CLIENT_SECRET_CONSOLE,
|
||||||
|
GOOGLE_CLIENT_ID,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
////////////////
|
////////////////
|
||||||
@@ -97,7 +103,8 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const ZEN_MODELS = new sst.Secret("ZEN_MODELS")
|
const ZEN_MODELS1 = new sst.Secret("ZEN_MODELS1")
|
||||||
|
const ZEN_MODELS2 = new sst.Secret("ZEN_MODELS2")
|
||||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||||
properties: { value: auth.url.apply((url) => url!) },
|
properties: { value: auth.url.apply((url) => url!) },
|
||||||
@@ -130,7 +137,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
AUTH_API_URL,
|
AUTH_API_URL,
|
||||||
STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET,
|
||||||
STRIPE_SECRET_KEY,
|
STRIPE_SECRET_KEY,
|
||||||
ZEN_MODELS,
|
ZEN_MODELS1,
|
||||||
|
ZEN_MODELS2,
|
||||||
EMAILOCTOPUS_API_KEY,
|
EMAILOCTOPUS_API_KEY,
|
||||||
AWS_SES_ACCESS_KEY_ID,
|
AWS_SES_ACCESS_KEY_ID,
|
||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.0",
|
"packageManager": "bun@1.3.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/precision-diffs": "0.4.1",
|
"@pierre/precision-diffs": "0.4.2",
|
||||||
"@solidjs/meta": "0.29.4",
|
"@solidjs/meta": "0.29.4",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||||
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||||
"start": "vinxi start",
|
"start": "vinxi start",
|
||||||
"version": "1.0.2"
|
"version": "1.0.27"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export default function App() {
|
|||||||
root={(props) => (
|
root={(props) => (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<Title>opencode</Title>
|
<Title>opencode</Title>
|
||||||
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
|
<Meta
|
||||||
|
name="description"
|
||||||
|
content="OpenCode - The AI coding agent built for the terminal."
|
||||||
|
/>
|
||||||
<Suspense>{props.children}</Suspense>
|
<Suspense>{props.children}</Suspense>
|
||||||
</MetaProvider>
|
</MetaProvider>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createAsync } from "@solidjs/router"
|
import { createAsync } from "@solidjs/router"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { github } from "~/lib/github"
|
import { github } from "~/lib/github"
|
||||||
|
import { config } from "~/config"
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const githubData = createAsync(() => github())
|
const githubData = createAsync(() => github())
|
||||||
@@ -10,13 +11,13 @@ export function Footer() {
|
|||||||
notation: "compact",
|
notation: "compact",
|
||||||
compactDisplay: "short",
|
compactDisplay: "short",
|
||||||
}).format(githubData()!.stars!)
|
}).format(githubData()!.stars!)
|
||||||
: "25K",
|
: config.github.starsFormatted.compact,
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer data-component="footer">
|
<footer data-component="footer">
|
||||||
<div data-slot="cell">
|
<div data-slot="cell">
|
||||||
<a href="https://github.com/sst/opencode" target="_blank">
|
<a href={config.github.repoUrl} target="_blank">
|
||||||
GitHub <span>[{starCount()}]</span>
|
GitHub <span>[{starCount()}]</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,7 +28,7 @@ export function Footer() {
|
|||||||
<a href="/discord">Discord</a>
|
<a href="/discord">Discord</a>
|
||||||
</div>
|
</div>
|
||||||
<div data-slot="cell">
|
<div data-slot="cell">
|
||||||
<a href="https://x.com/opencode">X</a>
|
<a href={config.social.twitter}>X</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { createMemo, Match, Show, Switch } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { github } from "~/lib/github"
|
import { github } from "~/lib/github"
|
||||||
import { createEffect, onCleanup } from "solid-js"
|
import { createEffect, onCleanup } from "solid-js"
|
||||||
|
import { config } from "~/config"
|
||||||
import "./header-context-menu.css"
|
import "./header-context-menu.css"
|
||||||
|
|
||||||
const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches
|
const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
@@ -42,7 +43,7 @@ export function Header(props: { zen?: boolean }) {
|
|||||||
notation: "compact",
|
notation: "compact",
|
||||||
compactDisplay: "short",
|
compactDisplay: "short",
|
||||||
}).format(githubData()?.stars!)
|
}).format(githubData()?.stars!)
|
||||||
: "29K",
|
: config.github.starsFormatted.compact,
|
||||||
)
|
)
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
@@ -148,7 +149,7 @@ export function Header(props: { zen?: boolean }) {
|
|||||||
<nav data-component="nav-desktop">
|
<nav data-component="nav-desktop">
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://github.com/sst/opencode" target="_blank">
|
<a href={config.github.repoUrl} target="_blank">
|
||||||
GitHub <span>[{starCount()}]</span>
|
GitHub <span>[{starCount()}]</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -222,7 +223,7 @@ export function Header(props: { zen?: boolean }) {
|
|||||||
<A href="/">Home</A>
|
<A href="/">Home</A>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://github.com/sst/opencode" target="_blank">
|
<a href={config.github.repoUrl} target="_blank">
|
||||||
GitHub <span>[{starCount()}]</span>
|
GitHub <span>[{starCount()}]</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Application-wide constants and configuration
|
||||||
|
*/
|
||||||
|
export const config = {
|
||||||
|
// GitHub
|
||||||
|
github: {
|
||||||
|
repoUrl: "https://github.com/sst/opencode",
|
||||||
|
starsFormatted: {
|
||||||
|
compact: "30K",
|
||||||
|
full: "30,000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Social links
|
||||||
|
social: {
|
||||||
|
twitter: "https://x.com/opencode",
|
||||||
|
discord: "https://discord.gg/opencode",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Static stats (used on landing page)
|
||||||
|
stats: {
|
||||||
|
contributors: "250",
|
||||||
|
commits: "3,500",
|
||||||
|
monthlyUsers: "300,000",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { query } from "@solidjs/router"
|
import { query } from "@solidjs/router"
|
||||||
|
import { config } from "~/config"
|
||||||
|
|
||||||
export const github = query(async () => {
|
export const github = query(async () => {
|
||||||
"use server"
|
"use server"
|
||||||
@@ -6,11 +7,15 @@ export const github = query(async () => {
|
|||||||
"User-Agent":
|
"User-Agent":
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
||||||
}
|
}
|
||||||
|
const apiBaseUrl = config.github.repoUrl.replace(
|
||||||
|
"https://github.com/",
|
||||||
|
"https://api.github.com/repos/",
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
const [meta, releases, contributors] = await Promise.all([
|
const [meta, releases, contributors] = await Promise.all([
|
||||||
fetch("https://api.github.com/repos/sst/opencode", { headers }).then((res) => res.json()),
|
fetch(apiBaseUrl, { headers }).then((res) => res.json()),
|
||||||
fetch("https://api.github.com/repos/sst/opencode/releases", { headers }).then((res) => res.json()),
|
fetch(`${apiBaseUrl}/releases`, { headers }).then((res) => res.json()),
|
||||||
fetch("https://api.github.com/repos/sst/opencode/contributors?per_page=1", { headers }),
|
fetch(`${apiBaseUrl}/contributors?per_page=1`, { headers }),
|
||||||
])
|
])
|
||||||
const [release] = releases
|
const [release] = releases
|
||||||
const contributorCount = Number.parseInt(
|
const contributorCount = Number.parseInt(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,146 +13,157 @@ export async function POST(input: APIEvent) {
|
|||||||
input.request.headers.get("stripe-signature")!,
|
input.request.headers.get("stripe-signature")!,
|
||||||
Resource.STRIPE_WEBHOOK_SECRET.value,
|
Resource.STRIPE_WEBHOOK_SECRET.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(body.type, JSON.stringify(body, null, 2))
|
console.log(body.type, JSON.stringify(body, null, 2))
|
||||||
if (body.type === "customer.updated") {
|
|
||||||
// check default payment method changed
|
|
||||||
const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
|
|
||||||
if (!("default_payment_method" in prevInvoiceSettings)) return
|
|
||||||
|
|
||||||
const customerID = body.data.object.id
|
return (async () => {
|
||||||
const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string
|
if (body.type === "customer.updated") {
|
||||||
|
// check default payment method changed
|
||||||
|
const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
|
||||||
|
if (!("default_payment_method" in prevInvoiceSettings)) return "ignored"
|
||||||
|
|
||||||
if (!customerID) throw new Error("Customer ID not found")
|
const customerID = body.data.object.id
|
||||||
if (!paymentMethodID) throw new Error("Payment method ID not found")
|
const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string
|
||||||
|
|
||||||
const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
|
if (!customerID) throw new Error("Customer ID not found")
|
||||||
await Database.use(async (tx) => {
|
if (!paymentMethodID) throw new Error("Payment method ID not found")
|
||||||
await tx
|
|
||||||
.update(BillingTable)
|
|
||||||
.set({
|
|
||||||
paymentMethodID,
|
|
||||||
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
|
||||||
paymentMethodType: paymentMethod.type,
|
|
||||||
})
|
|
||||||
.where(eq(BillingTable.customerID, customerID))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (body.type === "checkout.session.completed") {
|
|
||||||
const workspaceID = body.data.object.metadata?.workspaceID
|
|
||||||
const customerID = body.data.object.customer as string
|
|
||||||
const paymentID = body.data.object.payment_intent as string
|
|
||||||
const invoiceID = body.data.object.invoice as string
|
|
||||||
const amount = body.data.object.amount_total
|
|
||||||
|
|
||||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
|
||||||
if (!customerID) throw new Error("Customer ID not found")
|
await Database.use(async (tx) => {
|
||||||
if (!amount) throw new Error("Amount not found")
|
|
||||||
if (!paymentID) throw new Error("Payment ID not found")
|
|
||||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
|
||||||
|
|
||||||
await Actor.provide("system", { workspaceID }, async () => {
|
|
||||||
const customer = await Billing.get()
|
|
||||||
if (customer?.customerID && customer.customerID !== customerID)
|
|
||||||
throw new Error("Customer ID mismatch")
|
|
||||||
|
|
||||||
// set customer metadata
|
|
||||||
if (!customer?.customerID) {
|
|
||||||
await Billing.stripe().customers.update(customerID, {
|
|
||||||
metadata: {
|
|
||||||
workspaceID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// get payment method for the payment intent
|
|
||||||
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
|
||||||
expand: ["payment_method"],
|
|
||||||
})
|
|
||||||
const paymentMethod = paymentIntent.payment_method
|
|
||||||
if (!paymentMethod || typeof paymentMethod === "string")
|
|
||||||
throw new Error("Payment method not expanded")
|
|
||||||
|
|
||||||
const oldBillingInfo = await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.select({
|
|
||||||
customerID: BillingTable.customerID,
|
|
||||||
})
|
|
||||||
.from(BillingTable)
|
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
|
||||||
.then((rows) => rows[0]),
|
|
||||||
)
|
|
||||||
|
|
||||||
await Database.transaction(async (tx) => {
|
|
||||||
await tx
|
await tx
|
||||||
.update(BillingTable)
|
.update(BillingTable)
|
||||||
.set({
|
.set({
|
||||||
balance: sql`${BillingTable.balance} + ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
|
paymentMethodID,
|
||||||
customerID,
|
|
||||||
paymentMethodID: paymentMethod.id,
|
|
||||||
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||||
paymentMethodType: paymentMethod.type,
|
paymentMethodType: paymentMethod.type,
|
||||||
// enable reload if first time enabling billing
|
|
||||||
...(oldBillingInfo?.customerID
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
reload: true,
|
|
||||||
reloadError: null,
|
|
||||||
timeReloadError: null,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
await tx.insert(PaymentTable).values({
|
})
|
||||||
workspaceID,
|
}
|
||||||
id: Identifier.create("payment"),
|
if (body.type === "checkout.session.completed") {
|
||||||
amount: centsToMicroCents(Billing.CHARGE_AMOUNT),
|
const workspaceID = body.data.object.metadata?.workspaceID
|
||||||
paymentID,
|
const amountInCents =
|
||||||
invoiceID,
|
body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
||||||
customerID,
|
const customerID = body.data.object.customer as string
|
||||||
|
const paymentID = body.data.object.payment_intent as string
|
||||||
|
const invoiceID = body.data.object.invoice as string
|
||||||
|
|
||||||
|
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||||
|
if (!customerID) throw new Error("Customer ID not found")
|
||||||
|
if (!amountInCents) throw new Error("Amount not found")
|
||||||
|
if (!paymentID) throw new Error("Payment ID not found")
|
||||||
|
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||||
|
|
||||||
|
await Actor.provide("system", { workspaceID }, async () => {
|
||||||
|
const customer = await Billing.get()
|
||||||
|
if (customer?.customerID && customer.customerID !== customerID)
|
||||||
|
throw new Error("Customer ID mismatch")
|
||||||
|
|
||||||
|
// set customer metadata
|
||||||
|
if (!customer?.customerID) {
|
||||||
|
await Billing.stripe().customers.update(customerID, {
|
||||||
|
metadata: {
|
||||||
|
workspaceID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// get payment method for the payment intent
|
||||||
|
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
||||||
|
expand: ["payment_method"],
|
||||||
|
})
|
||||||
|
const paymentMethod = paymentIntent.payment_method
|
||||||
|
if (!paymentMethod || typeof paymentMethod === "string")
|
||||||
|
throw new Error("Payment method not expanded")
|
||||||
|
|
||||||
|
await Database.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
|
||||||
|
customerID,
|
||||||
|
paymentMethodID: paymentMethod.id,
|
||||||
|
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||||
|
paymentMethodType: paymentMethod.type,
|
||||||
|
// enable reload if first time enabling billing
|
||||||
|
...(customer?.customerID
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
reload: true,
|
||||||
|
reloadError: null,
|
||||||
|
timeReloadError: null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
await tx.insert(PaymentTable).values({
|
||||||
|
workspaceID,
|
||||||
|
id: Identifier.create("payment"),
|
||||||
|
amount: centsToMicroCents(amountInCents),
|
||||||
|
paymentID,
|
||||||
|
invoiceID,
|
||||||
|
customerID,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.then((message) => {
|
||||||
|
return Response.json({ message: message ?? "done" }, { status: 200 })
|
||||||
})
|
})
|
||||||
}
|
.catch((error: any) => {
|
||||||
if (body.type === "charge.refunded") {
|
return Response.json({ message: error.message }, { status: 500 })
|
||||||
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")
|
|
||||||
|
|
||||||
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} - ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
|
|
||||||
})
|
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
console.log("finished handling")
|
|
||||||
|
|
||||||
return Response.json("ok", { status: 200 })
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,17 +79,19 @@ export default function Home() {
|
|||||||
<strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM
|
<strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a> provided by opencode{" "}
|
<strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a>{" "}
|
||||||
<label>New</label>
|
provided by opencode <label>New</label>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Multi-session</strong> Start multiple agents in parallel on the same project
|
<strong>Multi-session</strong> Start multiple agents in parallel on the same project
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Shareable links</strong> Share a link to any sessions for reference or to debug
|
<strong>Shareable links</strong> Share a link to any sessions for reference or to
|
||||||
|
debug
|
||||||
</li>
|
</li>
|
||||||
<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>
|
<li>
|
||||||
<strong>Use any model</strong> Supports 75+ LLM providers through{" "}
|
<strong>Use any model</strong> Supports 75+ LLM providers through{" "}
|
||||||
@@ -121,7 +123,7 @@ export default function Home() {
|
|||||||
<h3 data-component="title">homebrew</h3>
|
<h3 data-component="title">homebrew</h3>
|
||||||
<button data-copy data-slot="button">
|
<button data-copy data-slot="button">
|
||||||
<span>
|
<span>
|
||||||
brew install <strong>sst/tap/opencode</strong>
|
brew install <strong>opencode</strong>
|
||||||
</span>
|
</span>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -71,6 +71,57 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="add-balance-form-container"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="add-balance-form"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[data-component="input"] {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="form-actions"] {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="form-error"] {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="credit-card"] {
|
[data-slot="credit-card"] {
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
background-color: var(--color-bg-surface);
|
background-color: var(--color-bg-surface);
|
||||||
|
|||||||
@@ -1,24 +1,80 @@
|
|||||||
import { action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
|
import { action, useParams, useAction, createAsync, useSubmission, json } from "@solidjs/router"
|
||||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { IconCreditCard, IconStripe } from "~/component/icon"
|
import { IconCreditCard, IconStripe } from "~/component/icon"
|
||||||
import styles from "./billing-section.module.css"
|
import styles from "./billing-section.module.css"
|
||||||
import { createCheckoutUrl, queryBillingInfo } from "../../common"
|
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
|
||||||
|
|
||||||
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
|
return json(
|
||||||
|
await withActor(
|
||||||
|
() =>
|
||||||
|
Billing.generateSessionUrl({ returnUrl })
|
||||||
|
.then((data) => ({ error: undefined, data }))
|
||||||
|
.catch((e) => ({
|
||||||
|
error: e.message as string,
|
||||||
|
data: undefined,
|
||||||
|
})),
|
||||||
|
workspaceID,
|
||||||
|
),
|
||||||
|
{ revalidate: queryBillingInfo.key },
|
||||||
|
)
|
||||||
}, "sessionUrl")
|
}, "sessionUrl")
|
||||||
|
|
||||||
export function BillingSection() {
|
export function BillingSection() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
|
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
|
||||||
const balanceInfo = createAsync(() => queryBillingInfo(params.id))
|
const billingInfo = createAsync(() => queryBillingInfo(params.id))
|
||||||
const createCheckoutUrlAction = useAction(createCheckoutUrl)
|
const checkoutAction = useAction(createCheckoutUrl)
|
||||||
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
|
const checkoutSubmission = useSubmission(createCheckoutUrl)
|
||||||
const createSessionUrlAction = useAction(createSessionUrl)
|
const sessionAction = useAction(createSessionUrl)
|
||||||
const createSessionUrlSubmission = useSubmission(createSessionUrl)
|
const sessionSubmission = useSubmission(createSessionUrl)
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
showAddBalanceForm: false,
|
||||||
|
addBalanceAmount: "",
|
||||||
|
checkoutRedirecting: false,
|
||||||
|
sessionRedirecting: false,
|
||||||
|
})
|
||||||
|
const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
|
||||||
|
|
||||||
|
async function onClickCheckout() {
|
||||||
|
const amount = parseInt(store.addBalanceAmount)
|
||||||
|
const baseUrl = window.location.href
|
||||||
|
|
||||||
|
const checkout = await checkoutAction(params.id, amount, baseUrl, baseUrl)
|
||||||
|
if (checkout && checkout.data) {
|
||||||
|
setStore("checkoutRedirecting", true)
|
||||||
|
window.location.href = checkout.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClickSession() {
|
||||||
|
const baseUrl = window.location.href
|
||||||
|
const sessionUrl = await sessionAction(params.id, baseUrl)
|
||||||
|
if (sessionUrl && sessionUrl.data) {
|
||||||
|
setStore("sessionRedirecting", true)
|
||||||
|
window.location.href = sessionUrl.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddBalanceForm() {
|
||||||
|
while (true) {
|
||||||
|
checkoutSubmission.clear()
|
||||||
|
if (!checkoutSubmission.result) break
|
||||||
|
}
|
||||||
|
setStore({
|
||||||
|
showAddBalanceForm: true,
|
||||||
|
addBalanceAmount: billingInfo()!.reloadAmount.toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAddBalanceForm() {
|
||||||
|
setStore("showAddBalanceForm", false)
|
||||||
|
checkoutSubmission.clear()
|
||||||
|
}
|
||||||
|
|
||||||
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
|
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
|
||||||
|
|
||||||
@@ -72,10 +128,6 @@ export function BillingSection() {
|
|||||||
// timeReloadError: null as Date | null
|
// timeReloadError: null as Date | null
|
||||||
// })
|
// })
|
||||||
|
|
||||||
const balanceAmount = createMemo(() => {
|
|
||||||
return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section class={styles.root}>
|
<section class={styles.root}>
|
||||||
<div data-slot="section-title">
|
<div data-slot="section-title">
|
||||||
@@ -88,81 +140,110 @@ export function BillingSection() {
|
|||||||
<div data-slot="section-content">
|
<div data-slot="section-content">
|
||||||
<div data-slot="balance-display">
|
<div data-slot="balance-display">
|
||||||
<div data-slot="balance-amount">
|
<div data-slot="balance-amount">
|
||||||
<span data-slot="balance-value">
|
<span data-slot="balance-value">${balance()}</span>
|
||||||
${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}
|
|
||||||
</span>
|
|
||||||
<span data-slot="balance-label">Current Balance</span>
|
<span data-slot="balance-label">Current Balance</span>
|
||||||
</div>
|
</div>
|
||||||
<Show when={balanceInfo()?.customerID}>
|
<Show when={billingInfo()?.customerID}>
|
||||||
<div data-slot="balance-right-section">
|
<div data-slot="balance-right-section">
|
||||||
<button
|
<Show
|
||||||
data-color="primary"
|
when={!store.showAddBalanceForm}
|
||||||
disabled={createCheckoutUrlSubmission.pending}
|
fallback={
|
||||||
onClick={async () => {
|
<div data-slot="add-balance-form-container">
|
||||||
const baseUrl = window.location.href
|
<div data-slot="add-balance-form">
|
||||||
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
|
<label>Add $</label>
|
||||||
if (checkoutUrl) {
|
<input
|
||||||
window.location.href = checkoutUrl
|
data-component="input"
|
||||||
}
|
type="number"
|
||||||
}}
|
min={billingInfo()?.reloadAmountMin.toString()}
|
||||||
|
step="1"
|
||||||
|
value={store.addBalanceAmount}
|
||||||
|
onInput={(e) => {
|
||||||
|
setStore("addBalanceAmount", e.currentTarget.value)
|
||||||
|
checkoutSubmission.clear()
|
||||||
|
}}
|
||||||
|
placeholder="Enter amount"
|
||||||
|
/>
|
||||||
|
<div data-slot="form-actions">
|
||||||
|
<button
|
||||||
|
data-color="ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={() => hideAddBalanceForm()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-color="primary"
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
!store.addBalanceAmount ||
|
||||||
|
checkoutSubmission.pending ||
|
||||||
|
store.checkoutRedirecting
|
||||||
|
}
|
||||||
|
onClick={onClickCheckout}
|
||||||
|
>
|
||||||
|
{checkoutSubmission.pending || store.checkoutRedirecting
|
||||||
|
? "Loading..."
|
||||||
|
: "Add"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={checkoutSubmission.result && (checkoutSubmission.result as any).error}
|
||||||
|
>
|
||||||
|
{(err: any) => <div data-slot="form-error">{err()}</div>}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{createCheckoutUrlSubmission.pending ? "Loading..." : "Add Balance"}
|
<button data-color="primary" onClick={() => showAddBalanceForm()}>
|
||||||
</button>
|
Add Balance
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<div data-slot="credit-card">
|
<div data-slot="credit-card">
|
||||||
<div data-slot="card-icon">
|
<div data-slot="card-icon">
|
||||||
<Switch fallback={<IconCreditCard style={{ width: "24px", height: "24px" }} />}>
|
<Switch fallback={<IconCreditCard style={{ width: "24px", height: "24px" }} />}>
|
||||||
<Match when={balanceInfo()?.paymentMethodType === "link"}>
|
<Match when={billingInfo()?.paymentMethodType === "link"}>
|
||||||
<IconStripe style={{ width: "24px", height: "24px" }} />
|
<IconStripe style={{ width: "24px", height: "24px" }} />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
<div data-slot="card-details">
|
<div data-slot="card-details">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={balanceInfo()?.paymentMethodType === "card"}>
|
<Match when={billingInfo()?.paymentMethodType === "card"}>
|
||||||
<Show
|
<Show
|
||||||
when={balanceInfo()?.paymentMethodLast4}
|
when={billingInfo()?.paymentMethodLast4}
|
||||||
fallback={<span data-slot="number">----</span>}
|
fallback={<span data-slot="number">----</span>}
|
||||||
>
|
>
|
||||||
<span data-slot="secret">••••</span>
|
<span data-slot="secret">••••</span>
|
||||||
<span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
|
<span data-slot="number">{billingInfo()?.paymentMethodLast4}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={balanceInfo()?.paymentMethodType === "link"}>
|
<Match when={billingInfo()?.paymentMethodType === "link"}>
|
||||||
<span data-slot="type">Linked to Stripe</span>
|
<span data-slot="type">Linked to Stripe</span>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
data-color="ghost"
|
data-color="ghost"
|
||||||
disabled={createSessionUrlSubmission.pending}
|
disabled={sessionSubmission.pending || store.sessionRedirecting}
|
||||||
onClick={async () => {
|
onClick={onClickSession}
|
||||||
const baseUrl = window.location.href
|
|
||||||
const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
|
|
||||||
if (sessionUrl) {
|
|
||||||
window.location.href = sessionUrl
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{createSessionUrlSubmission.pending ? "Loading..." : "Manage"}
|
{sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={!balanceInfo()?.customerID}>
|
<Show when={!billingInfo()?.customerID}>
|
||||||
<button
|
<button
|
||||||
data-slot="enable-billing-button"
|
data-slot="enable-billing-button"
|
||||||
data-color="primary"
|
data-color="primary"
|
||||||
disabled={createCheckoutUrlSubmission.pending}
|
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
|
||||||
onClick={async () => {
|
onClick={onClickCheckout}
|
||||||
const baseUrl = window.location.href
|
|
||||||
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
|
|
||||||
if (checkoutUrl) {
|
|
||||||
window.location.href = checkoutUrl
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
|
{checkoutSubmission.pending || store.checkoutRedirecting
|
||||||
|
? "Loading..."
|
||||||
|
: "Enable Billing"}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||||
import { createEffect, Show } from "solid-js"
|
import { createEffect, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import styles from "./monthly-limit-section.module.css"
|
import styles from "./monthly-limit-section.module.css"
|
||||||
|
import { queryBillingInfo } from "../../common"
|
||||||
const getBillingInfo = query(async (workspaceID: string) => {
|
|
||||||
"use server"
|
|
||||||
return withActor(async () => {
|
|
||||||
return await Billing.get()
|
|
||||||
}, workspaceID)
|
|
||||||
}, "billing.get")
|
|
||||||
|
|
||||||
const setMonthlyLimit = action(async (form: FormData) => {
|
const setMonthlyLimit = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
@@ -28,7 +22,7 @@ const setMonthlyLimit = action(async (form: FormData) => {
|
|||||||
.catch((e) => ({ error: e.message as string })),
|
.catch((e) => ({ error: e.message as string })),
|
||||||
workspaceID,
|
workspaceID,
|
||||||
),
|
),
|
||||||
{ revalidate: getBillingInfo.key },
|
{ revalidate: queryBillingInfo.key },
|
||||||
)
|
)
|
||||||
}, "billing.setMonthlyLimit")
|
}, "billing.setMonthlyLimit")
|
||||||
|
|
||||||
@@ -36,7 +30,7 @@ export function MonthlyLimitSection() {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const submission = useSubmission(setMonthlyLimit)
|
const submission = useSubmission(setMonthlyLimit)
|
||||||
const [store, setStore] = createStore({ show: false })
|
const [store, setStore] = createStore({ show: false })
|
||||||
const balanceInfo = createAsync(() => getBillingInfo(params.id))
|
const billingInfo = createAsync(() => queryBillingInfo(params.id))
|
||||||
|
|
||||||
let input: HTMLInputElement
|
let input: HTMLInputElement
|
||||||
|
|
||||||
@@ -68,13 +62,13 @@ export function MonthlyLimitSection() {
|
|||||||
<section class={styles.root}>
|
<section class={styles.root}>
|
||||||
<div data-slot="section-title">
|
<div data-slot="section-title">
|
||||||
<h2>Monthly Limit</h2>
|
<h2>Monthly Limit</h2>
|
||||||
<p>Set a monthly spending limit for your account.</p>
|
<p>Set a monthly usage limit for your account.</p>
|
||||||
</div>
|
</div>
|
||||||
<div data-slot="section-content">
|
<div data-slot="section-content">
|
||||||
<div data-slot="balance">
|
<div data-slot="balance">
|
||||||
<div data-slot="amount">
|
<div data-slot="amount">
|
||||||
{balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
|
{billingInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
|
||||||
<span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
|
<span data-slot="value">{billingInfo()?.monthlyLimit ?? "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<Show
|
||||||
when={!store.show}
|
when={!store.show}
|
||||||
@@ -106,15 +100,19 @@ export function MonthlyLimitSection() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button data-color="primary" onClick={() => show()}>
|
<button data-color="primary" onClick={() => show()}>
|
||||||
{balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
|
{billingInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
|
<Show
|
||||||
|
when={billingInfo()?.monthlyLimit}
|
||||||
|
fallback={<p data-slot="usage-status">No usage limit set.</p>}
|
||||||
|
>
|
||||||
<p data-slot="usage-status">
|
<p data-slot="usage-status">
|
||||||
Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
|
Current usage for{" "}
|
||||||
|
{new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
|
||||||
{(() => {
|
{(() => {
|
||||||
const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
|
const dateLastUsed = billingInfo()?.timeMonthlyUsageUpdated
|
||||||
if (!dateLastUsed) return "0"
|
if (!dateLastUsed) return "0"
|
||||||
|
|
||||||
const current = new Date().toLocaleDateString("en-US", {
|
const current = new Date().toLocaleDateString("en-US", {
|
||||||
@@ -128,7 +126,7 @@ export function MonthlyLimitSection() {
|
|||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
})
|
})
|
||||||
if (current !== lastUsed) return "0"
|
if (current !== lastUsed) return "0"
|
||||||
return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
|
return ((billingInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
|
||||||
})()}
|
})()}
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -34,6 +34,206 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="create-form"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
|
||||||
|
[data-slot="form-field"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="field-label"] {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="toggle-container"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[data-component="input"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="input-row"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--space-3);
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="input-field"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[data-component="input"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: var(--color-bg-surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="field-with-connector"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
[data-slot="field-connector"] {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[data-component="input"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="form-actions"] {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="form-error"] {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: calc(var(--space-1) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="model-toggle-label"] {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: #ccc;
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0.125rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + span {
|
||||||
|
background-color: #21ad0e;
|
||||||
|
border-color: #148605;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: translateX(1rem) translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover span {
|
||||||
|
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked:hover + span {
|
||||||
|
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(input:disabled) {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled + span {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="reload-error"] {
|
[data-slot="reload-error"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -54,6 +254,8 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||||
import { Show } from "solid-js"
|
import { createEffect, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
import styles from "./reload-section.module.css"
|
import styles from "./reload-section.module.css"
|
||||||
|
import { queryBillingInfo } from "../../common"
|
||||||
|
|
||||||
const reload = action(async (form: FormData) => {
|
const reload = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID")?.toString()
|
||||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||||
return json(await withActor(() => Billing.reload(), workspaceID), {
|
return json(await withActor(() => Billing.reload(), workspaceID), {
|
||||||
revalidate: getBillingInfo.key,
|
revalidate: queryBillingInfo.key,
|
||||||
})
|
})
|
||||||
}, "billing.reload")
|
}, "billing.reload")
|
||||||
|
|
||||||
@@ -20,12 +22,27 @@ const setReload = action(async (form: FormData) => {
|
|||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID")?.toString()
|
||||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||||
const reloadValue = form.get("reload")?.toString() === "true"
|
const reloadValue = form.get("reload")?.toString() === "true"
|
||||||
|
const amountStr = form.get("reloadAmount")?.toString()
|
||||||
|
const triggerStr = form.get("reloadTrigger")?.toString()
|
||||||
|
|
||||||
|
const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
|
||||||
|
const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
|
||||||
|
|
||||||
|
if (reloadValue) {
|
||||||
|
if (reloadAmount === null || reloadAmount < Billing.RELOAD_AMOUNT_MIN)
|
||||||
|
return { error: `Reload amount must be at least $${Billing.RELOAD_AMOUNT_MIN}` }
|
||||||
|
if (reloadTrigger === null || reloadTrigger < Billing.RELOAD_TRIGGER_MIN)
|
||||||
|
return { error: `Balance trigger must be at least $${Billing.RELOAD_TRIGGER_MIN}` }
|
||||||
|
}
|
||||||
|
|
||||||
return json(
|
return json(
|
||||||
await Database.use((tx) =>
|
await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.update(BillingTable)
|
.update(BillingTable)
|
||||||
.set({
|
.set({
|
||||||
reload: reloadValue,
|
reload: reloadValue,
|
||||||
|
...(reloadAmount !== null ? { reloadAmount } : {}),
|
||||||
|
...(reloadTrigger !== null ? { reloadTrigger } : {}),
|
||||||
...(reloadValue
|
...(reloadValue
|
||||||
? {
|
? {
|
||||||
reloadError: null,
|
reloadError: null,
|
||||||
@@ -35,22 +52,47 @@ const setReload = action(async (form: FormData) => {
|
|||||||
})
|
})
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||||
),
|
),
|
||||||
{ revalidate: getBillingInfo.key },
|
{ revalidate: queryBillingInfo.key },
|
||||||
)
|
)
|
||||||
}, "billing.setReload")
|
}, "billing.setReload")
|
||||||
|
|
||||||
const getBillingInfo = query(async (workspaceID: string) => {
|
|
||||||
"use server"
|
|
||||||
return withActor(async () => {
|
|
||||||
return await Billing.get()
|
|
||||||
}, workspaceID)
|
|
||||||
}, "billing.get")
|
|
||||||
|
|
||||||
export function ReloadSection() {
|
export function ReloadSection() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const balanceInfo = createAsync(() => getBillingInfo(params.id))
|
const billingInfo = createAsync(() => queryBillingInfo(params.id))
|
||||||
const setReloadSubmission = useSubmission(setReload)
|
const setReloadSubmission = useSubmission(setReload)
|
||||||
const reloadSubmission = useSubmission(reload)
|
const reloadSubmission = useSubmission(reload)
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
show: false,
|
||||||
|
reload: false,
|
||||||
|
reloadAmount: "",
|
||||||
|
reloadTrigger: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (
|
||||||
|
!setReloadSubmission.pending &&
|
||||||
|
setReloadSubmission.result &&
|
||||||
|
!(setReloadSubmission.result as any).error
|
||||||
|
) {
|
||||||
|
setStore("show", false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
while (true) {
|
||||||
|
setReloadSubmission.clear()
|
||||||
|
if (!setReloadSubmission.result) break
|
||||||
|
}
|
||||||
|
const info = billingInfo()!
|
||||||
|
setStore("show", true)
|
||||||
|
setStore("reload", info.reload ? true : true)
|
||||||
|
setStore("reloadAmount", info.reloadAmount.toString())
|
||||||
|
setStore("reloadTrigger", info.reloadTrigger.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
setStore("show", false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section class={styles.root}>
|
<section class={styles.root}>
|
||||||
@@ -58,43 +100,101 @@ export function ReloadSection() {
|
|||||||
<h2>Auto Reload</h2>
|
<h2>Auto Reload</h2>
|
||||||
<div data-slot="title-row">
|
<div data-slot="title-row">
|
||||||
<Show
|
<Show
|
||||||
when={balanceInfo()?.reload}
|
when={billingInfo()?.reload}
|
||||||
fallback={
|
fallback={
|
||||||
<p>Auto reload is disabled. Enable to automatically reload when balance is low.</p>
|
<p>
|
||||||
|
Auto reload is <b>disabled</b>. Enable to automatically reload when balance is low.
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches{" "}
|
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b>{" "}
|
||||||
<b>$5</b>.
|
(+$1.23 processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
|
||||||
</p>
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
<form action={setReload} method="post" data-slot="create-form">
|
<button data-color="primary" type="button" onClick={() => show()}>
|
||||||
<input type="hidden" name="workspaceID" value={params.id} />
|
{billingInfo()?.reload ? "Edit" : "Enable"}
|
||||||
<input type="hidden" name="reload" value={balanceInfo()?.reload ? "false" : "true"} />
|
</button>
|
||||||
<button data-color="primary" type="submit" disabled={setReloadSubmission.pending}>
|
|
||||||
<Show
|
|
||||||
when={balanceInfo()?.reload}
|
|
||||||
fallback={setReloadSubmission.pending ? "Enabling..." : "Enable"}
|
|
||||||
>
|
|
||||||
{setReloadSubmission.pending ? "Disabling..." : "Disable"}
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-slot="section-content">
|
<Show when={store.show}>
|
||||||
<Show when={balanceInfo()?.reload && balanceInfo()?.reloadError}>
|
<form action={setReload} method="post" data-slot="create-form">
|
||||||
|
<div data-slot="form-field">
|
||||||
|
<label>
|
||||||
|
<span data-slot="field-label">Enable Auto Reload</span>
|
||||||
|
<div data-slot="toggle-container">
|
||||||
|
<label data-slot="model-toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="reload"
|
||||||
|
value="true"
|
||||||
|
checked={store.reload}
|
||||||
|
onChange={(e) => setStore("reload", e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-slot="input-row">
|
||||||
|
<div data-slot="input-field">
|
||||||
|
<p>Reload $</p>
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
name="reloadAmount"
|
||||||
|
type="number"
|
||||||
|
min={billingInfo()?.reloadAmountMin.toString()}
|
||||||
|
step="1"
|
||||||
|
value={store.reloadAmount}
|
||||||
|
onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
|
||||||
|
placeholder={billingInfo()?.reloadAmount.toString()}
|
||||||
|
disabled={!store.reload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div data-slot="input-field">
|
||||||
|
<p>When balance reaches $</p>
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
name="reloadTrigger"
|
||||||
|
type="number"
|
||||||
|
min={billingInfo()?.reloadTriggerMin.toString()}
|
||||||
|
step="1"
|
||||||
|
value={store.reloadTrigger}
|
||||||
|
onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
|
||||||
|
placeholder={billingInfo()?.reloadTrigger.toString()}
|
||||||
|
disabled={!store.reload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={setReloadSubmission.result && (setReloadSubmission.result as any).error}>
|
||||||
|
{(err: any) => <div data-slot="form-error">{err()}</div>}
|
||||||
|
</Show>
|
||||||
|
<input type="hidden" name="workspaceID" value={params.id} />
|
||||||
|
<div data-slot="form-actions">
|
||||||
|
<button type="button" data-color="ghost" onClick={() => hide()}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" data-color="primary" disabled={setReloadSubmission.pending}>
|
||||||
|
{setReloadSubmission.pending ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Show>
|
||||||
|
<Show when={billingInfo()?.reload && billingInfo()?.reloadError}>
|
||||||
|
<div data-slot="section-content">
|
||||||
<div data-slot="reload-error">
|
<div data-slot="reload-error">
|
||||||
<p>
|
<p>
|
||||||
Reload failed at{" "}
|
Reload failed at{" "}
|
||||||
{balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
|
{billingInfo()?.timeReloadError!.toLocaleString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
})}
|
})}
|
||||||
. Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment
|
. Reason: {billingInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment
|
||||||
method and try again.
|
method and try again.
|
||||||
</p>
|
</p>
|
||||||
<form action={reload} method="post" data-slot="create-form">
|
<form action={reload} method="post" data-slot="create-form">
|
||||||
@@ -104,8 +204,8 @@ export function ReloadSection() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
|
import { Show, createMemo } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
|
||||||
import { NewUserSection } from "./new-user-section"
|
import { NewUserSection } from "./new-user-section"
|
||||||
import { UsageSection } from "./usage-section"
|
import { UsageSection } from "./usage-section"
|
||||||
import { ModelSection } from "./model-section"
|
import { ModelSection } from "./model-section"
|
||||||
import { ProviderSection } from "./provider-section"
|
import { ProviderSection } from "./provider-section"
|
||||||
import { IconLogo } from "~/component/icon"
|
import { IconLogo } from "~/component/icon"
|
||||||
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
|
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
|
||||||
import { querySessionInfo, queryBillingInfo, createCheckoutUrl } from "../common"
|
|
||||||
import { Show, createMemo } from "solid-js"
|
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const userInfo = createAsync(() => querySessionInfo(params.id))
|
const userInfo = createAsync(() => querySessionInfo(params.id))
|
||||||
const billingInfo = createAsync(() => queryBillingInfo(params.id))
|
const billingInfo = createAsync(() => queryBillingInfo(params.id))
|
||||||
const createCheckoutUrlAction = useAction(createCheckoutUrl)
|
const checkoutAction = useAction(createCheckoutUrl)
|
||||||
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
|
const checkoutSubmission = useSubmission(createCheckoutUrl)
|
||||||
|
const [store, setStore] = createStore({
|
||||||
const balanceAmount = createMemo(() => {
|
checkoutRedirecting: false,
|
||||||
return ((billingInfo()?.balance ?? 0) / 100000000).toFixed(2)
|
|
||||||
})
|
})
|
||||||
|
const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
|
||||||
|
|
||||||
|
async function onClickCheckout() {
|
||||||
|
const baseUrl = window.location.href
|
||||||
|
const checkout = await checkoutAction(params.id, billingInfo()!.reloadAmount, baseUrl, baseUrl)
|
||||||
|
if (checkout && checkout.data) {
|
||||||
|
setStore("checkoutRedirecting", true)
|
||||||
|
window.location.href = checkout.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-page="workspace-[id]">
|
<div data-page="workspace-[id]">
|
||||||
@@ -38,21 +48,17 @@ export default function () {
|
|||||||
<button
|
<button
|
||||||
data-color="primary"
|
data-color="primary"
|
||||||
data-size="sm"
|
data-size="sm"
|
||||||
disabled={createCheckoutUrlSubmission.pending}
|
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
|
||||||
onClick={async () => {
|
onClick={onClickCheckout}
|
||||||
const baseUrl = window.location.href
|
|
||||||
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
|
|
||||||
if (checkoutUrl) {
|
|
||||||
window.location.href = checkoutUrl
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable billing"}
|
{checkoutSubmission.pending || store.checkoutRedirecting
|
||||||
|
? "Loading..."
|
||||||
|
: "Enable billing"}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span data-slot="balance">
|
<span data-slot="balance">
|
||||||
Current balance <b>${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
|
Current balance <b>${balance()}</b>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import { withActor } from "~/context/auth.withActor"
|
|||||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||||
import styles from "./model-section.module.css"
|
import styles from "./model-section.module.css"
|
||||||
import { querySessionInfo } from "../common"
|
import { querySessionInfo } from "../common"
|
||||||
import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon"
|
import {
|
||||||
|
IconAlibaba,
|
||||||
|
IconAnthropic,
|
||||||
|
IconMoonshotAI,
|
||||||
|
IconOpenAI,
|
||||||
|
IconStealth,
|
||||||
|
IconXai,
|
||||||
|
IconZai,
|
||||||
|
} from "~/component/icon"
|
||||||
|
|
||||||
const getModelLab = (modelId: string) => {
|
const getModelLab = (modelId: string) => {
|
||||||
if (modelId.startsWith("claude")) return "Anthropic"
|
if (modelId.startsWith("claude")) return "Anthropic"
|
||||||
@@ -22,7 +30,7 @@ const getModelsInfo = query(async (workspaceID: string) => {
|
|||||||
return withActor(async () => {
|
return withActor(async () => {
|
||||||
return {
|
return {
|
||||||
all: Object.entries(ZenData.list().models)
|
all: Object.entries(ZenData.list().models)
|
||||||
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
|
.filter(([id, _model]) => !["claude-3-5-haiku", "minimax-m2"].includes(id))
|
||||||
.filter(([id, _model]) => !id.startsWith("an-"))
|
.filter(([id, _model]) => !id.startsWith("an-"))
|
||||||
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name))
|
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name))
|
||||||
.map(([id, model]) => ({ id, name: model.name })),
|
.map(([id, model]) => ({ id, name: model.name })),
|
||||||
@@ -68,7 +76,8 @@ export function ModelSection() {
|
|||||||
<div data-slot="section-title">
|
<div data-slot="section-title">
|
||||||
<h2>Models</h2>
|
<h2>Models</h2>
|
||||||
<p>
|
<p>
|
||||||
Manage which models workspace members can access. <a href="/docs/zen#pricing ">Learn more</a>.
|
Manage which models workspace members can access.{" "}
|
||||||
|
<a href="/docs/zen#pricing ">Learn more</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div data-slot="models-list">
|
<div data-slot="models-list">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
import { action, query } from "@solidjs/router"
|
import { action, json, query } from "@solidjs/router"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { User } from "@opencode-ai/console-core/user.js"
|
import { User } from "@opencode-ai/console-core/user.js"
|
||||||
@@ -34,6 +34,11 @@ export function formatDateUTC(date: Date) {
|
|||||||
return date.toLocaleDateString("en-US", options)
|
return date.toLocaleDateString("en-US", options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatBalance(amount: number) {
|
||||||
|
const balance = ((amount ?? 0) / 100000000).toFixed(2)
|
||||||
|
return balance === "-0.00" ? "0.00" : balance
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLastSeenWorkspaceID() {
|
export async function getLastSeenWorkspaceID() {
|
||||||
"use server"
|
"use server"
|
||||||
return withActor(async () => {
|
return withActor(async () => {
|
||||||
@@ -71,14 +76,34 @@ export const querySessionInfo = query(async (workspaceID: string) => {
|
|||||||
}, "session.get")
|
}, "session.get")
|
||||||
|
|
||||||
export const createCheckoutUrl = action(
|
export const createCheckoutUrl = action(
|
||||||
async (workspaceID: string, successUrl: string, cancelUrl: string) => {
|
async (workspaceID: string, amount: number, successUrl: string, cancelUrl: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
|
return json(
|
||||||
|
await withActor(
|
||||||
|
() =>
|
||||||
|
Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl })
|
||||||
|
.then((data) => ({ error: undefined, data }))
|
||||||
|
.catch((e) => ({
|
||||||
|
error: e.message as string,
|
||||||
|
data: undefined,
|
||||||
|
})),
|
||||||
|
workspaceID,
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
"checkoutUrl",
|
"checkoutUrl",
|
||||||
)
|
)
|
||||||
|
|
||||||
export const queryBillingInfo = query(async (workspaceID: string) => {
|
export const queryBillingInfo = query(async (workspaceID: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return withActor(() => Billing.get(), workspaceID)
|
return withActor(async () => {
|
||||||
|
const billing = await Billing.get()
|
||||||
|
return {
|
||||||
|
...billing,
|
||||||
|
reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
|
||||||
|
reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
|
||||||
|
reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
|
||||||
|
reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
|
||||||
|
}
|
||||||
|
}, workspaceID)
|
||||||
}, "billing.get")
|
}, "billing.get")
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export type Format = "anthropic" | "openai" | "oa-compat"
|
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
createStreamPartConverter,
|
createStreamPartConverter,
|
||||||
createResponseConverter,
|
createResponseConverter,
|
||||||
} from "./provider/provider"
|
} from "./provider/provider"
|
||||||
import { Format } from "./format"
|
|
||||||
import { anthropicHelper } from "./provider/anthropic"
|
import { anthropicHelper } from "./provider/anthropic"
|
||||||
import { openaiHelper } from "./provider/openai"
|
import { openaiHelper } from "./provider/openai"
|
||||||
import { oaCompatHelper } from "./provider/openai-compatible"
|
import { oaCompatHelper } from "./provider/openai-compatible"
|
||||||
@@ -29,7 +28,7 @@ type Model = ZenData["models"][string]
|
|||||||
export async function handler(
|
export async function handler(
|
||||||
input: APIEvent,
|
input: APIEvent,
|
||||||
opts: {
|
opts: {
|
||||||
format: Format
|
format: ZenData.Format
|
||||||
parseApiKey: (headers: Headers) => string | undefined
|
parseApiKey: (headers: Headers) => string | undefined
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -239,21 +238,23 @@ export async function handler(
|
|||||||
.filter((provider) => !provider.disabled)
|
.filter((provider) => !provider.disabled)
|
||||||
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
||||||
|
|
||||||
// Use last character of IP address to select a provider
|
// Use the last 2 characters of IP address to select a provider
|
||||||
const lastChar = ip.charCodeAt(ip.length - 1) || 0
|
const lastChars = ip.slice(-2)
|
||||||
const index = lastChar % providers.length
|
const index = parseInt(lastChars, 16) % providers.length
|
||||||
const provider = providers[index]
|
const provider = providers[index || 0]
|
||||||
|
|
||||||
if (!(provider.id in zenData.providers)) {
|
if (!(provider.id in zenData.providers)) {
|
||||||
throw new ModelError(`Provider ${provider.id} not supported`)
|
throw new ModelError(`Provider ${provider.id} not supported`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const format = zenData.providers[provider.id].format
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...provider,
|
...provider,
|
||||||
...zenData.providers[provider.id],
|
...zenData.providers[provider.id],
|
||||||
...(provider.id === "anthropic"
|
...(format === "anthropic"
|
||||||
? anthropicHelper
|
? anthropicHelper
|
||||||
: provider.id === "openai"
|
: format === "openai"
|
||||||
? openaiHelper
|
? openaiHelper
|
||||||
: oaCompatHelper),
|
: oaCompatHelper),
|
||||||
}
|
}
|
||||||
@@ -280,6 +281,7 @@ export async function handler(
|
|||||||
monthlyLimit: BillingTable.monthlyLimit,
|
monthlyLimit: BillingTable.monthlyLimit,
|
||||||
monthlyUsage: BillingTable.monthlyUsage,
|
monthlyUsage: BillingTable.monthlyUsage,
|
||||||
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
||||||
|
reloadTrigger: BillingTable.reloadTrigger,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
id: UserTable.id,
|
id: UserTable.id,
|
||||||
@@ -531,7 +533,10 @@ export async function handler(
|
|||||||
and(
|
and(
|
||||||
eq(BillingTable.workspaceID, authInfo.workspaceID),
|
eq(BillingTable.workspaceID, authInfo.workspaceID),
|
||||||
eq(BillingTable.reload, true),
|
eq(BillingTable.reload, true),
|
||||||
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
|
lt(
|
||||||
|
BillingTable.balance,
|
||||||
|
centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100),
|
||||||
|
),
|
||||||
or(
|
or(
|
||||||
isNull(BillingTable.timeReloadLockedTill),
|
isNull(BillingTable.timeReloadLockedTill),
|
||||||
lt(BillingTable.timeReloadLockedTill, sql`now()`),
|
lt(BillingTable.timeReloadLockedTill, sql`now()`),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Format } from "../format"
|
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fromAnthropicChunk,
|
fromAnthropicChunk,
|
||||||
fromAnthropicRequest,
|
fromAnthropicRequest,
|
||||||
@@ -26,7 +25,7 @@ import {
|
|||||||
} from "./openai-compatible"
|
} from "./openai-compatible"
|
||||||
|
|
||||||
export type ProviderHelper = {
|
export type ProviderHelper = {
|
||||||
format: Format
|
format: ZenData.Format
|
||||||
modifyUrl: (providerApi: string) => string
|
modifyUrl: (providerApi: string) => string
|
||||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
||||||
modifyBody: (body: Record<string, any>) => Record<string, any>
|
modifyBody: (body: Record<string, any>) => Record<string, any>
|
||||||
@@ -158,7 +157,7 @@ export interface CommonChunk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBodyConverter(from: Format, to: Format) {
|
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||||
return (body: any): any => {
|
return (body: any): any => {
|
||||||
if (from === to) return body
|
if (from === to) return body
|
||||||
|
|
||||||
@@ -173,7 +172,7 @@ export function createBodyConverter(from: Format, to: Format) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStreamPartConverter(from: Format, to: Format) {
|
export function createStreamPartConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||||
return (part: any): any => {
|
return (part: any): any => {
|
||||||
if (from === to) return part
|
if (from === to) return part
|
||||||
|
|
||||||
@@ -191,7 +190,7 @@ export function createStreamPartConverter(from: Format, to: Format) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createResponseConverter(from: Format, to: Format) {
|
export function createResponseConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||||
return (response: any): any => {
|
return (response: any): any => {
|
||||||
if (from === to) return response
|
if (from === to) return response
|
||||||
|
|
||||||
|
|||||||
@@ -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.0.2",
|
"version": "1.0.27",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -11,14 +11,20 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
|
|||||||
|
|
||||||
// read the secret
|
// read the secret
|
||||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||||
const value = ret
|
const value1 = ret
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.find((line) => line.startsWith("ZEN_MODELS"))
|
.find((line) => line.startsWith("ZEN_MODELS1"))
|
||||||
?.split("=")[1]
|
?.split("=")[1]
|
||||||
if (!value) throw new Error("ZEN_MODELS not found")
|
const value2 = ret
|
||||||
|
.split("\n")
|
||||||
|
.find((line) => line.startsWith("ZEN_MODELS2"))
|
||||||
|
?.split("=")[1]
|
||||||
|
if (!value1) throw new Error("ZEN_MODELS1 not found")
|
||||||
|
if (!value2) throw new Error("ZEN_MODELS2 not found")
|
||||||
|
|
||||||
// validate value
|
// validate value
|
||||||
ZenData.validate(JSON.parse(value))
|
ZenData.validate(JSON.parse(value1 + value2))
|
||||||
|
|
||||||
// update the secret
|
// update the secret
|
||||||
await $`bun sst secret set ZEN_MODELS ${value} --stage ${stage}`
|
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
|
||||||
|
await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}`
|
||||||
|
|||||||
@@ -10,23 +10,29 @@ const models = await $`bun sst secret list`.cwd(root).text()
|
|||||||
console.log("models", models)
|
console.log("models", models)
|
||||||
|
|
||||||
// read the line starting with "ZEN_MODELS"
|
// read the line starting with "ZEN_MODELS"
|
||||||
const oldValue = models
|
const oldValue1 = models
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.find((line) => line.startsWith("ZEN_MODELS"))
|
.find((line) => line.startsWith("ZEN_MODELS1"))
|
||||||
?.split("=")[1]
|
?.split("=")[1]
|
||||||
if (!oldValue) throw new Error("ZEN_MODELS not found")
|
const oldValue2 = models
|
||||||
console.log("oldValue", oldValue)
|
.split("\n")
|
||||||
|
.find((line) => line.startsWith("ZEN_MODELS2"))
|
||||||
|
?.split("=")[1]
|
||||||
|
if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
|
||||||
|
if (!oldValue2) throw new Error("ZEN_MODELS2 not found")
|
||||||
|
|
||||||
// store the prettified json to a temp file
|
// store the prettified json to a temp file
|
||||||
const filename = `models-${Date.now()}.json`
|
const filename = `models-${Date.now()}.json`
|
||||||
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
||||||
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
|
await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2), null, 2))
|
||||||
console.log("tempFile", tempFile.name)
|
console.log("tempFile", tempFile.name)
|
||||||
|
|
||||||
// open temp file in vim and read the file on close
|
// open temp file in vim and read the file on close
|
||||||
await $`vim ${tempFile.name}`
|
await $`vim ${tempFile.name}`
|
||||||
const newValue = JSON.parse(await tempFile.text())
|
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
||||||
ZenData.validate(newValue)
|
ZenData.validate(JSON.parse(newValue))
|
||||||
|
|
||||||
// update the secret
|
// update the secret
|
||||||
await $`bun sst secret set ZEN_MODELS ${JSON.stringify(newValue)}`
|
const mid = Math.floor(newValue.length / 2)
|
||||||
|
await $`bun sst secret set ZEN_MODELS1 ${newValue.slice(0, mid)}`
|
||||||
|
await $`bun sst secret set ZEN_MODELS2 ${newValue.slice(mid)}`
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ import { centsToMicroCents } from "./util/price"
|
|||||||
import { User } from "./user"
|
import { User } from "./user"
|
||||||
|
|
||||||
export namespace Billing {
|
export namespace Billing {
|
||||||
export const CHARGE_NAME = "opencode credits"
|
export const ITEM_CREDIT_NAME = "opencode credits"
|
||||||
export const CHARGE_FEE_NAME = "processing fee"
|
export const ITEM_FEE_NAME = "processing fee"
|
||||||
export const CHARGE_AMOUNT = 2000 // $20
|
export const RELOAD_AMOUNT = 20
|
||||||
export const CHARGE_AMOUNT_DOLLAR = 20
|
export const RELOAD_AMOUNT_MIN = 10
|
||||||
export const CHARGE_FEE = 123 // Stripe fee 4.4% + $0.30
|
export const RELOAD_TRIGGER = 5
|
||||||
export const CHARGE_THRESHOLD_DOLLAR = 5
|
export const RELOAD_TRIGGER_MIN = 5
|
||||||
export const CHARGE_THRESHOLD = 500 // $5
|
|
||||||
export const stripe = () =>
|
export const stripe = () =>
|
||||||
new Stripe(Resource.STRIPE_SECRET_KEY.value, {
|
new Stripe(Resource.STRIPE_SECRET_KEY.value, {
|
||||||
apiVersion: "2025-03-31.basil",
|
apiVersion: "2025-03-31.basil",
|
||||||
@@ -33,6 +32,8 @@ export namespace Billing {
|
|||||||
paymentMethodLast4: BillingTable.paymentMethodLast4,
|
paymentMethodLast4: BillingTable.paymentMethodLast4,
|
||||||
balance: BillingTable.balance,
|
balance: BillingTable.balance,
|
||||||
reload: BillingTable.reload,
|
reload: BillingTable.reload,
|
||||||
|
reloadAmount: BillingTable.reloadAmount,
|
||||||
|
reloadTrigger: BillingTable.reloadTrigger,
|
||||||
monthlyLimit: BillingTable.monthlyLimit,
|
monthlyLimit: BillingTable.monthlyLimit,
|
||||||
monthlyUsage: BillingTable.monthlyUsage,
|
monthlyUsage: BillingTable.monthlyUsage,
|
||||||
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
||||||
@@ -67,17 +68,28 @@ export namespace Billing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const calculateFeeInCents = (x: number) => {
|
||||||
|
// math: x = total - (total * 0.044 + 0.30)
|
||||||
|
// math: x = total * (1-0.044) - 0.30
|
||||||
|
// math: (x + 0.30) / 0.956 = total
|
||||||
|
return Math.round(((x + 30) / 0.956) * 0.044 + 30)
|
||||||
|
}
|
||||||
|
|
||||||
export const reload = async () => {
|
export const reload = async () => {
|
||||||
const { customerID, paymentMethodID } = await Database.use((tx) =>
|
const billing = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.select({
|
||||||
customerID: BillingTable.customerID,
|
customerID: BillingTable.customerID,
|
||||||
paymentMethodID: BillingTable.paymentMethodID,
|
paymentMethodID: BillingTable.paymentMethodID,
|
||||||
|
reloadAmount: BillingTable.reloadAmount,
|
||||||
})
|
})
|
||||||
.from(BillingTable)
|
.from(BillingTable)
|
||||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||||
.then((rows) => rows[0]),
|
.then((rows) => rows[0]),
|
||||||
)
|
)
|
||||||
|
const customerID = billing.customerID
|
||||||
|
const paymentMethodID = billing.paymentMethodID
|
||||||
|
const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
|
||||||
const paymentID = Identifier.create("payment")
|
const paymentID = Identifier.create("payment")
|
||||||
let invoice
|
let invoice
|
||||||
try {
|
try {
|
||||||
@@ -89,18 +101,18 @@ export namespace Billing {
|
|||||||
currency: "usd",
|
currency: "usd",
|
||||||
})
|
})
|
||||||
await Billing.stripe().invoiceItems.create({
|
await Billing.stripe().invoiceItems.create({
|
||||||
amount: Billing.CHARGE_AMOUNT,
|
amount: amountInCents,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
customer: customerID!,
|
customer: customerID!,
|
||||||
description: CHARGE_NAME,
|
|
||||||
invoice: draft.id!,
|
invoice: draft.id!,
|
||||||
|
description: ITEM_CREDIT_NAME,
|
||||||
})
|
})
|
||||||
await Billing.stripe().invoiceItems.create({
|
await Billing.stripe().invoiceItems.create({
|
||||||
amount: Billing.CHARGE_FEE,
|
amount: calculateFeeInCents(amountInCents),
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
customer: customerID!,
|
customer: customerID!,
|
||||||
description: CHARGE_FEE_NAME,
|
|
||||||
invoice: draft.id!,
|
invoice: draft.id!,
|
||||||
|
description: ITEM_FEE_NAME,
|
||||||
})
|
})
|
||||||
await Billing.stripe().invoices.finalizeInvoice(draft.id!)
|
await Billing.stripe().invoices.finalizeInvoice(draft.id!)
|
||||||
invoice = await Billing.stripe().invoices.pay(draft.id!, {
|
invoice = await Billing.stripe().invoices.pay(draft.id!, {
|
||||||
@@ -128,7 +140,7 @@ export namespace Billing {
|
|||||||
await tx
|
await tx
|
||||||
.update(BillingTable)
|
.update(BillingTable)
|
||||||
.set({
|
.set({
|
||||||
balance: sql`${BillingTable.balance} + ${centsToMicroCents(CHARGE_AMOUNT)}`,
|
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
|
||||||
reloadError: null,
|
reloadError: null,
|
||||||
timeReloadError: null,
|
timeReloadError: null,
|
||||||
})
|
})
|
||||||
@@ -136,7 +148,7 @@ export namespace Billing {
|
|||||||
await tx.insert(PaymentTable).values({
|
await tx.insert(PaymentTable).values({
|
||||||
workspaceID: Actor.workspace(),
|
workspaceID: Actor.workspace(),
|
||||||
id: paymentID,
|
id: paymentID,
|
||||||
amount: centsToMicroCents(CHARGE_AMOUNT),
|
amount: centsToMicroCents(amountInCents),
|
||||||
invoiceID: invoice.id!,
|
invoiceID: invoice.id!,
|
||||||
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
|
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
|
||||||
customerID,
|
customerID,
|
||||||
@@ -159,13 +171,19 @@ export namespace Billing {
|
|||||||
z.object({
|
z.object({
|
||||||
successUrl: z.string(),
|
successUrl: z.string(),
|
||||||
cancelUrl: z.string(),
|
cancelUrl: z.string(),
|
||||||
|
amount: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
async (input) => {
|
async (input) => {
|
||||||
const user = Actor.assert("user")
|
const user = Actor.assert("user")
|
||||||
const { successUrl, cancelUrl } = input
|
const { successUrl, cancelUrl, amount } = input
|
||||||
|
|
||||||
|
if (amount !== undefined && amount < Billing.RELOAD_AMOUNT_MIN) {
|
||||||
|
throw new Error(`Amount must be at least $${Billing.RELOAD_AMOUNT_MIN}`)
|
||||||
|
}
|
||||||
|
|
||||||
const email = await User.getAuthEmail(user.properties.userID)
|
const email = await User.getAuthEmail(user.properties.userID)
|
||||||
const customer = await Billing.get()
|
const customer = await Billing.get()
|
||||||
|
const amountInCents = (amount ?? customer.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
|
||||||
const session = await Billing.stripe().checkout.sessions.create({
|
const session = await Billing.stripe().checkout.sessions.create({
|
||||||
mode: "payment",
|
mode: "payment",
|
||||||
billing_address_collection: "required",
|
billing_address_collection: "required",
|
||||||
@@ -173,20 +191,16 @@ export namespace Billing {
|
|||||||
{
|
{
|
||||||
price_data: {
|
price_data: {
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
product_data: {
|
product_data: { name: ITEM_CREDIT_NAME },
|
||||||
name: CHARGE_NAME,
|
unit_amount: amountInCents,
|
||||||
},
|
|
||||||
unit_amount: CHARGE_AMOUNT,
|
|
||||||
},
|
},
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
price_data: {
|
price_data: {
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
product_data: {
|
product_data: { name: ITEM_FEE_NAME },
|
||||||
name: CHARGE_FEE_NAME,
|
unit_amount: calculateFeeInCents(amountInCents),
|
||||||
},
|
|
||||||
unit_amount: CHARGE_FEE,
|
|
||||||
},
|
},
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
@@ -218,6 +232,7 @@ export namespace Billing {
|
|||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
workspaceID: Actor.workspace(),
|
workspaceID: Actor.workspace(),
|
||||||
|
amount: amountInCents.toString(),
|
||||||
},
|
},
|
||||||
success_url: successUrl,
|
success_url: successUrl,
|
||||||
cancel_url: cancelUrl,
|
cancel_url: cancelUrl,
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { Actor } from "./actor"
|
|||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
|
||||||
export namespace ZenData {
|
export namespace ZenData {
|
||||||
|
const FormatSchema = z.enum(["anthropic", "openai", "oa-compat"])
|
||||||
|
export type Format = z.infer<typeof FormatSchema>
|
||||||
|
|
||||||
const ModelCostSchema = z.object({
|
const ModelCostSchema = z.object({
|
||||||
input: z.number(),
|
input: z.number(),
|
||||||
output: z.number(),
|
output: z.number(),
|
||||||
@@ -34,6 +37,7 @@ export namespace ZenData {
|
|||||||
const ProviderSchema = z.object({
|
const ProviderSchema = z.object({
|
||||||
api: z.string(),
|
api: z.string(),
|
||||||
apiKey: z.string(),
|
apiKey: z.string(),
|
||||||
|
format: FormatSchema,
|
||||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -47,7 +51,7 @@ export namespace ZenData {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const list = fn(z.void(), () => {
|
export const list = fn(z.void(), () => {
|
||||||
const json = JSON.parse(Resource.ZEN_MODELS.value)
|
const json = JSON.parse(Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value)
|
||||||
return ModelsSchema.parse(json)
|
return ModelsSchema.parse(json)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -56,7 +60,9 @@ export namespace Model {
|
|||||||
export const enable = fn(z.object({ model: z.string() }), ({ model }) => {
|
export const enable = fn(z.object({ model: z.string() }), ({ model }) => {
|
||||||
Actor.assertAdmin()
|
Actor.assertAdmin()
|
||||||
return Database.use((db) =>
|
return Database.use((db) =>
|
||||||
db.delete(ModelTable).where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))),
|
db
|
||||||
|
.delete(ModelTable)
|
||||||
|
.where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Vendored
+96
-1
@@ -3,7 +3,102 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* deno-fmt-ignore-file */
|
/* deno-fmt-ignore-file */
|
||||||
|
|
||||||
/// <reference path="../../../sst-env.d.ts" />
|
import "sst"
|
||||||
|
declare module "sst" {
|
||||||
|
export interface Resource {
|
||||||
|
"ADMIN_SECRET": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"AUTH_API_URL": {
|
||||||
|
"type": "sst.sst.Linkable"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"AWS_SES_ACCESS_KEY_ID": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"AWS_SES_SECRET_ACCESS_KEY": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"Console": {
|
||||||
|
"type": "sst.cloudflare.SolidStart"
|
||||||
|
"url": string
|
||||||
|
}
|
||||||
|
"Database": {
|
||||||
|
"database": string
|
||||||
|
"host": string
|
||||||
|
"password": string
|
||||||
|
"port": number
|
||||||
|
"type": "sst.sst.Linkable"
|
||||||
|
"username": string
|
||||||
|
}
|
||||||
|
"Desktop": {
|
||||||
|
"type": "sst.cloudflare.StaticSite"
|
||||||
|
"url": string
|
||||||
|
}
|
||||||
|
"EMAILOCTOPUS_API_KEY": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"GITHUB_APP_ID": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"GITHUB_APP_PRIVATE_KEY": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"GITHUB_CLIENT_ID_CONSOLE": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"GITHUB_CLIENT_SECRET_CONSOLE": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"GOOGLE_CLIENT_ID": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"HONEYCOMB_API_KEY": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"STRIPE_SECRET_KEY": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"STRIPE_WEBHOOK_SECRET": {
|
||||||
|
"type": "sst.sst.Linkable"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"Web": {
|
||||||
|
"type": "sst.cloudflare.Astro"
|
||||||
|
"url": string
|
||||||
|
}
|
||||||
|
"ZEN_MODELS1": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"ZEN_MODELS2": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// cloudflare
|
||||||
|
import * as cloudflare from "@cloudflare/workers-types";
|
||||||
|
declare module "sst" {
|
||||||
|
export interface Resource {
|
||||||
|
"Api": cloudflare.Service
|
||||||
|
"AuthApi": cloudflare.Service
|
||||||
|
"AuthStorage": cloudflare.KVNamespace
|
||||||
|
"Bucket": cloudflare.R2Bucket
|
||||||
|
"LogProcessor": cloudflare.Service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
import "sst"
|
import "sst"
|
||||||
export {}
|
export {}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+9
-1
@@ -6,6 +6,10 @@
|
|||||||
import "sst"
|
import "sst"
|
||||||
declare module "sst" {
|
declare module "sst" {
|
||||||
export interface Resource {
|
export interface Resource {
|
||||||
|
"ADMIN_SECRET": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"AUTH_API_URL": {
|
"AUTH_API_URL": {
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
"value": string
|
"value": string
|
||||||
@@ -74,7 +78,11 @@ declare module "sst" {
|
|||||||
"type": "sst.cloudflare.Astro"
|
"type": "sst.cloudflare.Astro"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_MODELS": {
|
"ZEN_MODELS1": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"ZEN_MODELS2": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"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",
|
||||||
|
|||||||
+9
-1
@@ -6,6 +6,10 @@
|
|||||||
import "sst"
|
import "sst"
|
||||||
declare module "sst" {
|
declare module "sst" {
|
||||||
export interface Resource {
|
export interface Resource {
|
||||||
|
"ADMIN_SECRET": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"AUTH_API_URL": {
|
"AUTH_API_URL": {
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
"value": string
|
"value": string
|
||||||
@@ -74,7 +78,11 @@ declare module "sst" {
|
|||||||
"type": "sst.cloudflare.Astro"
|
"type": "sst.cloudflare.Astro"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_MODELS": {
|
"ZEN_MODELS1": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"ZEN_MODELS2": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "0.15.3",
|
"@solidjs/router": "0.15.3",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function Code(props: Props) {
|
|||||||
let ticking = false
|
let ticking = false
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (!container) return
|
if (!container) return
|
||||||
if (ctx.file.active()?.path !== local.path) return
|
// if (ctx.file.active()?.path !== local.path) return
|
||||||
if (ticking) return
|
if (ticking) return
|
||||||
ticking = true
|
ticking = true
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -99,20 +99,20 @@ export function Code(props: Props) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSelectionChange = () => {
|
const onSelectionChange = async () => {
|
||||||
if (!container) return
|
if (!container) return
|
||||||
if (isProgrammaticSelection) return
|
if (isProgrammaticSelection) return
|
||||||
if (ctx.file.active()?.path !== local.path) return
|
// if (ctx.file.active()?.path !== local.path) return
|
||||||
const d = getSelectionInContainer(container)
|
const d = getSelectionInContainer(container)
|
||||||
if (!d) return
|
if (!d) return
|
||||||
const p = ctx.file.node(local.path)?.selection
|
const p = (await ctx.file.node(local.path))?.selection
|
||||||
if (p && p.startLine === d.sl && p.endLine === d.el && p.startChar === d.sch && p.endChar === d.ech) return
|
if (p && p.startLine === d.sl && p.endLine === d.el && p.startChar === d.sch && p.endChar === d.ech) return
|
||||||
ctx.file.select(local.path, { startLine: d.sl, startChar: d.sch, endLine: d.el, endChar: d.ech })
|
ctx.file.select(local.path, { startLine: d.sl, startChar: d.sch, endLine: d.el, endChar: d.ech })
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (ctx.file.active()?.path !== local.path) return
|
// if (ctx.file.active()?.path !== local.path) return
|
||||||
const ae = document.activeElement as HTMLElement | undefined
|
const ae = document.activeElement as HTMLElement | undefined
|
||||||
const tag = (ae?.tagName || "").toLowerCase()
|
const tag = (ae?.tagName || "").toLowerCase()
|
||||||
const inputFocused = !!ae && (tag === "input" || tag === "textarea" || ae.isContentEditable)
|
const inputFocused = !!ae && (tag === "input" || tag === "textarea" || ae.isContentEditable)
|
||||||
@@ -144,21 +144,21 @@ export function Code(props: Props) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Restore scroll position from store when content is ready
|
// Restore scroll position from store when content is ready
|
||||||
createEffect(() => {
|
createEffect(async () => {
|
||||||
const content = html()
|
const content = html()
|
||||||
if (!container || !content) return
|
if (!container || !content) return
|
||||||
const top = ctx.file.node(local.path)?.scrollTop
|
const top = (await ctx.file.node(local.path))?.scrollTop
|
||||||
if (top !== undefined && container.scrollTop !== top) container.scrollTop = top
|
if (top !== undefined && container.scrollTop !== top) container.scrollTop = top
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sync selection from store -> DOM
|
// Sync selection from store -> DOM
|
||||||
createEffect(() => {
|
createEffect(async () => {
|
||||||
const content = html()
|
const content = html()
|
||||||
if (!container || !content) return
|
if (!container || !content) return
|
||||||
if (ctx.file.active()?.path !== local.path) return
|
// if (ctx.file.active()?.path !== local.path) return
|
||||||
const codeEl = container.querySelector("code") as HTMLElement | undefined
|
const codeEl = container.querySelector("code") as HTMLElement | undefined
|
||||||
if (!codeEl) return
|
if (!codeEl) return
|
||||||
const target = ctx.file.node(local.path)?.selection
|
const target = (await ctx.file.node(local.path))?.selection
|
||||||
const current = getSelectionInContainer(container)
|
const current = getSelectionInContainer(container)
|
||||||
const sel = window.getSelection()
|
const sel = window.getSelection()
|
||||||
if (!sel) return
|
if (!sel) return
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function FileTree(props: {
|
|||||||
component={p.as ?? "div"}
|
component={p.as ?? "div"}
|
||||||
classList={{
|
classList={{
|
||||||
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
|
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
|
||||||
"bg-background-element": local.file.active()?.path === p.node.path,
|
// "bg-background-element": local.file.active()?.path === p.node.path,
|
||||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
[props.nodeClass ?? ""]: !!props.nodeClass,
|
||||||
}}
|
}}
|
||||||
style={`padding-left: ${level * 10}px`}
|
style={`padding-left: ${level * 10}px`}
|
||||||
@@ -55,7 +55,7 @@ export default function FileTree(props: {
|
|||||||
"text-xs whitespace-nowrap truncate": true,
|
"text-xs whitespace-nowrap truncate": true,
|
||||||
"text-text-muted/40": p.node.ignored,
|
"text-text-muted/40": p.node.ignored,
|
||||||
"text-text-muted/80": !p.node.ignored,
|
"text-text-muted/80": !p.node.ignored,
|
||||||
"!text-text": local.file.active()?.path === p.node.path,
|
// "!text-text": local.file.active()?.path === p.node.path,
|
||||||
"!text-primary": local.file.changed(p.node.path),
|
"!text-primary": local.file.changed(p.node.path),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -77,7 +77,7 @@ export default function FileTree(props: {
|
|||||||
<Collapsible
|
<Collapsible
|
||||||
class="w-full"
|
class="w-full"
|
||||||
forceMount={false}
|
forceMount={false}
|
||||||
open={local.file.node(node.path)?.expanded}
|
// open={local.file.node(node.path)?.expanded}
|
||||||
onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
|
onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
@@ -85,7 +85,7 @@ export default function FileTree(props: {
|
|||||||
<Collapsible.Arrow class="text-text-muted/60 ml-1" />
|
<Collapsible.Arrow class="text-text-muted/60 ml-1" />
|
||||||
<FileIcon
|
<FileIcon
|
||||||
node={node}
|
node={node}
|
||||||
expanded={local.file.node(node.path).expanded}
|
// expanded={local.file.node(node.path).expanded}
|
||||||
class="text-text-muted/60 -ml-1"
|
class="text-text-muted/60 -ml-1"
|
||||||
/>
|
/>
|
||||||
</Node>
|
</Node>
|
||||||
|
|||||||
@@ -1,34 +1,61 @@
|
|||||||
import { For, Match, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import { Part } from "@opencode-ai/ui"
|
import { Part } from "@opencode-ai/ui"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import type { AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk"
|
||||||
|
import { Spinner } from "./spinner"
|
||||||
|
|
||||||
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[] }) {
|
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const items = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
|
const parts = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
|
||||||
|
const done = createMemo(() => props.done ?? false)
|
||||||
const finishedItems = createMemo(() => [
|
const currentTask = createMemo(
|
||||||
"",
|
() =>
|
||||||
"",
|
parts().findLast(
|
||||||
"Loading...",
|
(p) =>
|
||||||
...items().filter(
|
p &&
|
||||||
(p) =>
|
p.type === "tool" &&
|
||||||
p?.type === "text" ||
|
p.tool === "task" &&
|
||||||
(p?.type === "reasoning" && p.time?.end) ||
|
p.state &&
|
||||||
(p?.type === "tool" && p.state.status === "completed"),
|
"metadata" in p.state &&
|
||||||
),
|
p.state.metadata &&
|
||||||
"",
|
p.state.metadata.sessionId &&
|
||||||
|
p.state.status === "running",
|
||||||
|
) as ToolPart,
|
||||||
|
)
|
||||||
|
const resolvedParts = createMemo(() => {
|
||||||
|
let resolved = parts()
|
||||||
|
const task = currentTask()
|
||||||
|
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
|
||||||
|
const messages = sync.data.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant")
|
||||||
|
resolved = messages?.flatMap((m) => sync.data.part[m.id]) ?? parts()
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
})
|
||||||
|
// const currentText = createMemo(
|
||||||
|
// () =>
|
||||||
|
// resolvedParts().findLast((p) => p?.type === "text")?.text ||
|
||||||
|
// resolvedParts().findLast((p) => p?.type === "reasoning")?.text,
|
||||||
|
// )
|
||||||
|
const eligibleItems = createMemo(() => {
|
||||||
|
return resolvedParts().filter((p) => p?.type === "tool" && p?.state.status === "completed") as ToolPart[]
|
||||||
|
})
|
||||||
|
const finishedItems = createMemo<(JSXElement | ToolPart)[]>(() => [
|
||||||
|
<div class="h-8 w-full" />,
|
||||||
|
<div class="h-8 w-full" />,
|
||||||
|
<div class="h-8 w-full" />,
|
||||||
|
...eligibleItems(),
|
||||||
|
...(done() ? [<div class="h-8 w-full" />, <div class="h-8 w-full" />, <div class="h-8 w-full" />] : []),
|
||||||
])
|
])
|
||||||
|
|
||||||
const MINIMUM_DELAY = 400
|
const delay = createMemo(() => (done() ? 220 : 400))
|
||||||
const [visibleCount, setVisibleCount] = createSignal(1)
|
const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const total = finishedItems().length
|
const total = finishedItems().length
|
||||||
if (total > visibleCount()) {
|
if (total > visibleCount()) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setVisibleCount((prev) => prev + 1)
|
setVisibleCount((prev) => prev + 1)
|
||||||
}, MINIMUM_DELAY)
|
}, delay())
|
||||||
onCleanup(() => clearTimeout(timer))
|
onCleanup(() => clearTimeout(timer))
|
||||||
} else if (total < visibleCount()) {
|
} else if (total < visibleCount()) {
|
||||||
setVisibleCount(total)
|
setVisibleCount(total)
|
||||||
@@ -41,42 +68,120 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
|
|||||||
return `-${(total - 2) * 40 - 8}px`
|
return `-${(total - 2) * 40 - 8}px`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
|
||||||
|
const rawStatus = createMemo(() => {
|
||||||
|
const last = lastPart()
|
||||||
|
if (!last) return undefined
|
||||||
|
|
||||||
|
if (last.type === "tool") {
|
||||||
|
switch (last.tool) {
|
||||||
|
case "task":
|
||||||
|
return "Delegating work..."
|
||||||
|
case "todowrite":
|
||||||
|
case "todoread":
|
||||||
|
return "Planning next steps..."
|
||||||
|
case "read":
|
||||||
|
return "Gathering context..."
|
||||||
|
case "list":
|
||||||
|
case "grep":
|
||||||
|
case "glob":
|
||||||
|
return "Searching the codebase..."
|
||||||
|
case "webfetch":
|
||||||
|
return "Searching the web..."
|
||||||
|
case "edit":
|
||||||
|
case "write":
|
||||||
|
return "Making edits..."
|
||||||
|
case "bash":
|
||||||
|
return "Running commands..."
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else if (last.type === "reasoning") {
|
||||||
|
return "Thinking..."
|
||||||
|
} else if (last.type === "text") {
|
||||||
|
return "Gathering thoughts..."
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const [status, setStatus] = createSignal(rawStatus())
|
||||||
|
let lastStatusChange = Date.now()
|
||||||
|
let statusTimeout: number | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const newStatus = rawStatus()
|
||||||
|
if (newStatus === status() || !newStatus) return
|
||||||
|
|
||||||
|
const timeSinceLastChange = Date.now() - lastStatusChange
|
||||||
|
|
||||||
|
if (timeSinceLastChange >= 1500) {
|
||||||
|
setStatus(newStatus)
|
||||||
|
lastStatusChange = Date.now()
|
||||||
|
if (statusTimeout) {
|
||||||
|
clearTimeout(statusTimeout)
|
||||||
|
statusTimeout = undefined
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (statusTimeout) clearTimeout(statusTimeout)
|
||||||
|
statusTimeout = setTimeout(() => {
|
||||||
|
setStatus(rawStatus())
|
||||||
|
lastStatusChange = Date.now()
|
||||||
|
statusTimeout = undefined
|
||||||
|
}, 1000 - timeSinceLastChange) as unknown as number
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div class="flex flex-col gap-3">
|
||||||
class="h-30 overflow-hidden pointer-events-none
|
{/* <Show when={currentText()}> */}
|
||||||
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
|
{/* {(text) => ( */}
|
||||||
mask-b-from-90% mask-b-from-background-base mask-b-to-transparent"
|
{/* <div */}
|
||||||
>
|
{/* class="h-20 flex flex-col justify-end overflow-hidden py-3 */}
|
||||||
<div
|
{/* mask-alpha mask-t-from-80% mask-t-from-background-base mask-t-to-transparent" */}
|
||||||
class="w-full flex flex-col items-start self-stretch gap-2 py-8
|
{/* > */}
|
||||||
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
{/* <Markdown text={text()} class="w-full shrink-0 overflow-visible" /> */}
|
||||||
style={{ transform: `translateY(${translateY()})` }}
|
{/* </div> */}
|
||||||
>
|
{/* )} */}
|
||||||
<For each={finishedItems()}>
|
{/* </Show> */}
|
||||||
{(part) => {
|
<div class="flex items-center gap-x-5 pl-3 border border-transparent text-text-base">
|
||||||
if (typeof part === "string") return <div class="h-8 flex items-center w-full">{part}</div>
|
<Spinner /> <span class="text-12-medium">{status() ?? "Considering next steps..."}</span>
|
||||||
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
|
|
||||||
return (
|
|
||||||
<div class="h-8 flex items-center w-full">
|
|
||||||
<Switch>
|
|
||||||
<Match when={part.type === "text" && part}>
|
|
||||||
{(p) => (
|
|
||||||
<div
|
|
||||||
textContent={p().text}
|
|
||||||
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
<Match when={part.type === "reasoning" && part}>
|
|
||||||
{(p) => <Part message={message()!} part={p()} />}
|
|
||||||
</Match>
|
|
||||||
<Match when={part.type === "tool" && part}>{(p) => <Part message={message()!} part={p()} />}</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={eligibleItems().length > 0}>
|
||||||
|
<div
|
||||||
|
class="h-30 overflow-hidden pointer-events-none pb-1
|
||||||
|
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
|
||||||
|
mask-b-from-95% mask-b-from-background-base mask-b-to-transparent"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full flex flex-col items-start self-stretch gap-2 py-8
|
||||||
|
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||||
|
style={{ transform: `translateY(${translateY()})` }}
|
||||||
|
>
|
||||||
|
<For each={finishedItems()}>
|
||||||
|
{(part) => (
|
||||||
|
<Switch>
|
||||||
|
<Match when={part && typeof part === "object" && "type" in part && part}>
|
||||||
|
{(p) => {
|
||||||
|
const part = p() as ToolPart
|
||||||
|
const message = createMemo(() =>
|
||||||
|
sync.data.message[part.sessionID].find((m) => m.id === part.messageID),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div class="h-8 flex items-center w-full">
|
||||||
|
<Part message={message()!} part={part} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<div class="h-8 flex items-center w-full">{part as JSXElement}</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,41 @@
|
|||||||
import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui"
|
import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui"
|
||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { createEffect, on, Component, createMemo, Show, For, onMount, onCleanup } from "solid-js"
|
import { createEffect, on, Component, Show, For, onMount, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { FileIcon } from "@/ui"
|
import { FileIcon } from "@/ui"
|
||||||
import { getDirectory, getFilename } from "@/utils"
|
import { getDirectory, getFilename } from "@/utils"
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||||
import { TextSelection, useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
|
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
|
||||||
interface PartBase {
|
import { useSDK } from "@/context/sdk"
|
||||||
content: string
|
import { useNavigate } from "@solidjs/router"
|
||||||
start: number
|
import { useSync } from "@/context/sync"
|
||||||
end: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextPart extends PartBase {
|
|
||||||
type: "text"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileAttachmentPart extends PartBase {
|
|
||||||
type: "file"
|
|
||||||
path: string
|
|
||||||
selection?: TextSelection
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ContentPart = TextPart | FileAttachmentPart
|
|
||||||
|
|
||||||
interface PromptInputProps {
|
interface PromptInputProps {
|
||||||
onSubmit: (parts: ContentPart[]) => void
|
|
||||||
class?: string
|
class?: string
|
||||||
ref?: (el: HTMLDivElement) => void
|
ref?: (el: HTMLDivElement) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
const session = useSession()
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
|
|
||||||
const defaultParts = [{ type: "text", content: "", start: 0, end: 0 } as const]
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
contentParts: ContentPart[]
|
|
||||||
popoverIsOpen: boolean
|
popoverIsOpen: boolean
|
||||||
}>({
|
}>({
|
||||||
contentParts: defaultParts,
|
|
||||||
popoverIsOpen: false,
|
popoverIsOpen: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
|
createEffect(() => {
|
||||||
|
session.id
|
||||||
|
editorRef.focus()
|
||||||
|
})
|
||||||
|
|
||||||
const isFocused = createFocusSignal(() => editorRef)
|
const isFocused = createFocusSignal(() => editorRef)
|
||||||
|
|
||||||
const handlePaste = (event: ClipboardEvent) => {
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
@@ -71,14 +61,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleFileSelect = (path: string | undefined) => {
|
||||||
|
if (!path) return
|
||||||
|
addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
|
||||||
|
setStore("popoverIsOpen", false)
|
||||||
|
}
|
||||||
|
|
||||||
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
|
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
|
||||||
items: local.file.search,
|
items: local.file.searchFilesAndDirectories,
|
||||||
key: (x) => x,
|
key: (x) => x,
|
||||||
onSelect: (path) => {
|
onSelect: handleFileSelect,
|
||||||
if (!path) return
|
|
||||||
addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
|
|
||||||
setStore("popoverIsOpen", false)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -88,10 +80,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => store.contentParts,
|
() => session.prompt.current(),
|
||||||
(currentParts) => {
|
(currentParts) => {
|
||||||
const domParts = parseFromDOM()
|
const domParts = parseFromDOM()
|
||||||
if (isEqual(currentParts, domParts)) return
|
if (isPromptEqual(currentParts, domParts)) return
|
||||||
|
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
let cursorPosition: number | null = null
|
let cursorPosition: number | null = null
|
||||||
@@ -122,8 +114,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const parseFromDOM = (): ContentPart[] => {
|
createEffect(
|
||||||
const newParts: ContentPart[] = []
|
on(
|
||||||
|
() => session.prompt.cursor(),
|
||||||
|
(cursor) => {
|
||||||
|
if (cursor === undefined) return
|
||||||
|
queueMicrotask(() => setCursorPosition(editorRef, cursor))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const parseFromDOM = (): Prompt => {
|
||||||
|
const newParts: Prompt = []
|
||||||
let position = 0
|
let position = 0
|
||||||
editorRef.childNodes.forEach((node) => {
|
editorRef.childNodes.forEach((node) => {
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
@@ -150,7 +152,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (newParts.length === 0) newParts.push(...defaultParts)
|
if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
|
||||||
return newParts
|
return newParts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,12 +169,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
setStore("popoverIsOpen", false)
|
setStore("popoverIsOpen", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("contentParts", rawParts)
|
session.prompt.set(rawParts, cursorPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addPart = (part: ContentPart) => {
|
const addPart = (part: ContentPart) => {
|
||||||
const cursorPosition = getCursorPosition(editorRef)
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
const rawText = store.contentParts.map((p) => p.content).join("")
|
const rawText = session.prompt
|
||||||
|
.current()
|
||||||
|
.map((p) => p.content)
|
||||||
|
.join("")
|
||||||
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
||||||
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
||||||
|
|
||||||
@@ -198,7 +203,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
parts: nextParts,
|
parts: nextParts,
|
||||||
inserted,
|
inserted,
|
||||||
cursorPositionAfter,
|
cursorPositionAfter,
|
||||||
} = store.contentParts.reduce(
|
} = session.prompt.current().reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
if (acc.inserted) {
|
if (acc.inserted) {
|
||||||
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
|
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
|
||||||
@@ -257,7 +262,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!inserted) {
|
if (!inserted) {
|
||||||
const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
|
const baseParts = session.prompt.current().filter((item) => !(item.type === "text" && item.content === ""))
|
||||||
const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0)
|
const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0)
|
||||||
const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex }
|
const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex }
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
@@ -270,20 +275,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
end: appendedAcc.runningIndex + part.content.length,
|
end: appendedAcc.runningIndex + part.content.length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts
|
const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : DEFAULT_PROMPT
|
||||||
setStore("contentParts", next)
|
|
||||||
setStore("popoverIsOpen", false)
|
|
||||||
const nextCursor = rawText.length + part.content.length
|
const nextCursor = rawText.length + part.content.length
|
||||||
|
session.prompt.set(next, nextCursor)
|
||||||
|
setStore("popoverIsOpen", false)
|
||||||
queueMicrotask(() => setCursorPosition(editorRef, nextCursor))
|
queueMicrotask(() => setCursorPosition(editorRef, nextCursor))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("contentParts", nextParts)
|
session.prompt.set(nextParts, cursorPositionAfter)
|
||||||
setStore("popoverIsOpen", false)
|
setStore("popoverIsOpen", false)
|
||||||
|
|
||||||
queueMicrotask(() => setCursorPosition(editorRef, cursorPositionAfter))
|
queueMicrotask(() => setCursorPosition(editorRef, cursorPositionAfter))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abort = () =>
|
||||||
|
sdk.client.session.abort({
|
||||||
|
path: {
|
||||||
|
id: session.id!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||||
onKeyDown(event)
|
onKeyDown(event)
|
||||||
@@ -293,48 +305,138 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
handleSubmit(event)
|
handleSubmit(event)
|
||||||
}
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (store.popoverIsOpen) {
|
||||||
|
setStore("popoverIsOpen", false)
|
||||||
|
} else if (session.working()) {
|
||||||
|
abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (event: Event) => {
|
const handleSubmit = async (event: Event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (store.contentParts.length > 0) {
|
const text = session.prompt
|
||||||
props.onSubmit([...store.contentParts])
|
.current()
|
||||||
setStore("contentParts", defaultParts)
|
.map((part) => part.content)
|
||||||
|
.join("")
|
||||||
|
if (text.trim().length === 0) {
|
||||||
|
if (session.working()) abort()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let existing = session.info()
|
||||||
|
if (!existing) {
|
||||||
|
const created = await sdk.client.session.create()
|
||||||
|
existing = created.data ?? undefined
|
||||||
|
}
|
||||||
|
if (!existing) return
|
||||||
|
|
||||||
|
navigate(`/session/${existing.id}`)
|
||||||
|
if (!session.id) {
|
||||||
|
// session.layout.setOpenedTabs(
|
||||||
|
// session.layout.copyTabs("", session.id)
|
||||||
|
}
|
||||||
|
session.layout.setActiveTab(undefined)
|
||||||
|
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||||
|
|
||||||
|
const attachments = session.prompt.current().filter((part) => part.type === "file")
|
||||||
|
|
||||||
|
// const activeFile = local.context.active()
|
||||||
|
// if (activeFile) {
|
||||||
|
// registerAttachment(
|
||||||
|
// activeFile.path,
|
||||||
|
// activeFile.selection,
|
||||||
|
// activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
// for (const contextFile of local.context.all()) {
|
||||||
|
// registerAttachment(
|
||||||
|
// contextFile.path,
|
||||||
|
// contextFile.selection,
|
||||||
|
// formatAttachmentLabel(contextFile.path, contextFile.selection),
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
const attachmentParts = attachments.map((attachment) => {
|
||||||
|
const absolute = toAbsolutePath(attachment.path)
|
||||||
|
const query = attachment.selection
|
||||||
|
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
||||||
|
: ""
|
||||||
|
return {
|
||||||
|
type: "file" as const,
|
||||||
|
mime: "text/plain",
|
||||||
|
url: `file://${absolute}${query}`,
|
||||||
|
filename: getFilename(attachment.path),
|
||||||
|
source: {
|
||||||
|
type: "file" as const,
|
||||||
|
text: {
|
||||||
|
value: attachment.content,
|
||||||
|
start: attachment.start,
|
||||||
|
end: attachment.end,
|
||||||
|
},
|
||||||
|
path: absolute,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
session.prompt.set(DEFAULT_PROMPT, 0)
|
||||||
|
|
||||||
|
await sdk.client.session.prompt({
|
||||||
|
path: { id: existing.id },
|
||||||
|
body: {
|
||||||
|
agent: local.agent.current()!.name,
|
||||||
|
model: {
|
||||||
|
modelID: local.model.current()!.id,
|
||||||
|
providerID: local.model.current()!.provider.id,
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
...attachmentParts,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
||||||
<Show when={store.popoverIsOpen}>
|
<Show when={store.popoverIsOpen}>
|
||||||
<div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
|
<div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
|
||||||
<For each={flat()}>
|
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}>
|
||||||
{(i) => (
|
<For each={flat()}>
|
||||||
<div
|
{(i) => (
|
||||||
classList={{
|
<button
|
||||||
"w-full flex items-center justify-between rounded-md": true,
|
classList={{
|
||||||
"bg-surface-raised-base-hover": active() === i,
|
"w-full flex items-center justify-between rounded-md": true,
|
||||||
}}
|
"bg-surface-raised-base-hover": active() === i,
|
||||||
>
|
}}
|
||||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
onClick={() => handleFileSelect(i)}
|
||||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
>
|
||||||
<div class="flex items-center text-14-regular">
|
<div class="flex items-center gap-x-2 grow min-w-0">
|
||||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||||
{getDirectory(i)}
|
<div class="flex items-center text-14-regular">
|
||||||
</span>
|
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
{getDirectory(i)}
|
||||||
|
</span>
|
||||||
|
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</For>
|
||||||
</For>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
classList={{
|
classList={{
|
||||||
"bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
|
"bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
|
||||||
"rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
|
"rounded-2xl overflow-clip focus-within:border-transparent focus-within:shadow-xs-border-select": true,
|
||||||
[props.class ?? ""]: !!props.class,
|
[props.class ?? ""]: !!props.class,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -352,7 +454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
"[&>[data-type=file]]:text-icon-info-active": true,
|
"[&>[data-type=file]]:text-icon-info-active": true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Show when={isEmpty()}>
|
<Show when={!session.prompt.dirty()}>
|
||||||
<div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none">
|
<div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none">
|
||||||
Plan and build anything
|
Plan and build anything
|
||||||
</div>
|
</div>
|
||||||
@@ -417,29 +519,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</SelectDialog>
|
</SelectDialog>
|
||||||
</div>
|
</div>
|
||||||
<IconButton type="submit" disabled={isEmpty()} icon="arrow-up" variant="primary" />
|
<IconButton
|
||||||
|
type="submit"
|
||||||
|
disabled={!session.prompt.dirty() && !session.working()}
|
||||||
|
icon={session.working() ? "stop" : "arrow-up"}
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean {
|
|
||||||
if (arrA.length !== arrB.length) return false
|
|
||||||
for (let i = 0; i < arrA.length; i++) {
|
|
||||||
const partA = arrA[i]
|
|
||||||
const partB = arrB[i]
|
|
||||||
if (partA.type !== partB.type) return false
|
|
||||||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCursorPosition(parent: HTMLElement): number {
|
function getCursorPosition(parent: HTMLElement): number {
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (!selection || selection.rangeCount === 0) return 0
|
if (!selection || selection.rangeCount === 0) return 0
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { batch, createEffect, createMemo } from "solid-js"
|
import { batch, createEffect, createMemo } from "solid-js"
|
||||||
import { pipe, sumBy, uniqueBy } from "remeda"
|
import { uniqueBy } from "remeda"
|
||||||
import type {
|
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
|
||||||
FileContent,
|
|
||||||
FileNode,
|
|
||||||
Model,
|
|
||||||
Provider,
|
|
||||||
File as FileStatus,
|
|
||||||
Part,
|
|
||||||
Message,
|
|
||||||
AssistantMessage,
|
|
||||||
} from "@opencode-ai/sdk"
|
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
@@ -162,10 +153,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
||||||
|
|
||||||
|
const cycle = (direction: 1 | -1) => {
|
||||||
|
const recentList = recent()
|
||||||
|
const current = currentModel()
|
||||||
|
if (!current) return
|
||||||
|
|
||||||
|
const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
let next = index + direction
|
||||||
|
if (next < 0) next = recentList.length - 1
|
||||||
|
if (next >= recentList.length) next = 0
|
||||||
|
|
||||||
|
const val = recentList[next]
|
||||||
|
if (!val) return
|
||||||
|
|
||||||
|
model.set({
|
||||||
|
providerID: val.provider.id,
|
||||||
|
modelID: val.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current: currentModel,
|
current: currentModel,
|
||||||
recent,
|
recent,
|
||||||
list,
|
list,
|
||||||
|
cycle,
|
||||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("model", agent.current().name, model ?? fallbackModel())
|
setStore("model", agent.current().name, model ?? fallbackModel())
|
||||||
@@ -182,18 +195,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const file = (() => {
|
const file = (() => {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
node: Record<string, LocalFile>
|
node: Record<string, LocalFile>
|
||||||
opened: string[]
|
|
||||||
active?: string
|
|
||||||
}>({
|
}>({
|
||||||
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||||
opened: [],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const active = createMemo(() => {
|
|
||||||
if (!store.active) return undefined
|
|
||||||
return store.node[store.active]
|
|
||||||
})
|
|
||||||
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
|
||||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||||
|
|
||||||
@@ -234,18 +239,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetNode = (path: string) => {
|
// const resetNode = (path: string) => {
|
||||||
setStore("node", path, {
|
// setStore("node", path, {
|
||||||
loaded: undefined,
|
// loaded: undefined,
|
||||||
pinned: undefined,
|
// pinned: undefined,
|
||||||
content: undefined,
|
// content: undefined,
|
||||||
selection: undefined,
|
// selection: undefined,
|
||||||
scrollTop: undefined,
|
// scrollTop: undefined,
|
||||||
folded: undefined,
|
// folded: undefined,
|
||||||
view: undefined,
|
// view: undefined,
|
||||||
selectedChange: undefined,
|
// selectedChange: undefined,
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|
||||||
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
|
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
|
||||||
|
|
||||||
@@ -281,16 +286,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
|
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
|
||||||
const relativePath = relative(path)
|
const relativePath = relative(path)
|
||||||
if (!store.node[relativePath]) await fetch(path)
|
if (!store.node[relativePath]) await fetch(path)
|
||||||
setStore("opened", (x) => {
|
// setStore("opened", (x) => {
|
||||||
if (x.includes(relativePath)) return x
|
// if (x.includes(relativePath)) return x
|
||||||
return [
|
// return [
|
||||||
...opened()
|
// ...opened()
|
||||||
.filter((x) => x.pinned)
|
// .filter((x) => x.pinned)
|
||||||
.map((x) => x.path),
|
// .map((x) => x.path),
|
||||||
relativePath,
|
// relativePath,
|
||||||
]
|
// ]
|
||||||
})
|
// })
|
||||||
setStore("active", relativePath)
|
// setStore("active", relativePath)
|
||||||
context.addActive()
|
context.addActive()
|
||||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||||
@@ -312,51 +317,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const search = (query: string) => sdk.client.find.files({ query: { query } }).then((x) => x.data!)
|
const searchFiles = (query: string) =>
|
||||||
|
sdk.client.find.files({ query: { query, dirs: "false" } }).then((x) => x.data!)
|
||||||
|
const searchFilesAndDirectories = (query: string) =>
|
||||||
|
sdk.client.find.files({ query: { query, dirs: "true" } }).then((x) => x.data!)
|
||||||
|
|
||||||
sdk.event.listen((e) => {
|
sdk.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "message.part.updated":
|
|
||||||
const part = event.properties.part
|
|
||||||
if (part.type === "tool" && part.state.status === "completed") {
|
|
||||||
switch (part.tool) {
|
|
||||||
case "read":
|
|
||||||
break
|
|
||||||
case "edit":
|
|
||||||
// load(part.state.input["filePath"] as string)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case "file.watcher.updated":
|
case "file.watcher.updated":
|
||||||
// setTimeout(sync.load.changes, 1000)
|
const relativePath = relative(event.properties.file)
|
||||||
// const relativePath = relative(event.properties.file)
|
if (relativePath.startsWith(".git/")) return
|
||||||
// if (relativePath.startsWith(".git/")) return
|
load(relativePath)
|
||||||
// load(relativePath)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
active,
|
node: async (path: string) => {
|
||||||
opened,
|
if (!store.node[path]) {
|
||||||
node: (path: string) => store.node[path],
|
await init(path)
|
||||||
|
}
|
||||||
|
return store.node[path]
|
||||||
|
},
|
||||||
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
|
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
|
||||||
open,
|
open,
|
||||||
load,
|
load,
|
||||||
init,
|
init,
|
||||||
close(path: string) {
|
|
||||||
setStore("opened", (opened) => opened.filter((x) => x !== path))
|
|
||||||
if (store.active === path) {
|
|
||||||
const index = store.opened.findIndex((f) => f === path)
|
|
||||||
const previous = store.opened[Math.max(0, index - 1)]
|
|
||||||
setStore("active", previous)
|
|
||||||
}
|
|
||||||
resetNode(path)
|
|
||||||
},
|
|
||||||
expand(path: string) {
|
expand(path: string) {
|
||||||
setStore("node", path, "expanded", true)
|
setStore("node", path, "expanded", true)
|
||||||
if (store.node[path].loaded) return
|
if (store.node[path].loaded) return
|
||||||
@@ -372,17 +359,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
scroll(path: string, scrollTop: number) {
|
scroll(path: string, scrollTop: number) {
|
||||||
setStore("node", path, "scrollTop", scrollTop)
|
setStore("node", path, "scrollTop", scrollTop)
|
||||||
},
|
},
|
||||||
move(path: string, to: number) {
|
|
||||||
const index = store.opened.findIndex((f) => f === path)
|
|
||||||
if (index === -1) return
|
|
||||||
setStore(
|
|
||||||
"opened",
|
|
||||||
produce((opened) => {
|
|
||||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setStore("node", path, "pinned", true)
|
|
||||||
},
|
|
||||||
view(path: string): View {
|
view(path: string): View {
|
||||||
const n = store.node[path]
|
const n = store.node[path]
|
||||||
return n && n.view ? n.view : "raw"
|
return n && n.view ? n.view : "raw"
|
||||||
@@ -420,157 +396,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
|
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
search,
|
searchFiles,
|
||||||
|
searchFilesAndDirectories,
|
||||||
relative,
|
relative,
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const session = (() => {
|
|
||||||
const [store, setStore] = createStore<{
|
|
||||||
active?: string
|
|
||||||
activeMessage?: string
|
|
||||||
}>({})
|
|
||||||
|
|
||||||
const active = createMemo(() => {
|
|
||||||
if (!store.active) return undefined
|
|
||||||
return sync.session.get(store.active)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!store.active) return
|
|
||||||
sync.session.sync(store.active)
|
|
||||||
})
|
|
||||||
|
|
||||||
const valid = (part: Part) => {
|
|
||||||
if (!part) return false
|
|
||||||
switch (part.type) {
|
|
||||||
case "step-start":
|
|
||||||
case "step-finish":
|
|
||||||
case "file":
|
|
||||||
case "patch":
|
|
||||||
return false
|
|
||||||
case "text":
|
|
||||||
return !part.synthetic && part.text.trim()
|
|
||||||
case "reasoning":
|
|
||||||
return part.text.trim()
|
|
||||||
case "tool":
|
|
||||||
switch (part.tool) {
|
|
||||||
case "todoread":
|
|
||||||
case "todowrite":
|
|
||||||
case "list":
|
|
||||||
case "grep":
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasValidParts = (message: Message) => {
|
|
||||||
return sync.data.part[message.id]?.filter(valid).length > 0
|
|
||||||
}
|
|
||||||
// const hasTextPart = (message: Message) => {
|
|
||||||
// return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
|
|
||||||
// }
|
|
||||||
|
|
||||||
const messages = createMemo(() => (store.active ? (sync.data.message[store.active] ?? []) : []))
|
|
||||||
const messagesWithValidParts = createMemo(() => messages().filter(hasValidParts) ?? [])
|
|
||||||
const userMessages = createMemo(() =>
|
|
||||||
messages()
|
|
||||||
.filter((m) => m.role === "user")
|
|
||||||
.sort((a, b) => b.id.localeCompare(a.id)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
|
||||||
const total = pipe(
|
|
||||||
messages(),
|
|
||||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
|
||||||
)
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(total)
|
|
||||||
})
|
|
||||||
|
|
||||||
const last = createMemo(() => {
|
|
||||||
return messages().findLast((x) => x.role === "assistant") as AssistantMessage
|
|
||||||
})
|
|
||||||
|
|
||||||
const lastUserMessage = createMemo(() => {
|
|
||||||
return userMessages()?.at(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeMessage = createMemo(() => {
|
|
||||||
if (!store.active || !store.activeMessage) return lastUserMessage()
|
|
||||||
return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
const model = createMemo(() => {
|
|
||||||
if (!last()) return
|
|
||||||
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
|
|
||||||
return model
|
|
||||||
})
|
|
||||||
|
|
||||||
const tokens = createMemo(() => {
|
|
||||||
if (!last()) return
|
|
||||||
const tokens = last().tokens
|
|
||||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
notation: "compact",
|
|
||||||
compactDisplay: "short",
|
|
||||||
}).format(total)
|
|
||||||
})
|
|
||||||
|
|
||||||
const context = createMemo(() => {
|
|
||||||
if (!last()) return
|
|
||||||
if (!model()?.limit.context) return 0
|
|
||||||
const tokens = last().tokens
|
|
||||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
|
||||||
return Math.round((total / model()!.limit.context) * 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
const getMessageText = (message: Message | Message[] | undefined): string => {
|
|
||||||
if (!message) return ""
|
|
||||||
if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ")
|
|
||||||
return sync.data.part[message.id]
|
|
||||||
?.filter((p) => p.type === "text")
|
|
||||||
?.filter((p) => !p.synthetic)
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
active,
|
|
||||||
activeMessage,
|
|
||||||
lastUserMessage,
|
|
||||||
cost,
|
|
||||||
last,
|
|
||||||
model,
|
|
||||||
tokens,
|
|
||||||
context,
|
|
||||||
messages,
|
|
||||||
messagesWithValidParts,
|
|
||||||
userMessages,
|
|
||||||
// working,
|
|
||||||
getMessageText,
|
|
||||||
setActive(sessionId: string | undefined) {
|
|
||||||
setStore("active", sessionId)
|
|
||||||
setStore("activeMessage", undefined)
|
|
||||||
},
|
|
||||||
clearActive() {
|
|
||||||
setStore("active", undefined)
|
|
||||||
setStore("activeMessage", undefined)
|
|
||||||
},
|
|
||||||
setActiveMessage(messageId: string | undefined) {
|
|
||||||
setStore("activeMessage", messageId)
|
|
||||||
},
|
|
||||||
clearActiveMessage() {
|
|
||||||
setStore("activeMessage", undefined)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const context = (() => {
|
const context = (() => {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
activeTab: boolean
|
activeTab: boolean
|
||||||
@@ -589,9 +420,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
all() {
|
all() {
|
||||||
return store.items
|
return store.items
|
||||||
},
|
},
|
||||||
active() {
|
// active() {
|
||||||
return store.activeTab ? file.active() : undefined
|
// return store.activeTab ? file.active() : undefined
|
||||||
},
|
// },
|
||||||
addActive() {
|
addActive() {
|
||||||
setStore("activeTab", true)
|
setStore("activeTab", true)
|
||||||
},
|
},
|
||||||
@@ -629,7 +460,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
model,
|
model,
|
||||||
agent,
|
agent,
|
||||||
file,
|
file,
|
||||||
session,
|
|
||||||
context,
|
context,
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { createSimpleContext } from "./helper"
|
||||||
|
import { batch, createEffect, createMemo } from "solid-js"
|
||||||
|
import { useSync } from "./sync"
|
||||||
|
import { makePersisted } from "@solid-primitives/storage"
|
||||||
|
import { TextSelection, useLocal } from "./local"
|
||||||
|
import { pipe, sumBy } from "remeda"
|
||||||
|
import { AssistantMessage } from "@opencode-ai/sdk"
|
||||||
|
|
||||||
|
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
|
||||||
|
name: "Session",
|
||||||
|
init: (props: { sessionId?: string }) => {
|
||||||
|
const sync = useSync()
|
||||||
|
const local = useLocal()
|
||||||
|
|
||||||
|
const [store, setStore] = makePersisted(
|
||||||
|
createStore<{
|
||||||
|
prompt: Prompt
|
||||||
|
cursorPosition?: number
|
||||||
|
messageId?: string
|
||||||
|
tabs: {
|
||||||
|
active?: string
|
||||||
|
opened: string[]
|
||||||
|
}
|
||||||
|
}>({
|
||||||
|
prompt: [{ type: "text", content: "", start: 0, end: 0 }],
|
||||||
|
tabs: {
|
||||||
|
opened: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: props.sessionId ?? "new-session",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.sessionId) return
|
||||||
|
sync.session.sync(props.sessionId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const info = createMemo(() => (props.sessionId ? sync.session.get(props.sessionId) : undefined))
|
||||||
|
const messages = createMemo(() => (props.sessionId ? (sync.data.message[props.sessionId] ?? []) : []))
|
||||||
|
const userMessages = createMemo(() =>
|
||||||
|
messages()
|
||||||
|
.filter((m) => m.role === "user")
|
||||||
|
.sort((a, b) => b.id.localeCompare(a.id)),
|
||||||
|
)
|
||||||
|
const lastUserMessage = createMemo(() => {
|
||||||
|
return userMessages()?.at(0)
|
||||||
|
})
|
||||||
|
const activeMessage = createMemo(() => {
|
||||||
|
if (!store.messageId) return lastUserMessage()
|
||||||
|
return userMessages()?.find((m) => m.id === store.messageId)
|
||||||
|
})
|
||||||
|
const working = createMemo(() => {
|
||||||
|
if (!props.sessionId) return false
|
||||||
|
const last = lastUserMessage()
|
||||||
|
if (!last) return false
|
||||||
|
const assistantMessages = sync.data.message[props.sessionId]?.filter(
|
||||||
|
(m) => m.role === "assistant" && m.parentID == last?.id,
|
||||||
|
) as AssistantMessage[]
|
||||||
|
const error = assistantMessages?.find((m) => m?.error)?.error
|
||||||
|
return !last?.summary?.body && !error
|
||||||
|
})
|
||||||
|
|
||||||
|
const cost = createMemo(() => {
|
||||||
|
const total = pipe(
|
||||||
|
messages(),
|
||||||
|
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
||||||
|
)
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
const last = createMemo(
|
||||||
|
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
|
||||||
|
)
|
||||||
|
const model = createMemo(() =>
|
||||||
|
last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokens = createMemo(() => {
|
||||||
|
if (!last()) return
|
||||||
|
const tokens = last().tokens
|
||||||
|
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||||
|
})
|
||||||
|
|
||||||
|
const context = createMemo(() => {
|
||||||
|
const total = tokens()
|
||||||
|
const limit = model()?.limit.context
|
||||||
|
if (!total || !limit) return 0
|
||||||
|
return Math.round((total / limit) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: props.sessionId,
|
||||||
|
info,
|
||||||
|
working,
|
||||||
|
prompt: {
|
||||||
|
current: createMemo(() => store.prompt),
|
||||||
|
cursor: createMemo(() => store.cursorPosition),
|
||||||
|
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||||
|
set(prompt: Prompt, cursorPosition?: number) {
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", prompt)
|
||||||
|
if (cursorPosition !== undefined) setStore("cursorPosition", cursorPosition)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
all: messages,
|
||||||
|
user: userMessages,
|
||||||
|
last: lastUserMessage,
|
||||||
|
active: activeMessage,
|
||||||
|
setActive(id: string | undefined) {
|
||||||
|
setStore("messageId", id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
tokens,
|
||||||
|
cost,
|
||||||
|
context,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
tabs: store.tabs,
|
||||||
|
setActiveTab(tab: string | undefined) {
|
||||||
|
setStore("tabs", "active", tab)
|
||||||
|
},
|
||||||
|
setOpenedTabs(tabs: string[]) {
|
||||||
|
setStore("tabs", "opened", tabs)
|
||||||
|
},
|
||||||
|
async openTab(tab: string) {
|
||||||
|
if (tab === "chat") {
|
||||||
|
setStore("tabs", "active", undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (tab.startsWith("file://")) {
|
||||||
|
await local.file.open(tab.replace("file://", ""))
|
||||||
|
}
|
||||||
|
if (!store.tabs.opened.includes(tab)) {
|
||||||
|
setStore("tabs", "opened", [...store.tabs.opened, tab])
|
||||||
|
}
|
||||||
|
setStore("tabs", "active", tab)
|
||||||
|
},
|
||||||
|
closeTab(tab: string) {
|
||||||
|
batch(() => {
|
||||||
|
setStore(
|
||||||
|
"tabs",
|
||||||
|
"opened",
|
||||||
|
store.tabs.opened.filter((x) => x !== tab),
|
||||||
|
)
|
||||||
|
if (store.tabs.active === tab) {
|
||||||
|
const index = store.tabs.opened.findIndex((f) => f === tab)
|
||||||
|
const previous = store.tabs.opened[Math.max(0, index - 1)]
|
||||||
|
setStore("tabs", "active", previous)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
moveTab(tab: string, to: number) {
|
||||||
|
const index = store.tabs.opened.findIndex((f) => f === tab)
|
||||||
|
if (index === -1) return
|
||||||
|
setStore(
|
||||||
|
"tabs",
|
||||||
|
"opened",
|
||||||
|
produce((opened) => {
|
||||||
|
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// setStore("node", path, "pinned", true)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
interface PartBase {
|
||||||
|
content: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextPart extends PartBase {
|
||||||
|
type: "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileAttachmentPart extends PartBase {
|
||||||
|
type: "file"
|
||||||
|
path: string
|
||||||
|
selection?: TextSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentPart = TextPart | FileAttachmentPart
|
||||||
|
export type Prompt = ContentPart[]
|
||||||
|
|
||||||
|
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
|
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||||
|
if (promptA.length !== promptB.length) return false
|
||||||
|
for (let i = 0; i < promptA.length; i++) {
|
||||||
|
const partA = promptA[i]
|
||||||
|
const partB = promptB[i]
|
||||||
|
if (partA.type !== partB.type) return false
|
||||||
|
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -1,16 +1,4 @@
|
|||||||
import type {
|
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
|
||||||
Message,
|
|
||||||
Agent,
|
|
||||||
Provider,
|
|
||||||
Session,
|
|
||||||
Part,
|
|
||||||
Config,
|
|
||||||
Path,
|
|
||||||
File,
|
|
||||||
FileNode,
|
|
||||||
Project,
|
|
||||||
Command,
|
|
||||||
} from "@opencode-ai/sdk"
|
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { Binary } from "@/utils/binary"
|
import { Binary } from "@/utils/binary"
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui"
|
|||||||
import { SDKProvider } from "./context/sdk"
|
import { SDKProvider } from "./context/sdk"
|
||||||
import { SyncProvider } from "./context/sync"
|
import { SyncProvider } from "./context/sync"
|
||||||
import { LocalProvider } from "./context/local"
|
import { LocalProvider } from "./context/local"
|
||||||
import Home from "@/pages"
|
import Layout from "@/pages/layout"
|
||||||
|
import SessionLayout from "@/pages/session-layout"
|
||||||
|
import Session from "@/pages/session"
|
||||||
|
|
||||||
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
|
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
|
||||||
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
|
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
|
||||||
|
|
||||||
const url = new URLSearchParams(document.location.search).get("url") || `http://${host}:${port}`
|
const url =
|
||||||
|
new URLSearchParams(document.location.search).get("url") ||
|
||||||
|
(location.hostname.includes("opencode.ai") || location.hostname.includes("localhost")
|
||||||
|
? `http://${host}:${port}`
|
||||||
|
: "/")
|
||||||
|
|
||||||
const root = document.getElementById("root")
|
const root = document.getElementById("root")
|
||||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||||
@@ -30,8 +36,10 @@ render(
|
|||||||
<LocalProvider>
|
<LocalProvider>
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<Fonts />
|
<Fonts />
|
||||||
<Router>
|
<Router root={Layout}>
|
||||||
<Route path="/" component={Home} />
|
<Route path={["/", "/session"]} component={SessionLayout}>
|
||||||
|
<Route path="/:id?" component={Session} />
|
||||||
|
</Route>
|
||||||
</Router>
|
</Router>
|
||||||
</MetaProvider>
|
</MetaProvider>
|
||||||
</LocalProvider>
|
</LocalProvider>
|
||||||
|
|||||||
@@ -1,830 +0,0 @@
|
|||||||
import {
|
|
||||||
Button,
|
|
||||||
List,
|
|
||||||
SelectDialog,
|
|
||||||
Tooltip,
|
|
||||||
IconButton,
|
|
||||||
Tabs,
|
|
||||||
Icon,
|
|
||||||
Accordion,
|
|
||||||
Diff,
|
|
||||||
Collapsible,
|
|
||||||
Part,
|
|
||||||
DiffChanges,
|
|
||||||
ProgressCircle,
|
|
||||||
Message,
|
|
||||||
Typewriter,
|
|
||||||
} from "@opencode-ai/ui"
|
|
||||||
import { FileIcon } from "@/ui"
|
|
||||||
import FileTree from "@/components/file-tree"
|
|
||||||
import { MessageProgress } from "@/components/message-progress"
|
|
||||||
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
|
|
||||||
import { useLocal, type LocalFile } from "@/context/local"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { getDirectory, getFilename } from "@/utils"
|
|
||||||
import { ContentPart, PromptInput } from "@/components/prompt-input"
|
|
||||||
import { DateTime } from "luxon"
|
|
||||||
import {
|
|
||||||
DragDropProvider,
|
|
||||||
DragDropSensors,
|
|
||||||
DragOverlay,
|
|
||||||
SortableProvider,
|
|
||||||
closestCenter,
|
|
||||||
createSortable,
|
|
||||||
useDragDropContext,
|
|
||||||
} from "@thisbeyond/solid-dnd"
|
|
||||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
|
||||||
import type { JSX } from "solid-js"
|
|
||||||
import { Code } from "@/components/code"
|
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
|
||||||
import { Markdown } from "@opencode-ai/ui"
|
|
||||||
import { Spinner } from "@/components/spinner"
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const local = useLocal()
|
|
||||||
const sync = useSync()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
clickTimer: undefined as number | undefined,
|
|
||||||
fileSelectOpen: false,
|
|
||||||
})
|
|
||||||
let inputRef!: HTMLDivElement
|
|
||||||
let messageScrollElement!: HTMLDivElement
|
|
||||||
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
// Set first message as active if none selected
|
|
||||||
const userMessages = local.session.userMessages()
|
|
||||||
if (userMessages.length > 0 && !local.session.activeMessage()) {
|
|
||||||
local.session.setActiveMessage(userMessages[0].id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
|
|
||||||
event.preventDefault()
|
|
||||||
setStore("fileSelectOpen", true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const focused = document.activeElement === inputRef
|
|
||||||
if (focused) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
inputRef?.blur()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (local.file.active()) {
|
|
||||||
const active = local.file.active()!
|
|
||||||
if (event.key === "Enter" && active.selection) {
|
|
||||||
local.context.add({
|
|
||||||
type: "file",
|
|
||||||
path: active.path,
|
|
||||||
selection: { ...active.selection },
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.getModifierState(MOD)) {
|
|
||||||
if (event.key.toLowerCase() === "a") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.key.toLowerCase() === "c") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
|
||||||
inputRef?.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetClickTimer = () => {
|
|
||||||
if (!store.clickTimer) return
|
|
||||||
clearTimeout(store.clickTimer)
|
|
||||||
setStore("clickTimer", undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const startClickTimer = () => {
|
|
||||||
const newClickTimer = setTimeout(() => {
|
|
||||||
setStore("clickTimer", undefined)
|
|
||||||
}, 300)
|
|
||||||
setStore("clickTimer", newClickTimer as unknown as number)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileClick = async (file: LocalFile) => {
|
|
||||||
if (store.clickTimer) {
|
|
||||||
resetClickTimer()
|
|
||||||
local.file.update(file.path, { ...file, pinned: true })
|
|
||||||
} else {
|
|
||||||
local.file.open(file.path)
|
|
||||||
startClickTimer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateChange = (dir: 1 | -1) => {
|
|
||||||
const active = local.file.active()
|
|
||||||
if (!active) return
|
|
||||||
const current = local.file.changeIndex(active.path)
|
|
||||||
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
|
|
||||||
local.file.setChangeIndex(active.path, next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTabChange = (path: string) => {
|
|
||||||
if (path === "chat" || path === "review") return
|
|
||||||
local.file.open(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTabClose = (file: LocalFile) => {
|
|
||||||
local.file.close(file.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragStart = (event: unknown) => {
|
|
||||||
const id = getDraggableId(event)
|
|
||||||
if (!id) return
|
|
||||||
setActiveItem(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent) => {
|
|
||||||
const { draggable, droppable } = event
|
|
||||||
if (draggable && droppable) {
|
|
||||||
const currentFiles = local.file.opened().map((file) => file.path)
|
|
||||||
const fromIndex = currentFiles.indexOf(draggable.id.toString())
|
|
||||||
const toIndex = currentFiles.indexOf(droppable.id.toString())
|
|
||||||
if (fromIndex !== toIndex) {
|
|
||||||
local.file.move(draggable.id.toString(), toIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
setActiveItem(undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollDiffItem = (element: HTMLElement) => {
|
|
||||||
element.scrollIntoView({ block: "start", behavior: "instant" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDiffTriggerClick = (event: MouseEvent) => {
|
|
||||||
// disabling scroll to diff for now
|
|
||||||
return
|
|
||||||
const target = event.currentTarget as HTMLElement
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (target.getAttribute("aria-expanded") !== "true") return
|
|
||||||
const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
|
|
||||||
if (!item) return
|
|
||||||
scrollDiffItem(item)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePromptSubmit = async (parts: ContentPart[]) => {
|
|
||||||
const existingSession = local.session.active()
|
|
||||||
let session = existingSession
|
|
||||||
if (!session) {
|
|
||||||
const created = await sdk.client.session.create()
|
|
||||||
session = created.data ?? undefined
|
|
||||||
}
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
local.session.setActive(session.id)
|
|
||||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
|
||||||
|
|
||||||
const text = parts.map((part) => part.content).join("")
|
|
||||||
const attachments = parts.filter((part) => part.type === "file")
|
|
||||||
|
|
||||||
// const activeFile = local.context.active()
|
|
||||||
// if (activeFile) {
|
|
||||||
// registerAttachment(
|
|
||||||
// activeFile.path,
|
|
||||||
// activeFile.selection,
|
|
||||||
// activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
// for (const contextFile of local.context.all()) {
|
|
||||||
// registerAttachment(
|
|
||||||
// contextFile.path,
|
|
||||||
// contextFile.selection,
|
|
||||||
// formatAttachmentLabel(contextFile.path, contextFile.selection),
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
const attachmentParts = attachments.map((attachment) => {
|
|
||||||
const absolute = toAbsolutePath(attachment.path)
|
|
||||||
const query = attachment.selection
|
|
||||||
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
|
||||||
: ""
|
|
||||||
return {
|
|
||||||
type: "file" as const,
|
|
||||||
mime: "text/plain",
|
|
||||||
url: `file://${absolute}${query}`,
|
|
||||||
filename: getFilename(attachment.path),
|
|
||||||
source: {
|
|
||||||
type: "file" as const,
|
|
||||||
text: {
|
|
||||||
value: attachment.content,
|
|
||||||
start: attachment.start,
|
|
||||||
end: attachment.end,
|
|
||||||
},
|
|
||||||
path: absolute,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await sdk.client.session.prompt({
|
|
||||||
path: { id: session.id },
|
|
||||||
body: {
|
|
||||||
agent: local.agent.current()!.name,
|
|
||||||
model: {
|
|
||||||
modelID: local.model.current()!.id,
|
|
||||||
providerID: local.model.current()!.provider.id,
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
...attachmentParts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNewSession = () => {
|
|
||||||
local.session.setActive(undefined)
|
|
||||||
inputRef?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const TabVisual = (props: { file: LocalFile }): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-x-1.5">
|
|
||||||
<FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
|
|
||||||
<span
|
|
||||||
classList={{
|
|
||||||
"text-14-medium": true,
|
|
||||||
"text-primary": !!props.file.status?.status,
|
|
||||||
italic: !props.file.pinned,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.file.name}
|
|
||||||
</span>
|
|
||||||
<span class="hidden opacity-70">
|
|
||||||
<Switch>
|
|
||||||
<Match when={props.file.status?.status === "modified"}>
|
|
||||||
<span class="text-primary">M</span>
|
|
||||||
</Match>
|
|
||||||
<Match when={props.file.status?.status === "added"}>
|
|
||||||
<span class="text-success">A</span>
|
|
||||||
</Match>
|
|
||||||
<Match when={props.file.status?.status === "deleted"}>
|
|
||||||
<span class="text-error">D</span>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SortableTab = (props: {
|
|
||||||
file: LocalFile
|
|
||||||
onTabClick: (file: LocalFile) => void
|
|
||||||
onTabClose: (file: LocalFile) => void
|
|
||||||
}): JSX.Element => {
|
|
||||||
const sortable = createSortable(props.file.path)
|
|
||||||
|
|
||||||
return (
|
|
||||||
// @ts-ignore
|
|
||||||
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
|
||||||
<Tooltip value={props.file.path} placement="bottom" class="h-full">
|
|
||||||
<div class="relative h-full">
|
|
||||||
<Tabs.Trigger
|
|
||||||
value={props.file.path}
|
|
||||||
class="group/tab pl-3 pr-1"
|
|
||||||
onClick={() => props.onTabClick(props.file)}
|
|
||||||
>
|
|
||||||
<TabVisual file={props.file} />
|
|
||||||
<IconButton
|
|
||||||
icon="close"
|
|
||||||
class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100
|
|
||||||
group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle
|
|
||||||
hover:opacity-100 group-hover/tab:opacity-100"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => props.onTabClose(props.file)}
|
|
||||||
/>
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConstrainDragYAxis = (): JSX.Element => {
|
|
||||||
const context = useDragDropContext()
|
|
||||||
if (!context) return <></>
|
|
||||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
|
||||||
const transformer: Transformer = {
|
|
||||||
id: "constrain-y-axis",
|
|
||||||
order: 100,
|
|
||||||
callback: (transform) => ({ ...transform, y: 0 }),
|
|
||||||
}
|
|
||||||
onDragStart((event) => {
|
|
||||||
const id = getDraggableId(event)
|
|
||||||
if (!id) return
|
|
||||||
addTransformer("draggables", id, transformer)
|
|
||||||
})
|
|
||||||
onDragEnd((event) => {
|
|
||||||
const id = getDraggableId(event)
|
|
||||||
if (!id) return
|
|
||||||
removeTransformer("draggables", id, transformer.id)
|
|
||||||
})
|
|
||||||
return <></>
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDraggableId = (event: unknown): string | undefined => {
|
|
||||||
if (typeof event !== "object" || event === null) return undefined
|
|
||||||
if (!("draggable" in event)) return undefined
|
|
||||||
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
|
||||||
if (!draggable) return undefined
|
|
||||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="relative h-screen flex flex-col">
|
|
||||||
<header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
|
|
||||||
<main class="h-[calc(100vh-0rem)] flex">
|
|
||||||
<div class="w-70 shrink-0 bg-background-weak border-r border-border-weak-base flex flex-col items-start">
|
|
||||||
<div class="h-10 flex items-center self-stretch px-5 border-b border-border-weak-base">
|
|
||||||
<span class="text-14-regular overflow-hidden text-ellipsis">{getFilename(sync.data.path.directory)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-start gap-4 self-stretch flex-1 py-4 px-3">
|
|
||||||
<Button class="w-full" size="large" onClick={handleNewSession} icon="edit-small-2">
|
|
||||||
New Session
|
|
||||||
</Button>
|
|
||||||
<List
|
|
||||||
data={sync.data.session}
|
|
||||||
key={(x) => x.id}
|
|
||||||
current={local.session.active()}
|
|
||||||
onSelect={(s) => local.session.setActive(s?.id)}
|
|
||||||
onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)}
|
|
||||||
>
|
|
||||||
{(session) => {
|
|
||||||
const diffs = createMemo(() => session.summary?.diffs ?? [])
|
|
||||||
const filesChanged = createMemo(() => diffs().length)
|
|
||||||
const updated = DateTime.fromMillis(session.time.updated)
|
|
||||||
return (
|
|
||||||
<Tooltip placement="right" value={session.title}>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center self-stretch gap-6 justify-between">
|
|
||||||
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
|
||||||
{session.title}
|
|
||||||
</span>
|
|
||||||
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
|
||||||
{Math.abs(updated.diffNow().as("seconds")) < 60
|
|
||||||
? "Now"
|
|
||||||
: updated
|
|
||||||
.toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
|
|
||||||
?.replace(" ago", "")
|
|
||||||
?.replace(/ days?/, "d")
|
|
||||||
?.replace(" min.", "m")
|
|
||||||
?.replace(" hr.", "h")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center self-stretch">
|
|
||||||
<span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span>
|
|
||||||
<DiffChanges diff={diffs()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</List>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="relative bg-background-base w-full h-full overflow-x-hidden">
|
|
||||||
<DragDropProvider
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
collisionDetector={closestCenter}
|
|
||||||
>
|
|
||||||
<DragDropSensors />
|
|
||||||
<ConstrainDragYAxis />
|
|
||||||
<Tabs onChange={handleTabChange}>
|
|
||||||
<div class="sticky top-0 shrink-0 flex">
|
|
||||||
<Tabs.List>
|
|
||||||
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
|
|
||||||
<div>Chat</div>
|
|
||||||
<Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5">
|
|
||||||
<ProgressCircle percentage={local.session.context() ?? 0} />
|
|
||||||
<div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div>
|
|
||||||
</Tooltip>
|
|
||||||
</Tabs.Trigger>
|
|
||||||
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
|
||||||
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
|
|
||||||
<For each={local.file.opened()}>
|
|
||||||
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
|
|
||||||
</For>
|
|
||||||
</SortableProvider>
|
|
||||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
|
||||||
<IconButton
|
|
||||||
icon="plus-small"
|
|
||||||
variant="ghost"
|
|
||||||
iconSize="large"
|
|
||||||
onClick={() => setStore("fileSelectOpen", true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tabs.List>
|
|
||||||
<div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
|
|
||||||
<Show when={local.file.active() && local.file.active()!.content?.diff}>
|
|
||||||
{(() => {
|
|
||||||
const activeFile = local.file.active()!
|
|
||||||
const view = local.file.view(activeFile.path)
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<Show when={view !== "raw"}>
|
|
||||||
<div class="mr-1 flex items-center gap-1">
|
|
||||||
<Tooltip value="Previous change" placement="bottom">
|
|
||||||
<IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip value="Next change" placement="bottom">
|
|
||||||
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Tooltip value="Raw" placement="bottom">
|
|
||||||
<IconButton
|
|
||||||
icon="file-text"
|
|
||||||
variant="ghost"
|
|
||||||
classList={{
|
|
||||||
"text-text": view === "raw",
|
|
||||||
"text-text-muted/70": view !== "raw",
|
|
||||||
"bg-background-element": view === "raw",
|
|
||||||
}}
|
|
||||||
onClick={() => local.file.setView(activeFile.path, "raw")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip value="Unified diff" placement="bottom">
|
|
||||||
<IconButton
|
|
||||||
icon="checklist"
|
|
||||||
variant="ghost"
|
|
||||||
classList={{
|
|
||||||
"text-text": view === "diff-unified",
|
|
||||||
"text-text-muted/70": view !== "diff-unified",
|
|
||||||
"bg-background-element": view === "diff-unified",
|
|
||||||
}}
|
|
||||||
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip value="Split diff" placement="bottom">
|
|
||||||
<IconButton
|
|
||||||
icon="columns"
|
|
||||||
variant="ghost"
|
|
||||||
classList={{
|
|
||||||
"text-text": view === "diff-split",
|
|
||||||
"text-text-muted/70": view !== "diff-split",
|
|
||||||
"bg-background-element": view === "diff-split",
|
|
||||||
}}
|
|
||||||
onClick={() => local.file.setView(activeFile.path, "diff-split")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
|
|
||||||
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
|
|
||||||
<Show
|
|
||||||
when={local.session.active()}
|
|
||||||
fallback={
|
|
||||||
<div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
|
|
||||||
<div class="text-20-medium text-text-weaker">New session</div>
|
|
||||||
<div class="flex justify-center items-center gap-3">
|
|
||||||
<Icon name="folder" size="small" />
|
|
||||||
<div class="text-12-medium text-text-weak">
|
|
||||||
{getDirectory(sync.data.path.directory)}
|
|
||||||
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center items-center gap-3">
|
|
||||||
<Icon name="pencil-line" size="small" />
|
|
||||||
<div class="text-12-medium text-text-weak">
|
|
||||||
Last modified
|
|
||||||
<span class="text-text-strong">
|
|
||||||
{DateTime.fromMillis(sync.data.project.time.created).toRelative()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(activeSession) => (
|
|
||||||
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
|
||||||
<div class="flex-1 min-h-0">
|
|
||||||
<Show when={local.session.userMessages().length > 1}>
|
|
||||||
<ul
|
|
||||||
role="list"
|
|
||||||
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
|
|
||||||
>
|
|
||||||
<For each={local.session.userMessages()}>
|
|
||||||
{(message) => {
|
|
||||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
|
||||||
const working = createMemo(() => !message.summary?.title)
|
|
||||||
return (
|
|
||||||
<li class="group/li flex items-center self-stretch">
|
|
||||||
<button
|
|
||||||
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
|
|
||||||
onClick={() => local.session.setActiveMessage(message.id)}
|
|
||||||
>
|
|
||||||
<Switch>
|
|
||||||
<Match when={working()}>
|
|
||||||
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<DiffChanges diff={diffs()} variant="bars" />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
<div
|
|
||||||
data-active={local.session.activeMessage()?.id === message.id}
|
|
||||||
classList={{
|
|
||||||
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
|
||||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.summary?.title ?? local.session.getMessageText(message)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</ul>
|
|
||||||
</Show>
|
|
||||||
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
|
|
||||||
<For each={local.session.userMessages()}>
|
|
||||||
{(message) => {
|
|
||||||
const isActive = createMemo(() => local.session.activeMessage()?.id === message.id)
|
|
||||||
const [initialized, setInitialized] = createSignal(!!message.summary?.title)
|
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
|
||||||
const parts = createMemo(() => sync.data.part[message.id])
|
|
||||||
const title = createMemo(() => message.summary?.title)
|
|
||||||
const summary = createMemo(() => message.summary?.body)
|
|
||||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
|
||||||
const assistantMessages = createMemo(() => {
|
|
||||||
return sync.data.message[activeSession().id]?.filter(
|
|
||||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
|
||||||
) as AssistantMessageType[]
|
|
||||||
})
|
|
||||||
const hasToolPart = createMemo(() =>
|
|
||||||
assistantMessages()
|
|
||||||
?.flatMap((m) => sync.data.part[m.id])
|
|
||||||
.some((p) => p.type === "tool"),
|
|
||||||
)
|
|
||||||
const working = createMemo(() => !summary())
|
|
||||||
createEffect(() => {
|
|
||||||
setTimeout(() => setInitialized(!!title()), 10_000)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={isActive()}>
|
|
||||||
<div
|
|
||||||
data-message={message.id}
|
|
||||||
class="flex flex-col items-start self-stretch gap-8 pb-50"
|
|
||||||
>
|
|
||||||
{/* Title */}
|
|
||||||
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
|
|
||||||
<div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
|
|
||||||
<Show when={initialized()} fallback={<Typewriter as="h1" text={title()} />}>
|
|
||||||
<h1>{title()}</h1>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="-mt-8">
|
|
||||||
<Message message={message} parts={parts()} />
|
|
||||||
</div>
|
|
||||||
{/* Summary */}
|
|
||||||
<Show when={!working()}>
|
|
||||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
|
||||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
|
||||||
<h2 class="text-12-medium text-text-weak">
|
|
||||||
<Switch>
|
|
||||||
<Match when={diffs().length}>Summary</Match>
|
|
||||||
<Match when={true}>Response</Match>
|
|
||||||
</Switch>
|
|
||||||
</h2>
|
|
||||||
<Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show>
|
|
||||||
</div>
|
|
||||||
<Accordion class="w-full" multiple>
|
|
||||||
<For each={diffs()}>
|
|
||||||
{(diff) => (
|
|
||||||
<Accordion.Item value={diff.file}>
|
|
||||||
<Accordion.Header>
|
|
||||||
<Accordion.Trigger onClick={handleDiffTriggerClick}>
|
|
||||||
<div class="flex items-center justify-between w-full gap-5">
|
|
||||||
<div class="grow flex items-center gap-5 min-w-0">
|
|
||||||
<FileIcon
|
|
||||||
node={{ path: diff.file, type: "file" }}
|
|
||||||
class="shrink-0 size-4"
|
|
||||||
/>
|
|
||||||
<div class="flex grow min-w-0">
|
|
||||||
<Show when={diff.file.includes("/")}>
|
|
||||||
<span class="text-text-base truncate-start">
|
|
||||||
{getDirectory(diff.file)}‎
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<span class="text-text-strong shrink-0">
|
|
||||||
{getFilename(diff.file)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0 flex gap-4 items-center justify-end">
|
|
||||||
<DiffChanges diff={diff} />
|
|
||||||
<Icon name="chevron-grabber-vertical" size="small" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion.Trigger>
|
|
||||||
</Accordion.Header>
|
|
||||||
<Accordion.Content>
|
|
||||||
<Diff
|
|
||||||
before={{
|
|
||||||
name: diff.file!,
|
|
||||||
contents: diff.before!,
|
|
||||||
}}
|
|
||||||
after={{
|
|
||||||
name: diff.file!,
|
|
||||||
contents: diff.after!,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
{/* Response */}
|
|
||||||
<div class="w-full">
|
|
||||||
<Switch>
|
|
||||||
<Match when={working()}>
|
|
||||||
<MessageProgress assistantMessages={assistantMessages} />
|
|
||||||
</Match>
|
|
||||||
<Match when={!working() && hasToolPart()}>
|
|
||||||
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
|
|
||||||
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
|
||||||
<div class="flex items-center gap-1 self-stretch">
|
|
||||||
<div class="text-12-medium">
|
|
||||||
<Switch>
|
|
||||||
<Match when={expanded()}>Hide steps</Match>
|
|
||||||
<Match when={!expanded()}>Show steps</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
<Collapsible.Arrow />
|
|
||||||
</div>
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
<Collapsible.Content>
|
|
||||||
<div class="w-full flex flex-col items-start self-stretch gap-3">
|
|
||||||
<For each={assistantMessages()}>
|
|
||||||
{(assistantMessage) => {
|
|
||||||
const parts = createMemo(
|
|
||||||
() => sync.data.part[assistantMessage.id],
|
|
||||||
)
|
|
||||||
return <Message message={assistantMessage} parts={parts()} />
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
|
||||||
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
|
|
||||||
<For each={local.file.opened()}>
|
|
||||||
{(file) => (
|
|
||||||
<Tabs.Content value={file.path} class="select-text">
|
|
||||||
{(() => {
|
|
||||||
const view = local.file.view(file.path)
|
|
||||||
const showRaw = view === "raw" || !file.content?.diff
|
|
||||||
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
|
|
||||||
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
|
|
||||||
})()}
|
|
||||||
</Tabs.Content>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Tabs>
|
|
||||||
<DragOverlay>
|
|
||||||
{(() => {
|
|
||||||
const id = activeItem()
|
|
||||||
if (!id) return null
|
|
||||||
const draggedFile = local.file.node(id)
|
|
||||||
if (!draggedFile) return null
|
|
||||||
return (
|
|
||||||
<div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
|
|
||||||
<TabVisual file={draggedFile} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</DragOverlay>
|
|
||||||
</DragDropProvider>
|
|
||||||
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
|
|
||||||
<PromptInput
|
|
||||||
ref={(el) => {
|
|
||||||
inputRef = el
|
|
||||||
}}
|
|
||||||
onSubmit={handlePromptSubmit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
|
|
||||||
<FileTree path="" onFileClick={handleFileClick} />
|
|
||||||
</div>
|
|
||||||
<div class="hidden shrink-0 w-56 p-2">
|
|
||||||
<Show
|
|
||||||
when={local.file.changes().length}
|
|
||||||
fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
|
|
||||||
>
|
|
||||||
<ul class="">
|
|
||||||
<For each={local.file.changes()}>
|
|
||||||
{(path) => (
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
|
|
||||||
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
|
|
||||||
>
|
|
||||||
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
|
|
||||||
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
|
|
||||||
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
|
|
||||||
{getDirectory(path)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</ul>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<Show when={store.fileSelectOpen}>
|
|
||||||
<SelectDialog
|
|
||||||
defaultOpen
|
|
||||||
title="Select file"
|
|
||||||
placeholder="Search files"
|
|
||||||
emptyMessage="No files found"
|
|
||||||
items={local.file.search}
|
|
||||||
key={(x) => x}
|
|
||||||
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
|
||||||
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
|
|
||||||
>
|
|
||||||
{(i) => (
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"w-full flex items-center justify-between rounded-md": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
|
||||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
|
||||||
<div class="flex items-center text-14-regular">
|
|
||||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
|
||||||
{getDirectory(i)}
|
|
||||||
</span>
|
|
||||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SelectDialog>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Button, Tooltip, DiffChanges } from "@opencode-ai/ui"
|
||||||
|
import { createMemo, ParentProps } from "solid-js"
|
||||||
|
import { getFilename } from "@/utils"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { VList } from "virtua/solid"
|
||||||
|
import { A, useParams } from "@solidjs/router"
|
||||||
|
|
||||||
|
export default function Layout(props: ParentProps) {
|
||||||
|
const params = useParams()
|
||||||
|
const sync = useSync()
|
||||||
|
return (
|
||||||
|
<div class="relative h-screen flex flex-col">
|
||||||
|
<header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
|
||||||
|
<div class="h-[calc(100vh-0rem)] flex">
|
||||||
|
<div class="w-70 shrink-0 bg-background-weak border-r border-border-weak-base flex flex-col items-start">
|
||||||
|
<div class="h-10 flex items-center self-stretch px-5 border-b border-border-weak-base">
|
||||||
|
<span class="text-14-regular overflow-hidden text-ellipsis">{getFilename(sync.data.path.directory)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-start gap-4 self-stretch flex-1 py-4 px-3">
|
||||||
|
<A href="/session" class="w-full">
|
||||||
|
<Button class="w-full" size="large" icon="edit-small-2">
|
||||||
|
New Session
|
||||||
|
</Button>
|
||||||
|
</A>
|
||||||
|
<VList data={sync.data.session} class="no-scrollbar">
|
||||||
|
{(session) => {
|
||||||
|
const diffs = createMemo(() => session.summary?.diffs ?? [])
|
||||||
|
const filesChanged = createMemo(() => diffs().length)
|
||||||
|
const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
|
||||||
|
return (
|
||||||
|
<A
|
||||||
|
data-active={session.id === params.id}
|
||||||
|
href={`/session/${session.id}`}
|
||||||
|
class="group/session focus:outline-none"
|
||||||
|
>
|
||||||
|
<Tooltip placement="right" value={session.title}>
|
||||||
|
<div
|
||||||
|
class="w-full mb-1.5 px-3 py-1 rounded-md
|
||||||
|
group-data-[active=true]/session:bg-surface-raised-base-hover
|
||||||
|
group-hover/session:bg-surface-raised-base-hover
|
||||||
|
group-focus/session:bg-surface-raised-base-hover"
|
||||||
|
>
|
||||||
|
<div class="flex items-center self-stretch gap-6 justify-between">
|
||||||
|
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
||||||
|
{session.title}
|
||||||
|
</span>
|
||||||
|
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
||||||
|
{Math.abs(updated().diffNow().as("seconds")) < 60
|
||||||
|
? "Now"
|
||||||
|
: updated()
|
||||||
|
.toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
|
||||||
|
?.replace(" ago", "")
|
||||||
|
?.replace(/ days?/, "d")
|
||||||
|
?.replace(" min.", "m")
|
||||||
|
?.replace(" hr.", "h")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center self-stretch">
|
||||||
|
<span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span>
|
||||||
|
<DiffChanges diff={diffs()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</A>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</VList>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main class="size-full overflow-x-hidden">{props.children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Show, type ParentProps } from "solid-js"
|
||||||
|
import { SessionProvider } from "@/context/session"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
|
||||||
|
export default function Layout(props: ParentProps) {
|
||||||
|
const params = useParams()
|
||||||
|
return (
|
||||||
|
<Show when={params.id || true} keyed>
|
||||||
|
<SessionProvider sessionId={params.id}>{props.children}</SessionProvider>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
import {
|
||||||
|
SelectDialog,
|
||||||
|
IconButton,
|
||||||
|
Tabs,
|
||||||
|
Icon,
|
||||||
|
Accordion,
|
||||||
|
Diff,
|
||||||
|
Collapsible,
|
||||||
|
DiffChanges,
|
||||||
|
Message,
|
||||||
|
Typewriter,
|
||||||
|
Card,
|
||||||
|
Code,
|
||||||
|
Tooltip,
|
||||||
|
ProgressCircle,
|
||||||
|
} from "@opencode-ai/ui"
|
||||||
|
import { FileIcon } from "@/ui"
|
||||||
|
import { MessageProgress } from "@/components/message-progress"
|
||||||
|
import {
|
||||||
|
For,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
Show,
|
||||||
|
Match,
|
||||||
|
Switch,
|
||||||
|
createSignal,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
} from "solid-js"
|
||||||
|
import { useLocal, type LocalFile } from "@/context/local"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { getDirectory, getFilename } from "@/utils"
|
||||||
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
import {
|
||||||
|
DragDropProvider,
|
||||||
|
DragDropSensors,
|
||||||
|
DragOverlay,
|
||||||
|
SortableProvider,
|
||||||
|
closestCenter,
|
||||||
|
createSortable,
|
||||||
|
useDragDropContext,
|
||||||
|
} from "@thisbeyond/solid-dnd"
|
||||||
|
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||||
|
import { Markdown } from "@opencode-ai/ui"
|
||||||
|
import { Spinner } from "@/components/spinner"
|
||||||
|
import { useSession } from "@/context/session"
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const local = useLocal()
|
||||||
|
const sync = useSync()
|
||||||
|
const session = useSession()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
clickTimer: undefined as number | undefined,
|
||||||
|
fileSelectOpen: false,
|
||||||
|
activeDraggable: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
let inputRef!: HTMLDivElement
|
||||||
|
let messageScrollElement!: HTMLDivElement
|
||||||
|
|
||||||
|
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
|
||||||
|
event.preventDefault()
|
||||||
|
setStore("fileSelectOpen", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const focused = document.activeElement === inputRef
|
||||||
|
if (focused) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
inputRef?.blur()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (local.file.active()) {
|
||||||
|
// const active = local.file.active()!
|
||||||
|
// if (event.key === "Enter" && active.selection) {
|
||||||
|
// local.context.add({
|
||||||
|
// type: "file",
|
||||||
|
// path: active.path,
|
||||||
|
// selection: { ...active.selection },
|
||||||
|
// })
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (event.getModifierState(MOD)) {
|
||||||
|
// if (event.key.toLowerCase() === "a") {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// if (event.key.toLowerCase() === "c") {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||||
|
inputRef?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetClickTimer = () => {
|
||||||
|
if (!store.clickTimer) return
|
||||||
|
clearTimeout(store.clickTimer)
|
||||||
|
setStore("clickTimer", undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startClickTimer = () => {
|
||||||
|
const newClickTimer = setTimeout(() => {
|
||||||
|
setStore("clickTimer", undefined)
|
||||||
|
}, 300)
|
||||||
|
setStore("clickTimer", newClickTimer as unknown as number)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTabClick = async (tab: string) => {
|
||||||
|
if (store.clickTimer) {
|
||||||
|
resetClickTimer()
|
||||||
|
// local.file.update(file.path, { ...file, pinned: true })
|
||||||
|
} else {
|
||||||
|
if (tab.startsWith("file://")) {
|
||||||
|
local.file.open(tab.replace("file://", ""))
|
||||||
|
}
|
||||||
|
startClickTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragStart = (event: unknown) => {
|
||||||
|
const id = getDraggableId(event)
|
||||||
|
if (!id) return
|
||||||
|
setStore("activeDraggable", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
const { draggable, droppable } = event
|
||||||
|
if (draggable && droppable) {
|
||||||
|
const currentTabs = session.layout.tabs.opened
|
||||||
|
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
|
||||||
|
const toIndex = currentTabs?.indexOf(droppable.id.toString())
|
||||||
|
if (fromIndex !== toIndex && toIndex !== undefined) {
|
||||||
|
session.layout.moveTab(draggable.id.toString(), toIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setStore("activeDraggable", undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileVisual = (props: { file: LocalFile }): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-x-1.5">
|
||||||
|
<FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
|
||||||
|
<span
|
||||||
|
classList={{
|
||||||
|
"text-14-medium": true,
|
||||||
|
"text-primary": !!props.file.status?.status,
|
||||||
|
italic: !props.file.pinned,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.file.name}
|
||||||
|
</span>
|
||||||
|
<span class="hidden opacity-70">
|
||||||
|
<Switch>
|
||||||
|
<Match when={props.file.status?.status === "modified"}>
|
||||||
|
<span class="text-primary">M</span>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.file.status?.status === "added"}>
|
||||||
|
<span class="text-success">A</span>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.file.status?.status === "deleted"}>
|
||||||
|
<span class="text-error">D</span>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableTab = (props: {
|
||||||
|
tab: string
|
||||||
|
onTabClick: (tab: string) => void
|
||||||
|
onTabClose: (tab: string) => void
|
||||||
|
}): JSX.Element => {
|
||||||
|
const sortable = createSortable(props.tab)
|
||||||
|
|
||||||
|
const [file] = createResource(
|
||||||
|
() => props.tab,
|
||||||
|
async (tab) => {
|
||||||
|
if (tab.startsWith("file://")) {
|
||||||
|
return local.file.node(tab.replace("file://", ""))
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||||
|
<div class="relative h-full">
|
||||||
|
<Tabs.Trigger value={props.tab} class="group/tab pl-3 pr-1" onClick={() => props.onTabClick(props.tab)}>
|
||||||
|
<Switch>
|
||||||
|
<Match when={file()}>{(f) => <FileVisual file={f()} />}</Match>
|
||||||
|
</Switch>
|
||||||
|
<IconButton
|
||||||
|
icon="close"
|
||||||
|
class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100
|
||||||
|
group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle
|
||||||
|
hover:opacity-100 group-hover/tab:opacity-100"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => props.onTabClose(props.tab)}
|
||||||
|
/>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConstrainDragYAxis = (): JSX.Element => {
|
||||||
|
const context = useDragDropContext()
|
||||||
|
if (!context) return <></>
|
||||||
|
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||||
|
const transformer: Transformer = {
|
||||||
|
id: "constrain-y-axis",
|
||||||
|
order: 100,
|
||||||
|
callback: (transform) => ({ ...transform, y: 0 }),
|
||||||
|
}
|
||||||
|
onDragStart((event) => {
|
||||||
|
const id = getDraggableId(event)
|
||||||
|
if (!id) return
|
||||||
|
addTransformer("draggables", id, transformer)
|
||||||
|
})
|
||||||
|
onDragEnd((event) => {
|
||||||
|
const id = getDraggableId(event)
|
||||||
|
if (!id) return
|
||||||
|
removeTransformer("draggables", id, transformer.id)
|
||||||
|
})
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDraggableId = (event: unknown): string | undefined => {
|
||||||
|
if (typeof event !== "object" || event === null) return undefined
|
||||||
|
if (!("draggable" in event)) return undefined
|
||||||
|
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
||||||
|
if (!draggable) return undefined
|
||||||
|
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="relative bg-background-base size-full overflow-x-hidden">
|
||||||
|
<DragDropProvider
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
collisionDetector={closestCenter}
|
||||||
|
>
|
||||||
|
<DragDropSensors />
|
||||||
|
<ConstrainDragYAxis />
|
||||||
|
<Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
|
||||||
|
<div class="sticky top-0 shrink-0 flex">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
|
||||||
|
<div>Chat</div>
|
||||||
|
<Tooltip
|
||||||
|
value={`${new Intl.NumberFormat("en-US", {
|
||||||
|
notation: "compact",
|
||||||
|
compactDisplay: "short",
|
||||||
|
}).format(session.usage.tokens() ?? 0)} Tokens`}
|
||||||
|
class="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<ProgressCircle percentage={session.usage.context() ?? 0} />
|
||||||
|
<div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
||||||
|
<SortableProvider ids={session.layout.tabs.opened ?? []}>
|
||||||
|
<For each={session.layout.tabs.opened ?? []}>
|
||||||
|
{(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />}
|
||||||
|
</For>
|
||||||
|
</SortableProvider>
|
||||||
|
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||||
|
<IconButton
|
||||||
|
icon="plus-small"
|
||||||
|
variant="ghost"
|
||||||
|
iconSize="large"
|
||||||
|
onClick={() => setStore("fileSelectOpen", true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tabs.List>
|
||||||
|
</div>
|
||||||
|
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
|
||||||
|
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
|
||||||
|
<Show
|
||||||
|
when={session.id}
|
||||||
|
fallback={
|
||||||
|
<div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
|
||||||
|
<div class="text-20-medium text-text-weaker">New session</div>
|
||||||
|
<div class="flex justify-center items-center gap-3">
|
||||||
|
<Icon name="folder" size="small" />
|
||||||
|
<div class="text-12-medium text-text-weak">
|
||||||
|
{getDirectory(sync.data.path.directory)}
|
||||||
|
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center items-center gap-3">
|
||||||
|
<Icon name="pencil-line" size="small" />
|
||||||
|
<div class="text-12-medium text-text-weak">
|
||||||
|
Last modified
|
||||||
|
<span class="text-text-strong">
|
||||||
|
{DateTime.fromMillis(sync.data.project.time.created).toRelative()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(_) => {
|
||||||
|
return (
|
||||||
|
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
||||||
|
<div class="flex-1 min-h-0">
|
||||||
|
<Show when={session.messages.user().length > 1}>
|
||||||
|
<ul
|
||||||
|
role="list"
|
||||||
|
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
|
||||||
|
>
|
||||||
|
<For each={session.messages.user()}>
|
||||||
|
{(message) => {
|
||||||
|
const assistantMessages = createMemo(() => {
|
||||||
|
if (!session.id) return []
|
||||||
|
return sync.data.message[session.id]?.filter(
|
||||||
|
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||||
|
) as AssistantMessageType[]
|
||||||
|
})
|
||||||
|
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||||
|
const working = createMemo(() => !message.summary?.body && !error())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li class="group/li flex items-center self-stretch">
|
||||||
|
<button
|
||||||
|
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
|
||||||
|
onClick={() => session.messages.setActive(message.id)}
|
||||||
|
>
|
||||||
|
<Switch>
|
||||||
|
<Match when={working()}>
|
||||||
|
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<DiffChanges diff={message.summary?.diffs ?? []} variant="bars" />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
<div
|
||||||
|
data-active={session.messages.active()?.id === message.id}
|
||||||
|
classList={{
|
||||||
|
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
||||||
|
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={message.summary?.title} fallback="New message">
|
||||||
|
{message.summary?.title}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</Show>
|
||||||
|
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
|
||||||
|
<For each={session.messages.user()}>
|
||||||
|
{(message) => {
|
||||||
|
const isActive = createMemo(() => session.messages.active()?.id === message.id)
|
||||||
|
const [titled, setTitled] = createSignal(!!message.summary?.title)
|
||||||
|
const assistantMessages = createMemo(() => {
|
||||||
|
if (!session.id) return []
|
||||||
|
return sync.data.message[session.id]?.filter(
|
||||||
|
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||||
|
) as AssistantMessageType[]
|
||||||
|
})
|
||||||
|
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||||
|
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
|
||||||
|
const [detailsExpanded, setDetailsExpanded] = createSignal(false)
|
||||||
|
const parts = createMemo(() => sync.data.part[message.id])
|
||||||
|
const hasToolPart = createMemo(() =>
|
||||||
|
assistantMessages()
|
||||||
|
?.flatMap((m) => sync.data.part[m.id])
|
||||||
|
.some((p) => p?.type === "tool"),
|
||||||
|
)
|
||||||
|
const working = createMemo(() => !message.summary?.body && !error())
|
||||||
|
|
||||||
|
// allowing time for the animations to finish
|
||||||
|
createEffect(() => {
|
||||||
|
const title = message.summary?.title
|
||||||
|
setTimeout(() => setTitled(!!title), 10_000)
|
||||||
|
})
|
||||||
|
createEffect(() => {
|
||||||
|
const summary = message.summary?.body
|
||||||
|
const complete = !!summary || !!error()
|
||||||
|
setTimeout(() => setCompleted(complete), 1200)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={isActive()}>
|
||||||
|
<div
|
||||||
|
data-message={message.id}
|
||||||
|
class="flex flex-col items-start self-stretch gap-8 pb-50"
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
|
||||||
|
<div class="w-full text-14-medium text-text-strong">
|
||||||
|
<Show
|
||||||
|
when={titled()}
|
||||||
|
fallback={
|
||||||
|
<Typewriter
|
||||||
|
as="h1"
|
||||||
|
text={message.summary?.title}
|
||||||
|
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
|
||||||
|
{message.summary?.title}
|
||||||
|
</h1>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="-mt-8">
|
||||||
|
<Message message={message} parts={parts()} />
|
||||||
|
</div>
|
||||||
|
{/* Summary */}
|
||||||
|
<Show when={completed()}>
|
||||||
|
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||||
|
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||||
|
<h2 class="text-12-medium text-text-weak">
|
||||||
|
<Switch>
|
||||||
|
<Match when={message.summary?.diffs?.length}>Summary</Match>
|
||||||
|
<Match when={true}>Response</Match>
|
||||||
|
</Switch>
|
||||||
|
</h2>
|
||||||
|
<Show when={message.summary?.body}>
|
||||||
|
{(summary) => (
|
||||||
|
<Markdown
|
||||||
|
classList={{
|
||||||
|
"text-14-regular": !!message.summary?.diffs?.length,
|
||||||
|
"[&>*]:fade-up-text": !message.summary?.diffs?.length,
|
||||||
|
}}
|
||||||
|
text={summary()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Accordion class="w-full" multiple>
|
||||||
|
<For each={message.summary?.diffs ?? []}>
|
||||||
|
{(diff) => (
|
||||||
|
<Accordion.Item value={diff.file}>
|
||||||
|
<Accordion.Header>
|
||||||
|
<Accordion.Trigger>
|
||||||
|
<div class="flex items-center justify-between w-full gap-5">
|
||||||
|
<div class="grow flex items-center gap-5 min-w-0">
|
||||||
|
<FileIcon
|
||||||
|
node={{ path: diff.file, type: "file" }}
|
||||||
|
class="shrink-0 size-4"
|
||||||
|
/>
|
||||||
|
<div class="flex grow min-w-0">
|
||||||
|
<Show when={diff.file.includes("/")}>
|
||||||
|
<span class="text-text-base truncate-start">
|
||||||
|
{getDirectory(diff.file)}‎
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span class="text-text-strong shrink-0">
|
||||||
|
{getFilename(diff.file)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 flex gap-4 items-center justify-end">
|
||||||
|
<DiffChanges diff={diff} />
|
||||||
|
<Icon name="chevron-grabber-vertical" size="small" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
<Accordion.Content>
|
||||||
|
<Diff
|
||||||
|
before={{
|
||||||
|
name: diff.file!,
|
||||||
|
contents: diff.before!,
|
||||||
|
}}
|
||||||
|
after={{
|
||||||
|
name: diff.file!,
|
||||||
|
contents: diff.after!,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={error() && !detailsExpanded()}>
|
||||||
|
<Card variant="error" class="text-text-on-critical-base">
|
||||||
|
{error()?.data?.message as string}
|
||||||
|
</Card>
|
||||||
|
</Show>
|
||||||
|
{/* Response */}
|
||||||
|
<div class="w-full">
|
||||||
|
<Switch>
|
||||||
|
<Match when={!completed()}>
|
||||||
|
<MessageProgress assistantMessages={assistantMessages} done={!working()} />
|
||||||
|
</Match>
|
||||||
|
<Match when={completed() && hasToolPart()}>
|
||||||
|
<Collapsible
|
||||||
|
variant="ghost"
|
||||||
|
open={detailsExpanded()}
|
||||||
|
onOpenChange={setDetailsExpanded}
|
||||||
|
>
|
||||||
|
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
||||||
|
<div class="flex items-center gap-1 self-stretch">
|
||||||
|
<div class="text-12-medium">
|
||||||
|
<Switch>
|
||||||
|
<Match when={detailsExpanded()}>Hide details</Match>
|
||||||
|
<Match when={!detailsExpanded()}>Show details</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
<Collapsible.Arrow />
|
||||||
|
</div>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<div class="w-full flex flex-col items-start self-stretch gap-3">
|
||||||
|
<For each={assistantMessages()}>
|
||||||
|
{(assistantMessage) => {
|
||||||
|
const parts = createMemo(() => sync.data.part[assistantMessage.id])
|
||||||
|
return <Message message={assistantMessage} parts={parts()} />
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={error()}>
|
||||||
|
<Card variant="error" class="text-text-on-critical-base">
|
||||||
|
{error()?.data?.message as string}
|
||||||
|
</Card>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
|
||||||
|
<For each={session.layout.tabs.opened}>
|
||||||
|
{(tab) => {
|
||||||
|
const [file] = createResource(
|
||||||
|
() => tab,
|
||||||
|
async (tab) => {
|
||||||
|
if (tab.startsWith("file://")) {
|
||||||
|
return local.file.node(tab.replace("file://", ""))
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Tabs.Content value={tab} class="select-text">
|
||||||
|
<Switch>
|
||||||
|
<Match when={file()}>
|
||||||
|
{(f) => (
|
||||||
|
<Code
|
||||||
|
file={{ name: f().path, contents: f().content?.content ?? "" }}
|
||||||
|
overflow="scroll"
|
||||||
|
class="pt-3 pb-40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Tabs.Content>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Tabs>
|
||||||
|
<DragOverlay>
|
||||||
|
<Show when={store.activeDraggable}>
|
||||||
|
{(draggedFile) => {
|
||||||
|
const [file] = createResource(
|
||||||
|
() => draggedFile(),
|
||||||
|
async (tab) => {
|
||||||
|
if (tab.startsWith("file://")) {
|
||||||
|
return local.file.node(tab.replace("file://", ""))
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
|
||||||
|
<Show when={file()}>{(f) => <FileVisual file={f()} />}</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
|
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
|
||||||
|
<PromptInput
|
||||||
|
ref={(el) => {
|
||||||
|
inputRef = el
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
|
||||||
|
{/* <FileTree path="" onFileClick={ handleTabClick} /> */}
|
||||||
|
</div>
|
||||||
|
<div class="hidden shrink-0 w-56 p-2">
|
||||||
|
<Show when={local.file.changes().length} fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}>
|
||||||
|
<ul class="">
|
||||||
|
<For each={local.file.changes()}>
|
||||||
|
{(path) => (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
|
||||||
|
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
|
||||||
|
>
|
||||||
|
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
|
||||||
|
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
|
||||||
|
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
|
||||||
|
{getDirectory(path)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={store.fileSelectOpen}>
|
||||||
|
<SelectDialog
|
||||||
|
defaultOpen
|
||||||
|
title="Select file"
|
||||||
|
placeholder="Search files"
|
||||||
|
emptyMessage="No files found"
|
||||||
|
items={local.file.searchFiles}
|
||||||
|
key={(x) => x}
|
||||||
|
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
||||||
|
onSelect={(x) => (x ? session.layout.openTab("file://" + x) : undefined)}
|
||||||
|
>
|
||||||
|
{(i) => (
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"w-full flex items-center justify-between rounded-md": true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-x-2 grow min-w-0">
|
||||||
|
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||||
|
<div class="flex items-center text-14-regular">
|
||||||
|
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||||
|
{getDirectory(i)}
|
||||||
|
</span>
|
||||||
|
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SelectDialog>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
Vendored
+9
-1
@@ -6,6 +6,10 @@
|
|||||||
import "sst"
|
import "sst"
|
||||||
declare module "sst" {
|
declare module "sst" {
|
||||||
export interface Resource {
|
export interface Resource {
|
||||||
|
"ADMIN_SECRET": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"AUTH_API_URL": {
|
"AUTH_API_URL": {
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
"value": string
|
"value": string
|
||||||
@@ -74,7 +78,11 @@ declare module "sst" {
|
|||||||
"type": "sst.cloudflare.Astro"
|
"type": "sst.cloudflare.Astro"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_MODELS": {
|
"ZEN_MODELS1": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"ZEN_MODELS2": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.0.2",
|
"version": "1.0.27",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.4.9",
|
"@agentclientprotocol/sdk": "0.5.1",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
@@ -54,11 +54,11 @@
|
|||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opentui/core": "0.0.0-20251031-fc297165",
|
"@opentui/core": "0.1.33",
|
||||||
"@opentui/solid": "0.0.0-20251031-fc297165",
|
"@opentui/solid": "0.1.33",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
|
||||||
"@pierre/precision-diffs": "catalog:",
|
"@pierre/precision-diffs": "catalog:",
|
||||||
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"ai": "catalog:",
|
"ai": "catalog:",
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
"hono": "catalog:",
|
"hono": "catalog:",
|
||||||
"hono-openapi": "1.0.7",
|
"hono-openapi": "1.1.1",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"minimatch": "10.0.3",
|
"minimatch": "10.0.3",
|
||||||
|
|||||||
@@ -203,5 +203,14 @@ export default {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
filetype: "clojure",
|
||||||
|
wasm: "https://github.com/sogaiu/tree-sitter-clojure/releases/download/v0.0.13/tree-sitter-clojure.wasm",
|
||||||
|
queries: {
|
||||||
|
highlights: [
|
||||||
|
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ for (const [os, arch] of targets) {
|
|||||||
|
|
||||||
const opentui = `@opentui/core-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}`
|
const opentui = `@opentui/core-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}`
|
||||||
await $`mkdir -p ../../node_modules/${opentui}`
|
await $`mkdir -p ../../node_modules/${opentui}`
|
||||||
await $`npm pack ${opentui}@${pkg.dependencies["@opentui/core"]}`.cwd(path.join(dir, "../../node_modules"))
|
await $`npm pack ${opentui}@${pkg.dependencies["@opentui/core"]}`.cwd(
|
||||||
|
path.join(dir, "../../node_modules"),
|
||||||
|
)
|
||||||
await $`tar -xf ../../node_modules/${opentui.replace("@opentui/", "opentui-")}-*.tgz -C ../../node_modules/${opentui} --strip-components=1`
|
await $`tar -xf ../../node_modules/${opentui.replace("@opentui/", "opentui-")}-*.tgz -C ../../node_modules/${opentui} --strip-components=1`
|
||||||
|
|
||||||
const watcher = `@parcel/watcher-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}${os === "linux" ? "-glibc" : ""}`
|
const watcher = `@parcel/watcher-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}${os === "linux" ? "-glibc" : ""}`
|
||||||
@@ -49,7 +51,11 @@ for (const [os, arch] of targets) {
|
|||||||
await $`npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
|
await $`npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
|
||||||
await $`tar -xf ../../node_modules/${watcher.replace("@parcel/", "parcel-")}-*.tgz -C ../../node_modules/${watcher} --strip-components=1`
|
await $`tar -xf ../../node_modules/${watcher.replace("@parcel/", "parcel-")}-*.tgz -C ../../node_modules/${watcher} --strip-components=1`
|
||||||
|
|
||||||
const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"))
|
const parserWorker = fs.realpathSync(
|
||||||
|
path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"),
|
||||||
|
)
|
||||||
|
const workerPath = "./src/cli/cmd/tui/worker.ts"
|
||||||
|
|
||||||
await Bun.build({
|
await Bun.build({
|
||||||
conditions: ["browser"],
|
conditions: ["browser"],
|
||||||
tsconfig: "./tsconfig.json",
|
tsconfig: "./tsconfig.json",
|
||||||
@@ -61,10 +67,11 @@ for (const [os, arch] of targets) {
|
|||||||
execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`],
|
execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`],
|
||||||
windows: {},
|
windows: {},
|
||||||
},
|
},
|
||||||
entrypoints: ["./src/index.ts", parserWorker, "./src/cli/cmd/tui/worker.ts"],
|
entrypoints: ["./src/index.ts", parserWorker, workerPath],
|
||||||
define: {
|
define: {
|
||||||
OPENCODE_VERSION: `'${Script.version}'`,
|
OPENCODE_VERSION: `'${Script.version}'`,
|
||||||
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker),
|
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker),
|
||||||
|
OPENCODE_WORKER_PATH: workerPath,
|
||||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -125,15 +125,13 @@ if (!Script.preview) {
|
|||||||
"build() {",
|
"build() {",
|
||||||
` cd "opencode-\${pkgver}"`,
|
` cd "opencode-\${pkgver}"`,
|
||||||
` bun install`,
|
` bun install`,
|
||||||
" cd packages/tui",
|
" cd ./packages/opencode",
|
||||||
` CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=\${pkgver}" -o tui cmd/opencode/main.go`,
|
` OPENCODE_CHANNEL=latest OPENCODE_VERSION=${pkgver} bun run ./script/build.ts --single`,
|
||||||
" cd ../opencode",
|
|
||||||
` bun build --define OPENCODE_TUI_PATH="'$(realpath ../tui/tui)'" --define OPENCODE_VERSION="'\${pkgver}'" --compile --target=bun-linux-x64 --outfile=opencode ./src/index.ts`,
|
|
||||||
"}",
|
"}",
|
||||||
"",
|
"",
|
||||||
"package() {",
|
"package() {",
|
||||||
` cd "opencode-\${pkgver}/packages/opencode"`,
|
` cd "opencode-\${pkgver}/packages/opencode"`,
|
||||||
' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"',
|
' install -Dm755 $(find dist/*/bin/opencode) "${pkgdir}/usr/bin/opencode"',
|
||||||
"}",
|
"}",
|
||||||
"",
|
"",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
|
RequestError,
|
||||||
type Agent as ACPAgent,
|
type Agent as ACPAgent,
|
||||||
type AgentSideConnection,
|
type AgentSideConnection,
|
||||||
type AuthenticateRequest,
|
type AuthenticateRequest,
|
||||||
|
type AuthMethod,
|
||||||
type CancelNotification,
|
type CancelNotification,
|
||||||
type InitializeRequest,
|
type InitializeRequest,
|
||||||
|
type InitializeResponse,
|
||||||
type LoadSessionRequest,
|
type LoadSessionRequest,
|
||||||
type NewSessionRequest,
|
type NewSessionRequest,
|
||||||
type PermissionOption,
|
type PermissionOption,
|
||||||
@@ -28,13 +31,12 @@ import { Storage } from "@/storage/storage"
|
|||||||
import { Command } from "@/command"
|
import { Command } from "@/command"
|
||||||
import { Agent as Agents } from "@/agent/agent"
|
import { Agent as Agents } from "@/agent/agent"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Session } from "@/session"
|
|
||||||
import { Identifier } from "@/id/id"
|
|
||||||
import { SessionCompaction } from "@/session/compaction"
|
import { SessionCompaction } from "@/session/compaction"
|
||||||
import type { Config } from "@/config/config"
|
import type { Config } from "@/config/config"
|
||||||
import { MCP } from "@/mcp"
|
import { MCP } from "@/mcp"
|
||||||
import { Todo } from "@/session/todo"
|
import { Todo } from "@/session/todo"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import { LoadAPIKeyError } from "ai"
|
||||||
|
|
||||||
export namespace ACP {
|
export namespace ACP {
|
||||||
const log = Log.create({ service: "acp-agent" })
|
const log = Log.create({ service: "acp-agent" })
|
||||||
@@ -89,7 +91,11 @@ export namespace ACP {
|
|||||||
})
|
})
|
||||||
if (!res) return
|
if (!res) return
|
||||||
if (res.outcome.outcome !== "selected") {
|
if (res.outcome.outcome !== "selected") {
|
||||||
Permission.respond({ sessionID: permission.sessionID, permissionID: permission.id, response: "reject" })
|
Permission.respond({
|
||||||
|
sessionID: permission.sessionID,
|
||||||
|
permissionID: permission.id,
|
||||||
|
response: "reject",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Permission.respond({
|
Permission.respond({
|
||||||
@@ -111,9 +117,11 @@ export namespace ACP {
|
|||||||
const acpSession = this.sessionManager.get(part.sessionID)
|
const acpSession = this.sessionManager.get(part.sessionID)
|
||||||
if (!acpSession) return
|
if (!acpSession) return
|
||||||
|
|
||||||
const message = await Storage.read<MessageV2.Info>(["message", part.sessionID, part.messageID]).catch(
|
const message = await Storage.read<MessageV2.Info>([
|
||||||
() => undefined,
|
"message",
|
||||||
)
|
part.sessionID,
|
||||||
|
part.messageID,
|
||||||
|
]).catch(() => undefined)
|
||||||
if (!message || message.role !== "assistant") return
|
if (!message || message.role !== "assistant") return
|
||||||
|
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
@@ -192,7 +200,9 @@ export namespace ACP {
|
|||||||
sessionUpdate: "plan",
|
sessionUpdate: "plan",
|
||||||
entries: parsedTodos.data.map((todo) => {
|
entries: parsedTodos.data.map((todo) => {
|
||||||
const status: PlanEntry["status"] =
|
const status: PlanEntry["status"] =
|
||||||
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
|
todo.status === "cancelled"
|
||||||
|
? "completed"
|
||||||
|
: (todo.status as PlanEntry["status"])
|
||||||
return {
|
return {
|
||||||
priority: "medium",
|
priority: "medium",
|
||||||
status,
|
status,
|
||||||
@@ -296,9 +306,26 @@ export namespace ACP {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(params: InitializeRequest) {
|
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
|
||||||
log.info("initialize", { protocolVersion: params.protocolVersion })
|
log.info("initialize", { protocolVersion: params.protocolVersion })
|
||||||
|
|
||||||
|
const authMethod: AuthMethod = {
|
||||||
|
description: "Run `opencode auth login` in the terminal",
|
||||||
|
name: "Login with opencode",
|
||||||
|
id: "opencode-login",
|
||||||
|
}
|
||||||
|
|
||||||
|
// If client supports terminal-auth capability, use that instead.
|
||||||
|
if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
|
||||||
|
authMethod._meta = {
|
||||||
|
"terminal-auth": {
|
||||||
|
command: "opencode",
|
||||||
|
args: ["auth", "login"],
|
||||||
|
label: "OpenCode Login",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
agentCapabilities: {
|
agentCapabilities: {
|
||||||
@@ -319,10 +346,9 @@ export namespace ACP {
|
|||||||
id: "opencode-login",
|
id: "opencode-login",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
_meta: {
|
agentInfo: {
|
||||||
opencode: {
|
name: "OpenCode",
|
||||||
version: Installation.VERSION,
|
version: Installation.VERSION,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,21 +358,31 @@ export namespace ACP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async newSession(params: NewSessionRequest) {
|
async newSession(params: NewSessionRequest) {
|
||||||
const model = await defaultModel(this.config)
|
try {
|
||||||
const session = await this.sessionManager.create(params.cwd, params.mcpServers, model)
|
const model = await defaultModel(this.config)
|
||||||
|
const session = await this.sessionManager.create(params.cwd, params.mcpServers, model)
|
||||||
|
|
||||||
log.info("creating_session", { mcpServers: params.mcpServers.length })
|
log.info("creating_session", { mcpServers: params.mcpServers.length })
|
||||||
const load = await this.loadSession({
|
const load = await this.loadSession({
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
mcpServers: params.mcpServers,
|
mcpServers: params.mcpServers,
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
models: load.models,
|
models: load.models,
|
||||||
modes: load.modes,
|
modes: load.modes,
|
||||||
_meta: {},
|
_meta: {},
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const error = MessageV2.fromError(e, {
|
||||||
|
providerID: this.config.defaultModel?.providerID ?? "unknown",
|
||||||
|
})
|
||||||
|
if (LoadAPIKeyError.isInstance(error)) {
|
||||||
|
throw RequestError.authRequired()
|
||||||
|
}
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,27 +411,12 @@ export namespace ACP {
|
|||||||
description: command.description ?? "",
|
description: command.description ?? "",
|
||||||
}))
|
}))
|
||||||
const names = new Set(availableCommands.map((c) => c.name))
|
const names = new Set(availableCommands.map((c) => c.name))
|
||||||
if (!names.has("init"))
|
|
||||||
availableCommands.push({
|
|
||||||
name: "init",
|
|
||||||
description: "create/update a AGENTS.md",
|
|
||||||
})
|
|
||||||
if (!names.has("compact"))
|
if (!names.has("compact"))
|
||||||
availableCommands.push({
|
availableCommands.push({
|
||||||
name: "compact",
|
name: "compact",
|
||||||
description: "compact the session",
|
description: "compact the session",
|
||||||
})
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.connection.sessionUpdate({
|
|
||||||
sessionId,
|
|
||||||
update: {
|
|
||||||
sessionUpdate: "available_commands_update",
|
|
||||||
availableCommands,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
const availableModes = (await Agents.list())
|
const availableModes = (await Agents.list())
|
||||||
.filter((agent) => agent.mode !== "subagent")
|
.filter((agent) => agent.mode !== "subagent")
|
||||||
.map((agent) => ({
|
.map((agent) => ({
|
||||||
@@ -404,7 +425,8 @@ export namespace ACP {
|
|||||||
description: agent.description,
|
description: agent.description,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
|
const currentModeId =
|
||||||
|
availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
|
||||||
|
|
||||||
const mcpServers: Record<string, Config.Mcp> = {}
|
const mcpServers: Record<string, Config.Mcp> = {}
|
||||||
for (const server of params.mcpServers) {
|
for (const server of params.mcpServers) {
|
||||||
@@ -435,6 +457,16 @@ export namespace ACP {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connection.sessionUpdate({
|
||||||
|
sessionId,
|
||||||
|
update: {
|
||||||
|
sessionUpdate: "available_commands_update",
|
||||||
|
availableCommands,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
models: {
|
models: {
|
||||||
@@ -585,14 +617,6 @@ export namespace ACP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (cmd.name) {
|
switch (cmd.name) {
|
||||||
case "init":
|
|
||||||
await Session.initialize({
|
|
||||||
sessionID,
|
|
||||||
messageID: Identifier.ascending("message"),
|
|
||||||
providerID: model.providerID,
|
|
||||||
modelID: model.modelID,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "compact":
|
case "compact":
|
||||||
await SessionCompaction.run({
|
await SessionCompaction.run({
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -665,7 +689,9 @@ export namespace ACP {
|
|||||||
|
|
||||||
function parseUri(
|
function parseUri(
|
||||||
uri: string,
|
uri: string,
|
||||||
): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
|
):
|
||||||
|
| { type: "file"; url: string; filename: string; mime: string }
|
||||||
|
| { type: "text"; text: string } {
|
||||||
try {
|
try {
|
||||||
if (uri.startsWith("file://")) {
|
if (uri.startsWith("file://")) {
|
||||||
const path = uri.slice(7)
|
const path = uri.slice(7)
|
||||||
|
|||||||
@@ -6,13 +6,18 @@ import type { ACPSessionState } from "./types"
|
|||||||
export class ACPSessionManager {
|
export class ACPSessionManager {
|
||||||
private sessions = new Map<string, ACPSessionState>()
|
private sessions = new Map<string, ACPSessionState>()
|
||||||
|
|
||||||
async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise<ACPSessionState> {
|
async create(
|
||||||
|
cwd: string,
|
||||||
|
mcpServers: McpServer[],
|
||||||
|
model?: ACPSessionState["model"],
|
||||||
|
): Promise<ACPSessionState> {
|
||||||
const session = await Session.create({ title: `ACP Session ${crypto.randomUUID()}` })
|
const session = await Session.create({ title: `ACP Session ${crypto.randomUUID()}` })
|
||||||
const sessionId = session.id
|
const sessionId = session.id
|
||||||
const resolvedModel = model ?? (await Provider.defaultModel())
|
const resolvedModel = model ?? (await Provider.defaultModel())
|
||||||
|
|
||||||
const state: ACPSessionState = {
|
const state: ACPSessionState = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
|
parentId: session.parentID,
|
||||||
cwd,
|
cwd,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { McpServer } from "@agentclientprotocol/sdk"
|
|||||||
|
|
||||||
export interface ACPSessionState {
|
export interface ACPSessionState {
|
||||||
id: string
|
id: string
|
||||||
|
parentId?: string
|
||||||
cwd: string
|
cwd: string
|
||||||
mcpServers: McpServer[]
|
mcpServers: McpServer[]
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export namespace Auth {
|
|||||||
refresh: z.string(),
|
refresh: z.string(),
|
||||||
access: z.string(),
|
access: z.string(),
|
||||||
expires: z.number(),
|
expires: z.number(),
|
||||||
|
enterpriseUrl: z.string().optional(),
|
||||||
})
|
})
|
||||||
.meta({ ref: "OAuth" })
|
.meta({ ref: "OAuth" })
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { readableStreamToText } from "bun"
|
|||||||
export namespace BunProc {
|
export namespace BunProc {
|
||||||
const log = Log.create({ service: "bun" })
|
const log = Log.create({ service: "bun" })
|
||||||
|
|
||||||
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
|
export async function run(
|
||||||
|
cmd: string[],
|
||||||
|
options?: Bun.SpawnOptions.OptionsObject<any, any, any>,
|
||||||
|
) {
|
||||||
log.info("running", {
|
log.info("running", {
|
||||||
cmd: [which(), ...cmd],
|
cmd: [which(), ...cmd],
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -102,178 +102,223 @@ export const AuthLoginCommand = cmd({
|
|||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await ModelsDev.refresh().catch(() => {})
|
await ModelsDev.refresh().catch(() => {})
|
||||||
const providers = await ModelsDev.get()
|
const providers = await ModelsDev.get()
|
||||||
const priority: Record<string, number> = {
|
const priority: Record<string, number> = {
|
||||||
opencode: 0,
|
opencode: 0,
|
||||||
anthropic: 1,
|
anthropic: 1,
|
||||||
"github-copilot": 2,
|
"github-copilot": 2,
|
||||||
openai: 3,
|
openai: 3,
|
||||||
google: 4,
|
google: 4,
|
||||||
openrouter: 5,
|
openrouter: 5,
|
||||||
vercel: 6,
|
vercel: 6,
|
||||||
}
|
}
|
||||||
let provider = await prompts.autocomplete({
|
let provider = await prompts.autocomplete({
|
||||||
message: "Select provider",
|
message: "Select provider",
|
||||||
maxItems: 8,
|
maxItems: 8,
|
||||||
options: [
|
options: [
|
||||||
...pipe(
|
...pipe(
|
||||||
providers,
|
providers,
|
||||||
values(),
|
values(),
|
||||||
sortBy(
|
sortBy(
|
||||||
(x) => priority[x.id] ?? 99,
|
(x) => priority[x.id] ?? 99,
|
||||||
(x) => x.name ?? x.id,
|
(x) => x.name ?? x.id,
|
||||||
),
|
|
||||||
map((x) => ({
|
|
||||||
label: x.name,
|
|
||||||
value: x.id,
|
|
||||||
hint: priority[x.id] <= 1 ? "recommended" : undefined,
|
|
||||||
})),
|
|
||||||
),
|
),
|
||||||
{
|
map((x) => ({
|
||||||
value: "other",
|
label: x.name,
|
||||||
label: "Other",
|
value: x.id,
|
||||||
},
|
hint: priority[x.id] <= 1 ? "recommended" : undefined,
|
||||||
],
|
})),
|
||||||
})
|
),
|
||||||
|
{
|
||||||
|
value: "other",
|
||||||
|
label: "Other",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||||
|
|
||||||
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
|
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
|
||||||
if (plugin && plugin.auth) {
|
if (plugin && plugin.auth) {
|
||||||
let index = 0
|
let index = 0
|
||||||
if (plugin.auth.methods.length > 1) {
|
if (plugin.auth.methods.length > 1) {
|
||||||
const method = await prompts.select({
|
const method = await prompts.select({
|
||||||
message: "Login method",
|
message: "Login method",
|
||||||
options: [
|
options: [
|
||||||
...plugin.auth.methods.map((x, index) => ({
|
...plugin.auth.methods.map((x, index) => ({
|
||||||
label: x.label,
|
label: x.label,
|
||||||
value: index.toString(),
|
value: index.toString(),
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
||||||
index = parseInt(method)
|
index = parseInt(method)
|
||||||
}
|
}
|
||||||
const method = plugin.auth.methods[index]
|
const method = plugin.auth.methods[index]
|
||||||
if (method.type === "oauth") {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
const authorize = await method.authorize()
|
|
||||||
|
|
||||||
if (authorize.url) {
|
// Handle prompts for all auth types
|
||||||
prompts.log.info("Go to: " + authorize.url)
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
const inputs: Record<string, string> = {}
|
||||||
|
if (method.prompts) {
|
||||||
|
for (const prompt of method.prompts) {
|
||||||
|
if (prompt.condition && !prompt.condition(inputs)) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if (prompt.type === "select") {
|
||||||
if (authorize.method === "auto") {
|
const value = await prompts.select({
|
||||||
if (authorize.instructions) {
|
message: prompt.message,
|
||||||
prompts.log.info(authorize.instructions)
|
options: prompt.options,
|
||||||
}
|
|
||||||
const spinner = prompts.spinner()
|
|
||||||
spinner.start("Waiting for authorization...")
|
|
||||||
const result = await authorize.callback()
|
|
||||||
if (result.type === "failed") {
|
|
||||||
spinner.stop("Failed to authorize", 1)
|
|
||||||
}
|
|
||||||
if (result.type === "success") {
|
|
||||||
if ("refresh" in result) {
|
|
||||||
await Auth.set(provider, {
|
|
||||||
type: "oauth",
|
|
||||||
refresh: result.refresh,
|
|
||||||
access: result.access,
|
|
||||||
expires: result.expires,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if ("key" in result) {
|
|
||||||
await Auth.set(provider, {
|
|
||||||
type: "api",
|
|
||||||
key: result.key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
spinner.stop("Login successful")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authorize.method === "code") {
|
|
||||||
const code = await prompts.text({
|
|
||||||
message: "Paste the authorization code here: ",
|
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
if (prompts.isCancel(value)) throw new UI.CancelledError()
|
||||||
const result = await authorize.callback(code)
|
inputs[prompt.key] = value
|
||||||
if (result.type === "failed") {
|
} else {
|
||||||
prompts.log.error("Failed to authorize")
|
const value = await prompts.text({
|
||||||
|
message: prompt.message,
|
||||||
|
placeholder: prompt.placeholder,
|
||||||
|
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(value)) throw new UI.CancelledError()
|
||||||
|
inputs[prompt.key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method.type === "oauth") {
|
||||||
|
const authorize = await method.authorize(inputs)
|
||||||
|
|
||||||
|
if (authorize.url) {
|
||||||
|
prompts.log.info("Go to: " + authorize.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorize.method === "auto") {
|
||||||
|
if (authorize.instructions) {
|
||||||
|
prompts.log.info(authorize.instructions)
|
||||||
|
}
|
||||||
|
const spinner = prompts.spinner()
|
||||||
|
spinner.start("Waiting for authorization...")
|
||||||
|
const result = await authorize.callback()
|
||||||
|
if (result.type === "failed") {
|
||||||
|
spinner.stop("Failed to authorize", 1)
|
||||||
|
}
|
||||||
|
if (result.type === "success") {
|
||||||
|
const saveProvider = result.provider ?? provider
|
||||||
|
if ("refresh" in result) {
|
||||||
|
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||||
|
await Auth.set(saveProvider, {
|
||||||
|
type: "oauth",
|
||||||
|
refresh,
|
||||||
|
access,
|
||||||
|
expires,
|
||||||
|
...extraFields,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (result.type === "success") {
|
if ("key" in result) {
|
||||||
if ("refresh" in result) {
|
await Auth.set(saveProvider, {
|
||||||
await Auth.set(provider, {
|
type: "api",
|
||||||
type: "oauth",
|
key: result.key,
|
||||||
refresh: result.refresh,
|
})
|
||||||
access: result.access,
|
|
||||||
expires: result.expires,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if ("key" in result) {
|
|
||||||
await Auth.set(provider, {
|
|
||||||
type: "api",
|
|
||||||
key: result.key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
prompts.log.success("Login successful")
|
|
||||||
}
|
}
|
||||||
|
spinner.stop("Login successful")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorize.method === "code") {
|
||||||
|
const code = await prompts.text({
|
||||||
|
message: "Paste the authorization code here: ",
|
||||||
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
||||||
|
const result = await authorize.callback(code)
|
||||||
|
if (result.type === "failed") {
|
||||||
|
prompts.log.error("Failed to authorize")
|
||||||
|
}
|
||||||
|
if (result.type === "success") {
|
||||||
|
const saveProvider = result.provider ?? provider
|
||||||
|
if ("refresh" in result) {
|
||||||
|
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||||
|
await Auth.set(saveProvider, {
|
||||||
|
type: "oauth",
|
||||||
|
refresh,
|
||||||
|
access,
|
||||||
|
expires,
|
||||||
|
...extraFields,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ("key" in result) {
|
||||||
|
await Auth.set(saveProvider, {
|
||||||
|
type: "api",
|
||||||
|
key: result.key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
prompts.log.success("Login successful")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts.outro("Done")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method.type === "api") {
|
||||||
|
if (method.authorize) {
|
||||||
|
const result = await method.authorize(inputs)
|
||||||
|
if (result.type === "failed") {
|
||||||
|
prompts.log.error("Failed to authorize")
|
||||||
|
}
|
||||||
|
if (result.type === "success") {
|
||||||
|
const saveProvider = result.provider ?? provider
|
||||||
|
await Auth.set(saveProvider, {
|
||||||
|
type: "api",
|
||||||
|
key: result.key,
|
||||||
|
})
|
||||||
|
prompts.log.success("Login successful")
|
||||||
}
|
}
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === "other") {
|
if (provider === "other") {
|
||||||
provider = await prompts.text({
|
provider = await prompts.text({
|
||||||
message: "Enter provider id",
|
message: "Enter provider id",
|
||||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||||
})
|
|
||||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
|
||||||
provider = provider.replace(/^@ai-sdk\//, "")
|
|
||||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
|
||||||
prompts.log.warn(
|
|
||||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "amazon-bedrock") {
|
|
||||||
prompts.log.info(
|
|
||||||
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
|
|
||||||
)
|
|
||||||
prompts.outro("Done")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "google-vertex") {
|
|
||||||
prompts.log.info(
|
|
||||||
"Google Cloud Vertex AI uses Application Default Credentials. Set GOOGLE_APPLICATION_CREDENTIALS or run 'gcloud auth application-default login'. Optionally set GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION (or VERTEX_LOCATION)",
|
|
||||||
)
|
|
||||||
prompts.outro("Done")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "opencode") {
|
|
||||||
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "vercel") {
|
|
||||||
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = await prompts.password({
|
|
||||||
message: "Enter your API key",
|
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
|
||||||
await Auth.set(provider, {
|
|
||||||
type: "api",
|
|
||||||
key,
|
|
||||||
})
|
})
|
||||||
|
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||||
|
provider = provider.replace(/^@ai-sdk\//, "")
|
||||||
|
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||||
|
prompts.log.warn(
|
||||||
|
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "amazon-bedrock") {
|
||||||
|
prompts.log.info(
|
||||||
|
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
|
||||||
|
)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "opencode") {
|
||||||
|
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "vercel") {
|
||||||
|
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await prompts.password({
|
||||||
|
message: "Enter your API key",
|
||||||
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||||
|
await Auth.set(provider, {
|
||||||
|
type: "api",
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
|
||||||
|
prompts.outro("Done")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ export const GenerateCommand = {
|
|||||||
command: "generate",
|
command: "generate",
|
||||||
handler: async () => {
|
handler: async () => {
|
||||||
const specs = await Server.openapi()
|
const specs = await Server.openapi()
|
||||||
process.stdout.write(JSON.stringify(specs, null, 2))
|
const json = JSON.stringify(specs, null, 2)
|
||||||
|
|
||||||
|
// Wait for stdout to finish writing before process.exit() is called
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
process.stdout.write(json, (err) => {
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
},
|
},
|
||||||
} satisfies CommandModule
|
} satisfies CommandModule
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import type { Argv } from "yargs"
|
import type { Argv } from "yargs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Bus } from "../../bus"
|
|
||||||
import { Provider } from "../../provider/provider"
|
|
||||||
import { Session } from "../../session"
|
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { Flag } from "../../flag/flag"
|
import { Flag } from "../../flag/flag"
|
||||||
import { Config } from "../../config/config"
|
|
||||||
import { bootstrap } from "../bootstrap"
|
import { bootstrap } from "../bootstrap"
|
||||||
import { MessageV2 } from "../../session/message-v2"
|
|
||||||
import { Identifier } from "../../id/id"
|
|
||||||
import { Agent } from "../../agent/agent"
|
|
||||||
import { Command } from "../../command"
|
import { Command } from "../../command"
|
||||||
import { SessionPrompt } from "../../session/prompt"
|
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Permission } from "@/permission"
|
|
||||||
import { select } from "@clack/prompts"
|
import { select } from "@clack/prompts"
|
||||||
|
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"
|
||||||
|
import { Server } from "../../server/server"
|
||||||
|
|
||||||
const TOOL: Record<string, [string, string]> = {
|
const TOOL: Record<string, [string, string]> = {
|
||||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||||
@@ -84,11 +77,19 @@ export const RunCommand = cmd({
|
|||||||
type: "string",
|
type: "string",
|
||||||
describe: "title for the session (uses truncated prompt if no value provided)",
|
describe: "title for the session (uses truncated prompt if no value provided)",
|
||||||
})
|
})
|
||||||
|
.option("attach", {
|
||||||
|
type: "string",
|
||||||
|
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)",
|
||||||
|
})
|
||||||
},
|
},
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
let message = args.message.join(" ")
|
let message = args.message.join(" ")
|
||||||
|
|
||||||
let fileParts: any[] = []
|
const fileParts: any[] = []
|
||||||
if (args.file) {
|
if (args.file) {
|
||||||
const files = Array.isArray(args.file) ? args.file : [args.file]
|
const files = Array.isArray(args.file) ? args.file : [args.file]
|
||||||
|
|
||||||
@@ -124,79 +125,8 @@ export const RunCommand = cmd({
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
await bootstrap(process.cwd(), async () => {
|
const execute = async (sdk: OpencodeClient, sessionID: string) => {
|
||||||
if (args.command) {
|
const printEvent = (color: string, type: string, title: string) => {
|
||||||
const exists = await Command.get(args.command)
|
|
||||||
if (!exists) {
|
|
||||||
UI.error(`Command "${args.command}" not found`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const session = await (async () => {
|
|
||||||
if (args.continue) {
|
|
||||||
const it = Session.list()
|
|
||||||
try {
|
|
||||||
for await (const s of it) {
|
|
||||||
if (s.parentID === undefined) {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
} finally {
|
|
||||||
await it.return()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.session) return Session.get(args.session)
|
|
||||||
|
|
||||||
const title = (() => {
|
|
||||||
if (args.title !== undefined) {
|
|
||||||
if (args.title === "") {
|
|
||||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
|
||||||
}
|
|
||||||
return args.title
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
|
|
||||||
return Session.create({
|
|
||||||
title,
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
UI.error("Session not found")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = await Config.get()
|
|
||||||
if (cfg.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
|
||||||
try {
|
|
||||||
await Session.share(session.id)
|
|
||||||
UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + session.id.slice(-8))
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes("disabled")) {
|
|
||||||
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const agent = await (async () => {
|
|
||||||
if (args.agent) return Agent.get(args.agent)
|
|
||||||
const build = Agent.get("build")
|
|
||||||
if (build) return build
|
|
||||||
return Agent.list().then((x) => x[0])
|
|
||||||
})()
|
|
||||||
|
|
||||||
const { providerID, modelID } = await (async () => {
|
|
||||||
if (args.model) return Provider.parseModel(args.model)
|
|
||||||
if (agent.model) return agent.model
|
|
||||||
return await Provider.defaultModel()
|
|
||||||
})()
|
|
||||||
|
|
||||||
function printEvent(color: string, type: string, title: string) {
|
|
||||||
UI.println(
|
UI.println(
|
||||||
color + `|`,
|
color + `|`,
|
||||||
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
|
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
|
||||||
@@ -205,135 +135,218 @@ export const RunCommand = cmd({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function outputJsonEvent(type: string, data: any) {
|
const outputJsonEvent = (type: string, data: any) => {
|
||||||
if (args.format === "json") {
|
if (args.format === "json") {
|
||||||
const jsonEvent = {
|
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
||||||
type,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
sessionID: session?.id,
|
|
||||||
...data,
|
|
||||||
}
|
|
||||||
process.stdout.write(JSON.stringify(jsonEvent) + EOL)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageID = Identifier.ascending("message")
|
const events = await sdk.event.subscribe()
|
||||||
|
|
||||||
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
|
||||||
if (evt.properties.part.sessionID !== session.id) return
|
|
||||||
if (evt.properties.part.messageID === messageID) return
|
|
||||||
const part = evt.properties.part
|
|
||||||
|
|
||||||
if (part.type === "tool" && part.state.status === "completed") {
|
|
||||||
if (outputJsonEvent("tool_use", { part })) return
|
|
||||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
|
||||||
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 && part.state.output.trim()) {
|
|
||||||
UI.println()
|
|
||||||
UI.println(part.state.output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "step-start") {
|
|
||||||
if (outputJsonEvent("step_start", { part })) return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "step-finish") {
|
|
||||||
if (outputJsonEvent("step_finish", { part })) return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "text") {
|
|
||||||
const text = part.text
|
|
||||||
const isPiped = !process.stdout.isTTY
|
|
||||||
|
|
||||||
if (part.time?.end) {
|
|
||||||
if (outputJsonEvent("text", { part })) return
|
|
||||||
if (!isPiped) UI.println()
|
|
||||||
process.stdout.write((isPiped ? text : UI.markdown(text)) + EOL)
|
|
||||||
if (!isPiped) UI.println()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let errorMsg: string | undefined
|
let errorMsg: string | undefined
|
||||||
Bus.subscribe(Session.Event.Error, async (evt) => {
|
|
||||||
const { sessionID, error } = evt.properties
|
|
||||||
if (sessionID !== session.id || !error) return
|
|
||||||
let err = String(error.name)
|
|
||||||
|
|
||||||
if ("data" in error && error.data && "message" in error.data) {
|
const eventProcessor = (async () => {
|
||||||
err = error.data.message
|
for await (const event of events.stream) {
|
||||||
|
if (event.type === "message.part.updated") {
|
||||||
|
const part = event.properties.part
|
||||||
|
if (part.sessionID !== sessionID) continue
|
||||||
|
|
||||||
|
if (part.type === "tool" && part.state.status === "completed") {
|
||||||
|
if (outputJsonEvent("tool_use", { part })) continue
|
||||||
|
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
||||||
|
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 (outputJsonEvent("step_start", { part })) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "step-finish") {
|
||||||
|
if (outputJsonEvent("step_finish", { part })) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "text" && part.time?.end) {
|
||||||
|
if (outputJsonEvent("text", { part })) continue
|
||||||
|
const isPiped = !process.stdout.isTTY
|
||||||
|
if (!isPiped) UI.println()
|
||||||
|
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
|
||||||
|
if (!isPiped) UI.println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.error") {
|
||||||
|
const props = event.properties
|
||||||
|
if (props.sessionID !== sessionID || !props.error) continue
|
||||||
|
let err = String(props.error.name)
|
||||||
|
if ("data" in props.error && props.error.data && "message" in props.error.data) {
|
||||||
|
err = String(props.error.data.message)
|
||||||
|
}
|
||||||
|
errorMsg = errorMsg ? errorMsg + EOL + err : err
|
||||||
|
if (outputJsonEvent("error", { error: props.error })) continue
|
||||||
|
UI.error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.idle" && event.properties.sessionID === sessionID) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "permission.updated") {
|
||||||
|
const permission = event.properties
|
||||||
|
if (permission.sessionID !== sessionID) continue
|
||||||
|
const result = await select({
|
||||||
|
message: `Permission required to run: ${permission.title}`,
|
||||||
|
options: [
|
||||||
|
{ value: "once", label: "Allow once" },
|
||||||
|
{ value: "always", label: "Always allow" },
|
||||||
|
{ value: "reject", label: "Reject" },
|
||||||
|
],
|
||||||
|
initialValue: "once",
|
||||||
|
}).catch(() => "reject")
|
||||||
|
const response = (result.toString().includes("cancel") ? "reject" : result) as
|
||||||
|
| "once"
|
||||||
|
| "always"
|
||||||
|
| "reject"
|
||||||
|
await sdk.postSessionIdPermissionsPermissionId({
|
||||||
|
path: { id: sessionID, permissionID: permission.id },
|
||||||
|
body: { response },
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
errorMsg = errorMsg ? errorMsg + EOL + err : err
|
})()
|
||||||
|
|
||||||
if (outputJsonEvent("error", { error })) return
|
if (args.command) {
|
||||||
UI.error(err)
|
await sdk.session.command({
|
||||||
})
|
path: { id: sessionID },
|
||||||
|
body: {
|
||||||
Bus.subscribe(Permission.Event.Updated, async (evt) => {
|
agent: args.agent || "build",
|
||||||
const permission = evt.properties
|
model: args.model,
|
||||||
const message = `Permission required to run: ${permission.title}`
|
|
||||||
|
|
||||||
const result = await select({
|
|
||||||
message,
|
|
||||||
options: [
|
|
||||||
{ value: "once", label: "Allow once" },
|
|
||||||
{ value: "always", label: "Always allow" },
|
|
||||||
{ value: "reject", label: "Reject" },
|
|
||||||
],
|
|
||||||
initialValue: "once",
|
|
||||||
}).catch(() => "reject")
|
|
||||||
const response = (result.toString().includes("cancel") ? "reject" : result) as
|
|
||||||
| "once"
|
|
||||||
| "always"
|
|
||||||
| "reject"
|
|
||||||
|
|
||||||
Permission.respond({
|
|
||||||
sessionID: session.id,
|
|
||||||
permissionID: permission.id,
|
|
||||||
response,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await (async () => {
|
|
||||||
if (args.command) {
|
|
||||||
return await SessionPrompt.command({
|
|
||||||
messageID,
|
|
||||||
sessionID: session.id,
|
|
||||||
agent: agent.name,
|
|
||||||
model: providerID + "/" + modelID,
|
|
||||||
command: args.command,
|
command: args.command,
|
||||||
arguments: message,
|
arguments: message,
|
||||||
})
|
|
||||||
}
|
|
||||||
return await SessionPrompt.prompt({
|
|
||||||
sessionID: session.id,
|
|
||||||
messageID,
|
|
||||||
model: {
|
|
||||||
providerID,
|
|
||||||
modelID,
|
|
||||||
},
|
},
|
||||||
agent: agent.name,
|
|
||||||
parts: [
|
|
||||||
...fileParts,
|
|
||||||
{
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "text",
|
|
||||||
text: message,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
})()
|
} else {
|
||||||
|
const modelParam = args.model
|
||||||
|
? (() => {
|
||||||
|
const [providerID, modelID] = args.model.split("/")
|
||||||
|
return { providerID, modelID }
|
||||||
|
})()
|
||||||
|
: undefined
|
||||||
|
await sdk.session.prompt({
|
||||||
|
path: { id: sessionID },
|
||||||
|
body: {
|
||||||
|
agent: args.agent || "build",
|
||||||
|
model: modelParam,
|
||||||
|
parts: [...fileParts, { type: "text", text: message }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await eventProcessor
|
||||||
if (errorMsg) process.exit(1)
|
if (errorMsg) process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.attach) {
|
||||||
|
const sdk = createOpencodeClient({ baseUrl: args.attach })
|
||||||
|
|
||||||
|
const sessionID = await (async () => {
|
||||||
|
if (args.continue) {
|
||||||
|
const result = await sdk.session.list()
|
||||||
|
return result.data?.find((s) => !s.parentID)?.id
|
||||||
|
}
|
||||||
|
if (args.session) return args.session
|
||||||
|
|
||||||
|
const title =
|
||||||
|
args.title !== undefined
|
||||||
|
? args.title === ""
|
||||||
|
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||||
|
: args.title
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const result = await sdk.session.create({ body: title ? { title } : {} })
|
||||||
|
return result.data?.id
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (!sessionID) {
|
||||||
|
UI.error("Session not found")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfgResult = await sdk.config.get()
|
||||||
|
if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
|
||||||
|
const shareResult = await sdk.session.share({ path: { id: sessionID } }).catch((error) => {
|
||||||
|
if (error instanceof Error && error.message.includes("disabled")) {
|
||||||
|
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
||||||
|
}
|
||||||
|
return { error }
|
||||||
|
})
|
||||||
|
if (!shareResult.error) {
|
||||||
|
UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await execute(sdk, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
await bootstrap(process.cwd(), async () => {
|
||||||
|
const server = Server.listen({ port: args.port ?? 0, hostname: "127.0.0.1" })
|
||||||
|
const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` })
|
||||||
|
|
||||||
|
if (args.command) {
|
||||||
|
const exists = await Command.get(args.command)
|
||||||
|
if (!exists) {
|
||||||
|
server.stop()
|
||||||
|
UI.error(`Command "${args.command}" not found`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionID = await (async () => {
|
||||||
|
if (args.continue) {
|
||||||
|
const result = await sdk.session.list()
|
||||||
|
return result.data?.find((s) => !s.parentID)?.id
|
||||||
|
}
|
||||||
|
if (args.session) return args.session
|
||||||
|
|
||||||
|
const title =
|
||||||
|
args.title !== undefined
|
||||||
|
? args.title === ""
|
||||||
|
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||||
|
: args.title
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const result = await sdk.session.create({ body: title ? { title } : {} })
|
||||||
|
return result.data?.id
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (!sessionID) {
|
||||||
|
server.stop()
|
||||||
|
UI.error("Session not found")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfgResult = await sdk.config.get()
|
||||||
|
if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
|
||||||
|
const shareResult = await sdk.session.share({ path: { id: sessionID } }).catch((error) => {
|
||||||
|
if (error instanceof Error && error.message.includes("disabled")) {
|
||||||
|
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
||||||
|
}
|
||||||
|
return { error }
|
||||||
|
})
|
||||||
|
if (!shareResult.error) {
|
||||||
|
UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await execute(sdk, sessionID)
|
||||||
|
server.stop()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export const ServeCommand = cmd({
|
|||||||
default: 0,
|
default: 0,
|
||||||
})
|
})
|
||||||
.option("hostname", {
|
.option("hostname", {
|
||||||
alias: ["h"],
|
|
||||||
type: "string",
|
type: "string",
|
||||||
describe: "hostname to listen on",
|
describe: "hostname to listen on",
|
||||||
default: "127.0.0.1",
|
default: "127.0.0.1",
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
import type { Argv } from "yargs"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
|
import { Session } from "../../session"
|
||||||
|
import { bootstrap } from "../bootstrap"
|
||||||
|
import { Storage } from "../../storage/storage"
|
||||||
|
import { Project } from "../../project/project"
|
||||||
|
import { Instance } from "../../project/instance"
|
||||||
|
|
||||||
interface SessionStats {
|
interface SessionStats {
|
||||||
totalSessions: number
|
totalSessions: number
|
||||||
@@ -24,10 +30,186 @@ interface SessionStats {
|
|||||||
|
|
||||||
export const StatsCommand = cmd({
|
export const StatsCommand = cmd({
|
||||||
command: "stats",
|
command: "stats",
|
||||||
handler: async () => {},
|
describe: "show token usage and cost statistics",
|
||||||
|
builder: (yargs: Argv) => {
|
||||||
|
return yargs
|
||||||
|
.option("days", {
|
||||||
|
describe: "show stats for the last N days (default: all time)",
|
||||||
|
type: "number",
|
||||||
|
})
|
||||||
|
.option("tools", {
|
||||||
|
describe: "number of tools to show (default: all)",
|
||||||
|
type: "number",
|
||||||
|
})
|
||||||
|
.option("project", {
|
||||||
|
describe: "filter by project (default: all projects, empty string: current project)",
|
||||||
|
type: "string",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handler: async (args) => {
|
||||||
|
await bootstrap(process.cwd(), async () => {
|
||||||
|
const stats = await aggregateSessionStats(args.days, args.project)
|
||||||
|
displayStats(stats, args.tools)
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export function displayStats(stats: SessionStats) {
|
async function getCurrentProject(): Promise<Project.Info> {
|
||||||
|
return Instance.project
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllSessions(): Promise<Session.Info[]> {
|
||||||
|
const sessions: Session.Info[] = []
|
||||||
|
|
||||||
|
const projectKeys = await Storage.list(["project"])
|
||||||
|
const projects = await Promise.all(projectKeys.map((key) => Storage.read<Project.Info>(key)))
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
if (!project) continue
|
||||||
|
|
||||||
|
const sessionKeys = await Storage.list(["session", project.id])
|
||||||
|
const projectSessions = await Promise.all(
|
||||||
|
sessionKeys.map((key) => Storage.read<Session.Info>(key)),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const session of projectSessions) {
|
||||||
|
if (session) {
|
||||||
|
sessions.push(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
|
||||||
|
const sessions = await getAllSessions()
|
||||||
|
const DAYS_IN_SECOND = 24 * 60 * 60 * 1000
|
||||||
|
const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
|
||||||
|
|
||||||
|
let filteredSessions = days
|
||||||
|
? sessions.filter((session) => session.time.updated >= cutoffTime)
|
||||||
|
: sessions
|
||||||
|
|
||||||
|
if (projectFilter !== undefined) {
|
||||||
|
if (projectFilter === "") {
|
||||||
|
const currentProject = await getCurrentProject()
|
||||||
|
filteredSessions = filteredSessions.filter(
|
||||||
|
(session) => session.projectID === currentProject.id,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats: SessionStats = {
|
||||||
|
totalSessions: filteredSessions.length,
|
||||||
|
totalMessages: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
totalTokens: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toolUsage: {},
|
||||||
|
dateRange: {
|
||||||
|
earliest: Date.now(),
|
||||||
|
latest: Date.now(),
|
||||||
|
},
|
||||||
|
days: 0,
|
||||||
|
costPerDay: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredSessions.length > 1000) {
|
||||||
|
console.log(
|
||||||
|
`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredSessions.length === 0) {
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
let earliestTime = Date.now()
|
||||||
|
let latestTime = 0
|
||||||
|
|
||||||
|
const BATCH_SIZE = 20
|
||||||
|
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
|
||||||
|
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
|
||||||
|
|
||||||
|
const batchPromises = batch.map(async (session) => {
|
||||||
|
const messages = await Session.messages(session.id)
|
||||||
|
|
||||||
|
let sessionCost = 0
|
||||||
|
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||||
|
let sessionToolUsage: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.info.role === "assistant") {
|
||||||
|
sessionCost += message.info.cost || 0
|
||||||
|
|
||||||
|
if (message.info.tokens) {
|
||||||
|
sessionTokens.input += message.info.tokens.input || 0
|
||||||
|
sessionTokens.output += message.info.tokens.output || 0
|
||||||
|
sessionTokens.reasoning += message.info.tokens.reasoning || 0
|
||||||
|
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
|
||||||
|
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const part of message.parts) {
|
||||||
|
if (part.type === "tool" && part.tool) {
|
||||||
|
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageCount: messages.length,
|
||||||
|
sessionCost,
|
||||||
|
sessionTokens,
|
||||||
|
sessionToolUsage,
|
||||||
|
earliestTime: session.time.created,
|
||||||
|
latestTime: session.time.updated,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchResults = await Promise.all(batchPromises)
|
||||||
|
|
||||||
|
for (const result of batchResults) {
|
||||||
|
earliestTime = Math.min(earliestTime, result.earliestTime)
|
||||||
|
latestTime = Math.max(latestTime, result.latestTime)
|
||||||
|
|
||||||
|
stats.totalMessages += result.messageCount
|
||||||
|
stats.totalCost += result.sessionCost
|
||||||
|
stats.totalTokens.input += result.sessionTokens.input
|
||||||
|
stats.totalTokens.output += result.sessionTokens.output
|
||||||
|
stats.totalTokens.reasoning += result.sessionTokens.reasoning
|
||||||
|
stats.totalTokens.cache.read += result.sessionTokens.cache.read
|
||||||
|
stats.totalTokens.cache.write += result.sessionTokens.cache.write
|
||||||
|
|
||||||
|
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
|
||||||
|
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND))
|
||||||
|
stats.dateRange = {
|
||||||
|
earliest: earliestTime,
|
||||||
|
latest: latestTime,
|
||||||
|
}
|
||||||
|
stats.days = actualDays
|
||||||
|
stats.costPerDay = stats.totalCost / actualDays
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayStats(stats: SessionStats, toolLimit?: number) {
|
||||||
const width = 56
|
const width = 56
|
||||||
|
|
||||||
function renderRow(label: string, value: string): string {
|
function renderRow(label: string, value: string): string {
|
||||||
@@ -64,30 +246,35 @@ export function displayStats(stats: SessionStats) {
|
|||||||
|
|
||||||
// Tool Usage section
|
// Tool Usage section
|
||||||
if (Object.keys(stats.toolUsage).length > 0) {
|
if (Object.keys(stats.toolUsage).length > 0) {
|
||||||
const sortedTools = Object.entries(stats.toolUsage)
|
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
|
||||||
.sort(([, a], [, b]) => b - a)
|
const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
|
||||||
.slice(0, 10)
|
|
||||||
|
|
||||||
console.log("┌────────────────────────────────────────────────────────┐")
|
console.log("┌────────────────────────────────────────────────────────┐")
|
||||||
console.log("│ TOOL USAGE │")
|
console.log("│ TOOL USAGE │")
|
||||||
console.log("├────────────────────────────────────────────────────────┤")
|
console.log("├────────────────────────────────────────────────────────┤")
|
||||||
|
|
||||||
const maxCount = Math.max(...sortedTools.map(([, count]) => count))
|
const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count))
|
||||||
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
|
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
|
||||||
|
|
||||||
for (const [tool, count] of sortedTools) {
|
for (const [tool, count] of toolsToDisplay) {
|
||||||
const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
|
const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
|
||||||
const bar = "█".repeat(barLength)
|
const bar = "█".repeat(barLength)
|
||||||
const percentage = ((count / totalToolUsage) * 100).toFixed(1)
|
const percentage = ((count / totalToolUsage) * 100).toFixed(1)
|
||||||
|
|
||||||
const content = ` ${tool.padEnd(10)} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
|
const maxToolLength = 18
|
||||||
const padding = Math.max(0, width - content.length)
|
const truncatedTool =
|
||||||
|
tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool
|
||||||
|
const toolName = truncatedTool.padEnd(maxToolLength)
|
||||||
|
|
||||||
|
const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
|
||||||
|
const padding = Math.max(0, width - content.length - 1)
|
||||||
console.log(`│${content}${" ".repeat(padding)} │`)
|
console.log(`│${content}${" ".repeat(padding)} │`)
|
||||||
}
|
}
|
||||||
console.log("└────────────────────────────────────────────────────────┘")
|
console.log("└────────────────────────────────────────────────────────┘")
|
||||||
}
|
}
|
||||||
console.log()
|
console.log()
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(num: number): string {
|
function formatNumber(num: number): string {
|
||||||
if (num >= 1000000) {
|
if (num >= 1000000) {
|
||||||
return (num / 1000000).toFixed(1) + "M"
|
return (num / 1000000).toFixed(1) + "M"
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
import { Global } from "../../global"
|
|
||||||
import { Provider } from "../../provider/provider"
|
|
||||||
import { Server } from "../../server/server"
|
|
||||||
import { UI } from "../ui"
|
|
||||||
import { cmd } from "./cmd"
|
|
||||||
import path from "path"
|
|
||||||
import fs from "fs/promises"
|
|
||||||
import { Installation } from "../../installation"
|
|
||||||
import { Config } from "../../config/config"
|
|
||||||
import { Bus } from "../../bus"
|
|
||||||
import { Log } from "../../util/log"
|
|
||||||
import { Ide } from "../../ide"
|
|
||||||
|
|
||||||
import { Flag } from "../../flag/flag"
|
|
||||||
import { Session } from "../../session"
|
|
||||||
import { $ } from "bun"
|
|
||||||
import { bootstrap } from "../bootstrap"
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
const OPENCODE_TUI_PATH: string
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof OPENCODE_TUI_PATH !== "undefined") {
|
|
||||||
await import(OPENCODE_TUI_PATH as string, {
|
|
||||||
with: { type: "file" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TuiCommand = cmd({
|
|
||||||
command: "$0 [project]",
|
|
||||||
describe: "start opencode tui",
|
|
||||||
builder: (yargs) =>
|
|
||||||
yargs
|
|
||||||
.positional("project", {
|
|
||||||
type: "string",
|
|
||||||
describe: "path to start opencode in",
|
|
||||||
})
|
|
||||||
.option("model", {
|
|
||||||
type: "string",
|
|
||||||
alias: ["m"],
|
|
||||||
describe: "model to use in the format of provider/model",
|
|
||||||
})
|
|
||||||
.option("continue", {
|
|
||||||
alias: ["c"],
|
|
||||||
describe: "continue the last session",
|
|
||||||
type: "boolean",
|
|
||||||
})
|
|
||||||
.option("session", {
|
|
||||||
alias: ["s"],
|
|
||||||
describe: "session id to continue",
|
|
||||||
type: "string",
|
|
||||||
})
|
|
||||||
.option("prompt", {
|
|
||||||
alias: ["p"],
|
|
||||||
type: "string",
|
|
||||||
describe: "prompt to use",
|
|
||||||
})
|
|
||||||
.option("agent", {
|
|
||||||
type: "string",
|
|
||||||
describe: "agent to use",
|
|
||||||
})
|
|
||||||
.option("port", {
|
|
||||||
type: "number",
|
|
||||||
describe: "port to listen on",
|
|
||||||
default: 0,
|
|
||||||
})
|
|
||||||
.option("hostname", {
|
|
||||||
alias: ["h"],
|
|
||||||
type: "string",
|
|
||||||
describe: "hostname to listen on",
|
|
||||||
default: "127.0.0.1",
|
|
||||||
}),
|
|
||||||
handler: async (args) => {
|
|
||||||
while (true) {
|
|
||||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
|
||||||
try {
|
|
||||||
process.chdir(cwd)
|
|
||||||
} catch (e) {
|
|
||||||
UI.error("Failed to change directory to " + cwd)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await bootstrap(cwd, async () => {
|
|
||||||
const sessionID = await (async () => {
|
|
||||||
if (args.continue) {
|
|
||||||
const it = Session.list()
|
|
||||||
try {
|
|
||||||
for await (const s of it) {
|
|
||||||
if (s.parentID === undefined) {
|
|
||||||
return s.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
} finally {
|
|
||||||
await it.return()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (args.session) {
|
|
||||||
return args.session
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const providers = await Provider.list()
|
|
||||||
if (Object.keys(providers).length === 0) {
|
|
||||||
return "needs_provider"
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = Server.listen({
|
|
||||||
port: args.port,
|
|
||||||
hostname: args.hostname,
|
|
||||||
})
|
|
||||||
|
|
||||||
let cmd = [] as string[]
|
|
||||||
const tui = Bun.embeddedFiles.find((item) => (item as File).name.includes("tui")) as File
|
|
||||||
if (tui) {
|
|
||||||
let binaryName = tui.name
|
|
||||||
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
|
|
||||||
binaryName += ".exe"
|
|
||||||
}
|
|
||||||
const binary = path.join(Global.Path.cache, "tui", binaryName)
|
|
||||||
const file = Bun.file(binary)
|
|
||||||
if (!(await file.exists())) {
|
|
||||||
await Bun.write(file, tui, { mode: 0o755 })
|
|
||||||
if (process.platform !== "win32") await fs.chmod(binary, 0o755)
|
|
||||||
}
|
|
||||||
cmd = [binary]
|
|
||||||
}
|
|
||||||
if (!tui) {
|
|
||||||
const dir = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url))
|
|
||||||
let binaryName = `./dist/tui${process.platform === "win32" ? ".exe" : ""}`
|
|
||||||
await $`go build -o ${binaryName} ./main.go`.cwd(dir)
|
|
||||||
cmd = [path.join(dir, binaryName)]
|
|
||||||
}
|
|
||||||
Log.Default.info("tui", {
|
|
||||||
cmd,
|
|
||||||
})
|
|
||||||
const proc = Bun.spawn({
|
|
||||||
cmd: [
|
|
||||||
...cmd,
|
|
||||||
...(args.model ? ["--model", args.model] : []),
|
|
||||||
...(args.prompt ? ["--prompt", args.prompt] : []),
|
|
||||||
...(args.agent ? ["--agent", args.agent] : []),
|
|
||||||
...(sessionID ? ["--session", sessionID] : []),
|
|
||||||
],
|
|
||||||
cwd,
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
stdin: "inherit",
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
CGO_ENABLED: "0",
|
|
||||||
OPENCODE_SERVER: server.url.toString(),
|
|
||||||
},
|
|
||||||
onExit: () => {
|
|
||||||
server.stop()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
// if (Installation.isLocal()) return
|
|
||||||
const config = await Config.get()
|
|
||||||
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
|
||||||
const latest = await Installation.latest().catch(() => {})
|
|
||||||
if (!latest) return
|
|
||||||
if (Installation.VERSION === latest) return
|
|
||||||
const method = await Installation.method()
|
|
||||||
if (method === "unknown") return
|
|
||||||
await Installation.upgrade(method, latest)
|
|
||||||
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
|
|
||||||
.catch(() => {})
|
|
||||||
})()
|
|
||||||
;(async () => {
|
|
||||||
if (Ide.alreadyInstalled()) return
|
|
||||||
const ide = Ide.ide()
|
|
||||||
if (ide === "unknown") return
|
|
||||||
await Ide.install(ide)
|
|
||||||
.then(() => Bus.publish(Ide.Event.Installed, { ide }))
|
|
||||||
.catch(() => {})
|
|
||||||
})()
|
|
||||||
|
|
||||||
await proc.exited
|
|
||||||
server.stop()
|
|
||||||
|
|
||||||
return "done"
|
|
||||||
})
|
|
||||||
if (result === "done") break
|
|
||||||
if (result === "needs_provider") {
|
|
||||||
UI.empty()
|
|
||||||
UI.println(UI.logo(" "))
|
|
||||||
const result = await Bun.spawn({
|
|
||||||
cmd: [...getOpencodeCommand(), "auth", "login"],
|
|
||||||
cwd: process.cwd(),
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
stdin: "inherit",
|
|
||||||
}).exited
|
|
||||||
if (result !== 0) return
|
|
||||||
UI.empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the correct command to run opencode CLI
|
|
||||||
* In development: ["bun", "run", "packages/opencode/src/index.ts"]
|
|
||||||
* In production: ["/path/to/opencode"]
|
|
||||||
*/
|
|
||||||
function getOpencodeCommand(): string[] {
|
|
||||||
// Check if OPENCODE_BIN_PATH is set (used by shell wrapper scripts)
|
|
||||||
if (process.env["OPENCODE_BIN_PATH"]) {
|
|
||||||
return [process.env["OPENCODE_BIN_PATH"]]
|
|
||||||
}
|
|
||||||
|
|
||||||
const execPath = process.execPath.toLowerCase()
|
|
||||||
|
|
||||||
if (Installation.isLocal()) {
|
|
||||||
// In development, use bun to run the TypeScript entry point
|
|
||||||
return [execPath, "run", process.argv[1]]
|
|
||||||
}
|
|
||||||
|
|
||||||
// In production, use the current executable path
|
|
||||||
return [process.execPath]
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import { Installation } from "@/installation"
|
|||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
import { SyncProvider } from "@tui/context/sync"
|
||||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||||
import { DialogModel } from "@tui/component/dialog-model"
|
import { DialogModel } from "@tui/component/dialog-model"
|
||||||
import { DialogStatus } from "@tui/component/dialog-status"
|
import { DialogStatus } from "@tui/component/dialog-status"
|
||||||
@@ -23,21 +23,83 @@ import { Session } from "@tui/routes/session"
|
|||||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||||
import { DialogAlert } from "./ui/dialog-alert"
|
import { DialogAlert } from "./ui/dialog-alert"
|
||||||
import { ToastProvider, useToast } from "./ui/toast"
|
import { ToastProvider, useToast } from "./ui/toast"
|
||||||
import { ExitProvider } from "./context/exit"
|
import { ExitProvider, useExit } from "./context/exit"
|
||||||
import type { SessionRoute } from "./context/route"
|
|
||||||
import { Session as SessionApi } from "@/session"
|
import { Session as SessionApi } from "@/session"
|
||||||
import { TuiEvent } from "./event"
|
import { TuiEvent } from "./event"
|
||||||
import { KVProvider, useKV } from "./context/kv"
|
import { KVProvider, useKV } from "./context/kv"
|
||||||
|
|
||||||
|
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||||
|
// can't set raw mode if not a TTY
|
||||||
|
if (!process.stdin.isTTY) return "dark"
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let timeout: NodeJS.Timeout
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
process.stdin.setRawMode(false)
|
||||||
|
process.stdin.removeListener("data", handler)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (data: Buffer) => {
|
||||||
|
const str = data.toString()
|
||||||
|
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
||||||
|
if (match) {
|
||||||
|
cleanup()
|
||||||
|
const color = match[1]
|
||||||
|
// Parse RGB values from color string
|
||||||
|
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
|
||||||
|
let r = 0,
|
||||||
|
g = 0,
|
||||||
|
b = 0
|
||||||
|
|
||||||
|
if (color.startsWith("rgb:")) {
|
||||||
|
const parts = color.substring(4).split("/")
|
||||||
|
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
|
||||||
|
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
|
||||||
|
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
|
||||||
|
} else if (color.startsWith("#")) {
|
||||||
|
r = parseInt(color.substring(1, 3), 16)
|
||||||
|
g = parseInt(color.substring(3, 5), 16)
|
||||||
|
b = parseInt(color.substring(5, 7), 16)
|
||||||
|
} else if (color.startsWith("rgb(")) {
|
||||||
|
const parts = color.substring(4, color.length - 1).split(",")
|
||||||
|
r = parseInt(parts[0])
|
||||||
|
g = parseInt(parts[1])
|
||||||
|
b = parseInt(parts[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate luminance using relative luminance formula
|
||||||
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||||
|
|
||||||
|
// Determine if dark or light based on luminance threshold
|
||||||
|
resolve(luminance > 0.5 ? "light" : "dark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.setRawMode(true)
|
||||||
|
process.stdin.on("data", handler)
|
||||||
|
process.stdout.write("\x1b]11;?\x07")
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
cleanup()
|
||||||
|
resolve("dark")
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function tui(input: {
|
export function tui(input: {
|
||||||
url: string
|
url: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
model?: string
|
model?: string
|
||||||
agent?: string
|
agent?: string
|
||||||
|
prompt?: string
|
||||||
onExit?: () => Promise<void>
|
onExit?: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
// promise to prevent immediate exit
|
// promise to prevent immediate exit
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>(async (resolve) => {
|
||||||
|
const mode = await getTerminalBackgroundColor()
|
||||||
|
|
||||||
const routeData: Route | undefined = input.sessionID
|
const routeData: Route | undefined = input.sessionID
|
||||||
? {
|
? {
|
||||||
type: "session",
|
type: "session",
|
||||||
@@ -53,15 +115,23 @@ export function tui(input: {
|
|||||||
render(
|
render(
|
||||||
() => {
|
() => {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary fallback={<text>Something went wrong</text>}>
|
<ErrorBoundary
|
||||||
|
fallback={(error, reset) => (
|
||||||
|
<ErrorComponent error={error} reset={reset} onExit={onExit} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ExitProvider onExit={onExit}>
|
<ExitProvider onExit={onExit}>
|
||||||
<KVProvider>
|
<KVProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<RouteProvider data={routeData}>
|
<RouteProvider data={routeData}>
|
||||||
<SDKProvider url={input.url}>
|
<SDKProvider url={input.url}>
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider mode={mode}>
|
||||||
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
|
<LocalProvider
|
||||||
|
initialModel={input.model}
|
||||||
|
initialAgent={input.agent}
|
||||||
|
initialPrompt={input.prompt}
|
||||||
|
>
|
||||||
<KeybindProvider>
|
<KeybindProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<CommandProvider>
|
<CommandProvider>
|
||||||
@@ -86,6 +156,7 @@ export function tui(input: {
|
|||||||
targetFps: 60,
|
targetFps: 60,
|
||||||
gatherStats: false,
|
gatherStats: false,
|
||||||
exitOnCtrlC: false,
|
exitOnCtrlC: false,
|
||||||
|
useKittyKeyboard: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -101,12 +172,12 @@ function App() {
|
|||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const command = useCommandDialog()
|
const command = useCommandDialog()
|
||||||
const { event } = useSDK()
|
const { event } = useSDK()
|
||||||
const sync = useSync()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [sessionExists, setSessionExists] = createSignal(false)
|
const { theme, mode, setMode } = useTheme()
|
||||||
const { theme } = useTheme()
|
const exit = useExit()
|
||||||
|
|
||||||
useKeyboard(async (evt) => {
|
useKeyboard(async (evt) => {
|
||||||
|
if (!Installation.isLocal()) return
|
||||||
if (evt.meta && evt.name === "t") {
|
if (evt.meta && evt.name === "t") {
|
||||||
renderer.toggleDebugOverlay()
|
renderer.toggleDebugOverlay()
|
||||||
return
|
return
|
||||||
@@ -118,21 +189,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Make sure session is valid, otherwise redirect to home
|
|
||||||
createEffect(async () => {
|
|
||||||
if (route.data.type === "session") {
|
|
||||||
const data = route.data as SessionRoute
|
|
||||||
await sync.session.sync(data.sessionID).catch(() => {
|
|
||||||
toast.show({
|
|
||||||
message: `Session not found: ${data.sessionID}`,
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
return route.navigate({ type: "home" })
|
|
||||||
})
|
|
||||||
setSessionExists(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
console.log(JSON.stringify(route.data))
|
console.log(JSON.stringify(route.data))
|
||||||
})
|
})
|
||||||
@@ -168,6 +224,24 @@ function App() {
|
|||||||
dialog.replace(() => <DialogModel />)
|
dialog.replace(() => <DialogModel />)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Model cycle",
|
||||||
|
value: "model.cycle_recent",
|
||||||
|
keybind: "model_cycle_recent",
|
||||||
|
category: "Agent",
|
||||||
|
onSelect: () => {
|
||||||
|
local.model.cycle(1)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Model cycle reverse",
|
||||||
|
value: "model.cycle_recent_reverse",
|
||||||
|
keybind: "model_cycle_recent_reverse",
|
||||||
|
category: "Agent",
|
||||||
|
onSelect: () => {
|
||||||
|
local.model.cycle(-1)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Switch agent",
|
title: "Switch agent",
|
||||||
value: "agent.list",
|
value: "agent.list",
|
||||||
@@ -214,6 +288,14 @@ function App() {
|
|||||||
},
|
},
|
||||||
category: "System",
|
category: "System",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
|
||||||
|
value: "theme.switch_mode",
|
||||||
|
onSelect: () => {
|
||||||
|
setMode(mode() === "dark" ? "light" : "dark")
|
||||||
|
},
|
||||||
|
category: "System",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Help",
|
title: "Help",
|
||||||
value: "help.show",
|
value: "help.show",
|
||||||
@@ -222,11 +304,17 @@ function App() {
|
|||||||
},
|
},
|
||||||
category: "System",
|
category: "System",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Exit the app",
|
||||||
|
value: "app.exit",
|
||||||
|
onSelect: exit,
|
||||||
|
category: "System",
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const providerID = local.model.current().providerID
|
const providerID = local.model.current().providerID
|
||||||
if (providerID === "openrouter" && !kv.data.openrouter_warning) {
|
if (providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
DialogAlert.show(
|
DialogAlert.show(
|
||||||
dialog,
|
dialog,
|
||||||
@@ -252,6 +340,7 @@ function App() {
|
|||||||
|
|
||||||
event.on(SessionApi.Event.Deleted.type, (evt) => {
|
event.on(SessionApi.Event.Deleted.type, (evt) => {
|
||||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||||
|
dialog.clear()
|
||||||
route.navigate({ type: "home" })
|
route.navigate({ type: "home" })
|
||||||
toast.show({
|
toast.show({
|
||||||
variant: "info",
|
variant: "info",
|
||||||
@@ -260,6 +349,27 @@ function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
event.on(SessionApi.Event.Error.type, (evt) => {
|
||||||
|
const error = evt.properties.error
|
||||||
|
const message = (() => {
|
||||||
|
if (!error) return "An error occured"
|
||||||
|
|
||||||
|
if (typeof error === "object") {
|
||||||
|
const data = error.data
|
||||||
|
if ("message" in data && typeof data.message === "string") {
|
||||||
|
return data.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(error)
|
||||||
|
})()
|
||||||
|
|
||||||
|
toast.show({
|
||||||
|
variant: "error",
|
||||||
|
message,
|
||||||
|
duration: 5000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
width={dimensions().width}
|
width={dimensions().width}
|
||||||
@@ -274,8 +384,9 @@ function App() {
|
|||||||
/* @ts-expect-error */
|
/* @ts-expect-error */
|
||||||
renderer.writeOut(finalOsc52)
|
renderer.writeOut(finalOsc52)
|
||||||
await Clipboard.copy(text)
|
await Clipboard.copy(text)
|
||||||
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||||
|
.catch(toast.error)
|
||||||
renderer.clearSelection()
|
renderer.clearSelection()
|
||||||
toast.show({ message: "Copied to clipboard", variant: "info" })
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -284,7 +395,7 @@ function App() {
|
|||||||
<Match when={route.data.type === "home"}>
|
<Match when={route.data.type === "home"}>
|
||||||
<Home />
|
<Home />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={route.data.type === "session" && sessionExists()}>
|
<Match when={route.data.type === "session"}>
|
||||||
<Session />
|
<Session />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -304,7 +415,9 @@ function App() {
|
|||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
>
|
>
|
||||||
<text fg={theme.textMuted}>open</text>
|
<text fg={theme.textMuted}>open</text>
|
||||||
<text attributes={TextAttributes.BOLD}>code </text>
|
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||||
|
code{" "}
|
||||||
|
</text>
|
||||||
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
|
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
|
||||||
</box>
|
</box>
|
||||||
<box paddingLeft={1} paddingRight={1}>
|
<box paddingLeft={1} paddingRight={1}>
|
||||||
@@ -319,7 +432,7 @@ function App() {
|
|||||||
<text
|
<text
|
||||||
bg={local.agent.color(local.agent.current().name)}
|
bg={local.agent.color(local.agent.current().name)}
|
||||||
fg={theme.background}
|
fg={theme.background}
|
||||||
wrapMode="none"
|
wrapMode={undefined}
|
||||||
>
|
>
|
||||||
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
|
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
|
||||||
<span> AGENT </span>
|
<span> AGENT </span>
|
||||||
@@ -329,3 +442,57 @@ function App() {
|
|||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => Promise<void> }) {
|
||||||
|
const term = useTerminalDimensions()
|
||||||
|
useKeyboard((evt) => {
|
||||||
|
if (evt.ctrl && evt.name === "c") {
|
||||||
|
props.onExit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const [copied, setCopied] = createSignal(false)
|
||||||
|
|
||||||
|
const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml")
|
||||||
|
|
||||||
|
if (props.error.message) {
|
||||||
|
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.error.stack) {
|
||||||
|
issueURL.searchParams.set(
|
||||||
|
"description",
|
||||||
|
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyIssueURL = () => {
|
||||||
|
Clipboard.copy(issueURL.toString()).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
|
<text attributes={TextAttributes.BOLD}>Please report an issue.</text>
|
||||||
|
<box onMouseUp={copyIssueURL} backgroundColor="#565f89" padding={1}>
|
||||||
|
<text attributes={TextAttributes.BOLD}>Copy issue URL (exception info pre-filled)</text>
|
||||||
|
</box>
|
||||||
|
{copied() && <text>Successfully copied</text>}
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={2} alignItems="center">
|
||||||
|
<text>A fatal error occurred!</text>
|
||||||
|
<box onMouseUp={props.reset} backgroundColor="#565f89" padding={1}>
|
||||||
|
<text>Reset TUI</text>
|
||||||
|
</box>
|
||||||
|
<box onMouseUp={props.onExit} backgroundColor="#565f89" padding={1}>
|
||||||
|
<text>Exit</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
<scrollbox height={Math.floor(term().height * 0.7)}>
|
||||||
|
<text>{props.error.stack}</text>
|
||||||
|
</scrollbox>
|
||||||
|
<text>{props.error.message}</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ export type CommandOption = DialogSelectOption & {
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||||
|
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
return registrations().flatMap((x) => x())
|
return registrations().flatMap((x) => x())
|
||||||
})
|
})
|
||||||
|
const suspended = () => suspendCount() > 0
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
|
if (suspended()) return
|
||||||
for (const option of options()) {
|
for (const option of options()) {
|
||||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
@@ -39,14 +42,21 @@ function init() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
trigger(name: string) {
|
trigger(name: string, source?: "prompt") {
|
||||||
for (const option of options()) {
|
for (const option of options()) {
|
||||||
if (option.value === name) {
|
if (option.value === name) {
|
||||||
option.onSelect?.(dialog)
|
option.onSelect?.(dialog, source)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
keybinds(enabled: boolean) {
|
||||||
|
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||||
|
},
|
||||||
|
suspended,
|
||||||
|
show() {
|
||||||
|
dialog.replace(() => <DialogCommand options={options()} />)
|
||||||
|
},
|
||||||
register(cb: () => CommandOption[]) {
|
register(cb: () => CommandOption[]) {
|
||||||
const results = createMemo(cb)
|
const results = createMemo(cb)
|
||||||
setRegistrations((arr) => [results, ...arr])
|
setRegistrations((arr) => [results, ...arr])
|
||||||
@@ -75,6 +85,9 @@ export function CommandProvider(props: ParentProps) {
|
|||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
|
if (value.suspended()) return
|
||||||
|
if (dialog.stack.length > 0) return
|
||||||
|
if (evt.defaultPrevented) return
|
||||||
if (keybind.match("command_list", evt)) {
|
if (keybind.match("command_list", evt)) {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
dialog.replace(() => <DialogCommand options={value.options} />)
|
dialog.replace(() => <DialogCommand options={value.options} />)
|
||||||
@@ -90,7 +103,10 @@ function DialogCommand(props: { options: CommandOption[] }) {
|
|||||||
return (
|
return (
|
||||||
<DialogSelect
|
<DialogSelect
|
||||||
title="Commands"
|
title="Commands"
|
||||||
options={props.options.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined }))}
|
options={props.options.map((x) => ({
|
||||||
|
...x,
|
||||||
|
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Locale } from "@/util/locale"
|
|||||||
import { Keybind } from "@/util/keybind"
|
import { Keybind } from "@/util/keybind"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useSDK } from "../context/sdk"
|
import { useSDK } from "../context/sdk"
|
||||||
|
import { DialogSessionRename } from "./dialog-session-rename"
|
||||||
|
|
||||||
export function DialogSessionList() {
|
export function DialogSessionList() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -17,6 +18,8 @@ export function DialogSessionList() {
|
|||||||
|
|
||||||
const [toDelete, setToDelete] = createSignal<string>()
|
const [toDelete, setToDelete] = createSignal<string>()
|
||||||
|
|
||||||
|
const deleteKeybind = "ctrl+d"
|
||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
const today = new Date().toDateString()
|
const today = new Date().toDateString()
|
||||||
return sync.data.session
|
return sync.data.session
|
||||||
@@ -29,7 +32,7 @@ export function DialogSessionList() {
|
|||||||
}
|
}
|
||||||
const isDeleting = toDelete() === x.id
|
const isDeleting = toDelete() === x.id
|
||||||
return {
|
return {
|
||||||
title: isDeleting ? "Press delete again to confirm" : x.title,
|
title: isDeleting ? `Press ${deleteKeybind} again to confirm` : x.title,
|
||||||
bg: isDeleting ? theme.error : undefined,
|
bg: isDeleting ? theme.error : undefined,
|
||||||
value: x.id,
|
value: x.id,
|
||||||
category,
|
category,
|
||||||
@@ -59,7 +62,7 @@ export function DialogSessionList() {
|
|||||||
}}
|
}}
|
||||||
keybind={[
|
keybind={[
|
||||||
{
|
{
|
||||||
keybind: Keybind.parse("delete")[0],
|
keybind: Keybind.parse(deleteKeybind)[0],
|
||||||
title: "delete",
|
title: "delete",
|
||||||
onTrigger: async (option) => {
|
onTrigger: async (option) => {
|
||||||
if (toDelete() === option.value) {
|
if (toDelete() === option.value) {
|
||||||
@@ -69,11 +72,19 @@ export function DialogSessionList() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
setToDelete(undefined)
|
setToDelete(undefined)
|
||||||
|
// dialog.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setToDelete(option.value)
|
setToDelete(option.value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
keybind: Keybind.parse("ctrl+r")[0],
|
||||||
|
title: "rename",
|
||||||
|
onTrigger: async (option) => {
|
||||||
|
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||||
|
},
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { DialogPrompt } from "@tui/ui/dialog-prompt"
|
||||||
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
|
import { useSync } from "@tui/context/sync"
|
||||||
|
import { createMemo } from "solid-js"
|
||||||
|
import { useSDK } from "../context/sdk"
|
||||||
|
|
||||||
|
interface DialogSessionRenameProps {
|
||||||
|
session: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogSessionRename(props: DialogSessionRenameProps) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const session = createMemo(() => sync.session.get(props.session))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPrompt
|
||||||
|
title="Rename Session"
|
||||||
|
value={session()?.title}
|
||||||
|
onConfirm={(value) => {
|
||||||
|
sdk.client.session.update({
|
||||||
|
path: {
|
||||||
|
id: props.session,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dialog.clear()
|
||||||
|
}}
|
||||||
|
onCancel={() => dialog.clear()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { For, Match, Switch, Show } from "solid-js"
|
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||||
|
|
||||||
export type DialogStatusProps = {}
|
export type DialogStatusProps = {}
|
||||||
|
|
||||||
@@ -9,15 +9,19 @@ export function DialogStatus() {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text attributes={TextAttributes.BOLD}>Status</text>
|
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||||
|
Status
|
||||||
|
</text>
|
||||||
<text fg={theme.textMuted}>esc</text>
|
<text fg={theme.textMuted}>esc</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={Object.keys(sync.data.mcp).length > 0}>
|
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text>No MCP Servers</text>}>
|
||||||
<box>
|
<box>
|
||||||
<text>{Object.keys(sync.data.mcp).length} MCP Servers</text>
|
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
|
||||||
<For each={Object.entries(sync.data.mcp)}>
|
<For each={Object.entries(sync.data.mcp)}>
|
||||||
{([key, item]) => (
|
{([key, item]) => (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
@@ -33,7 +37,7 @@ export function DialogStatus() {
|
|||||||
>
|
>
|
||||||
•
|
•
|
||||||
</text>
|
</text>
|
||||||
<text wrapMode="word">
|
<text fg={theme.text} wrapMode="word">
|
||||||
<b>{key}</b>{" "}
|
<b>{key}</b>{" "}
|
||||||
<span style={{ fg: theme.textMuted }}>
|
<span style={{ fg: theme.textMuted }}>
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -50,7 +54,7 @@ export function DialogStatus() {
|
|||||||
</Show>
|
</Show>
|
||||||
{sync.data.lsp.length > 0 && (
|
{sync.data.lsp.length > 0 && (
|
||||||
<box>
|
<box>
|
||||||
<text>{sync.data.lsp.length} LSP Servers</text>
|
<text fg={theme.text}>{sync.data.lsp.length} LSP Servers</text>
|
||||||
<For each={sync.data.lsp}>
|
<For each={sync.data.lsp}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
@@ -65,7 +69,7 @@ export function DialogStatus() {
|
|||||||
>
|
>
|
||||||
•
|
•
|
||||||
</text>
|
</text>
|
||||||
<text wrapMode="word">
|
<text fg={theme.text} wrapMode="word">
|
||||||
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
|
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
@@ -73,6 +77,31 @@ export function DialogStatus() {
|
|||||||
</For>
|
</For>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
|
<Show
|
||||||
|
when={enabledFormatters().length > 0}
|
||||||
|
fallback={<text fg={theme.text}>No Formatters</text>}
|
||||||
|
>
|
||||||
|
<box>
|
||||||
|
<text fg={theme.text}>{enabledFormatters().length} Formatters</text>
|
||||||
|
<For each={enabledFormatters()}>
|
||||||
|
{(item) => (
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
style={{
|
||||||
|
fg: theme.success,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</text>
|
||||||
|
<text wrapMode="word" fg={theme.text}>
|
||||||
|
<b>{item.name}</b>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,16 @@ export function DialogThemeList() {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
let confirmed = false
|
let confirmed = false
|
||||||
let ref: DialogSelectRef<keyof typeof THEMES>
|
let ref: DialogSelectRef<keyof typeof THEMES>
|
||||||
const initial = theme.selectedTheme
|
const initial = theme.selected
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// highlight the first theme in the list when we open it for UX
|
// highlight the first theme in the list when we open it for UX
|
||||||
theme.setSelectedTheme(Object.keys(THEMES)[0] as keyof typeof THEMES)
|
theme.set(Object.keys(THEMES)[0] as keyof typeof THEMES)
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
// if we close the dialog without confirming, reset back to the initial theme
|
// if we close the dialog without confirming, reset back to the initial theme
|
||||||
if (!confirmed) theme.setSelectedTheme(initial)
|
if (!confirmed) theme.set(initial)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -28,10 +29,10 @@ export function DialogThemeList() {
|
|||||||
title="Themes"
|
title="Themes"
|
||||||
options={options}
|
options={options}
|
||||||
onMove={(opt) => {
|
onMove={(opt) => {
|
||||||
theme.setSelectedTheme(opt.value)
|
theme.set(opt.value)
|
||||||
}}
|
}}
|
||||||
onSelect={(opt) => {
|
onSelect={(opt) => {
|
||||||
theme.setSelectedTheme(opt.value)
|
theme.set(opt.value)
|
||||||
confirmed = true
|
confirmed = true
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
}}
|
}}
|
||||||
@@ -40,12 +41,12 @@ export function DialogThemeList() {
|
|||||||
}}
|
}}
|
||||||
onFilter={(query) => {
|
onFilter={(query) => {
|
||||||
if (query.length === 0) {
|
if (query.length === 0) {
|
||||||
theme.setSelectedTheme(initial)
|
theme.set(initial)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const first = ref.filtered[0]
|
const first = ref.filtered[0]
|
||||||
if (first) theme.setSelectedTheme(first.value)
|
if (first) theme.set(first.value)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type AutocompleteRef = {
|
|||||||
|
|
||||||
export type AutocompleteOption = {
|
export type AutocompleteOption = {
|
||||||
display: string
|
display: string
|
||||||
|
aliases?: string[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
description?: string
|
description?: string
|
||||||
onSelect?: () => void
|
onSelect?: () => void
|
||||||
@@ -48,12 +49,23 @@ export function Autocomplete(props: {
|
|||||||
})
|
})
|
||||||
const filter = createMemo(() => {
|
const filter = createMemo(() => {
|
||||||
if (!store.visible) return
|
if (!store.visible) return
|
||||||
return props.value.substring(store.index + 1).split(" ")[0]
|
// Track props.value to make memo reactive to text changes
|
||||||
|
props.value // <- there surely is a better way to do this, like making .input() reactive
|
||||||
|
|
||||||
|
const val = props.input().getTextRange(store.index + 1, props.input().cursorOffset + 1)
|
||||||
|
|
||||||
|
// If the filter contains a space, hide the autocomplete
|
||||||
|
if (val.includes(" ")) {
|
||||||
|
hide()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return val
|
||||||
})
|
})
|
||||||
|
|
||||||
function insertPart(text: string, part: PromptInfo["parts"][number]) {
|
function insertPart(text: string, part: PromptInfo["parts"][number]) {
|
||||||
const input = props.input()
|
const input = props.input()
|
||||||
const currentCursorOffset = input.visualCursor.offset
|
const currentCursorOffset = input.cursorOffset
|
||||||
|
|
||||||
const charAfterCursor = props.value.at(currentCursorOffset)
|
const charAfterCursor = props.value.at(currentCursorOffset)
|
||||||
const needsSpace = charAfterCursor !== " "
|
const needsSpace = charAfterCursor !== " "
|
||||||
@@ -69,7 +81,7 @@ export function Autocomplete(props: {
|
|||||||
|
|
||||||
const virtualText = "@" + text
|
const virtualText = "@" + text
|
||||||
const extmarkStart = store.index
|
const extmarkStart = store.index
|
||||||
const extmarkEnd = extmarkStart + virtualText.length
|
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
|
||||||
|
|
||||||
const styleId =
|
const styleId =
|
||||||
part.type === "file"
|
part.type === "file"
|
||||||
@@ -152,7 +164,6 @@ export function Autocomplete(props: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const agents = createMemo(() => {
|
const agents = createMemo(() => {
|
||||||
if (store.index !== 0) return []
|
|
||||||
const agents = sync.data.agent
|
const agents = sync.data.agent
|
||||||
return agents
|
return agents
|
||||||
.filter((agent) => !agent.builtIn && agent.mode !== "primary")
|
.filter((agent) => !agent.builtIn && agent.mode !== "primary")
|
||||||
@@ -198,7 +209,10 @@ export function Autocomplete(props: {
|
|||||||
{
|
{
|
||||||
display: "/undo",
|
display: "/undo",
|
||||||
description: "undo the last message",
|
description: "undo the last message",
|
||||||
onSelect: () => command.trigger("session.undo"),
|
onSelect: () => {
|
||||||
|
hide()
|
||||||
|
command.trigger("session.undo")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
display: "/redo",
|
display: "/redo",
|
||||||
@@ -207,6 +221,7 @@ export function Autocomplete(props: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
display: "/compact",
|
display: "/compact",
|
||||||
|
aliases: ["/summarize"],
|
||||||
description: "compact the session",
|
description: "compact the session",
|
||||||
onSelect: () => command.trigger("session.compact"),
|
onSelect: () => command.trigger("session.compact"),
|
||||||
},
|
},
|
||||||
@@ -222,11 +237,32 @@ export function Autocomplete(props: {
|
|||||||
description: "unshare a session",
|
description: "unshare a session",
|
||||||
onSelect: () => command.trigger("session.unshare"),
|
onSelect: () => command.trigger("session.unshare"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
display: "/rename",
|
||||||
|
description: "rename session",
|
||||||
|
onSelect: () => command.trigger("session.rename"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: "/copy",
|
||||||
|
description: "copy session transcript to clipboard",
|
||||||
|
onSelect: () => command.trigger("session.copy"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: "/export",
|
||||||
|
description: "export session transcript to file",
|
||||||
|
onSelect: () => command.trigger("session.export"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: "/timeline",
|
||||||
|
description: "jump to message",
|
||||||
|
onSelect: () => command.trigger("session.timeline"),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
results.push(
|
results.push(
|
||||||
{
|
{
|
||||||
display: "/new",
|
display: "/new",
|
||||||
|
aliases: ["/clear"],
|
||||||
description: "create a new session",
|
description: "create a new session",
|
||||||
onSelect: () => command.trigger("session.new"),
|
onSelect: () => command.trigger("session.new"),
|
||||||
},
|
},
|
||||||
@@ -242,19 +278,42 @@ export function Autocomplete(props: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
display: "/session",
|
display: "/session",
|
||||||
|
aliases: ["/resume", "/continue"],
|
||||||
description: "list sessions",
|
description: "list sessions",
|
||||||
onSelect: () => command.trigger("session.list"),
|
onSelect: () => command.trigger("session.list"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
display: "/status",
|
display: "/status",
|
||||||
|
aliases: ["/mcp"],
|
||||||
description: "show status",
|
description: "show status",
|
||||||
onSelect: () => command.trigger("opencode.status"),
|
onSelect: () => command.trigger("opencode.status"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
display: "/theme",
|
||||||
|
description: "toggle theme",
|
||||||
|
onSelect: () => command.trigger("theme.switch"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: "/editor",
|
||||||
|
description: "open editor",
|
||||||
|
onSelect: () => command.trigger("prompt.editor", "prompt"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
display: "/help",
|
display: "/help",
|
||||||
description: "show help",
|
description: "show help",
|
||||||
onSelect: () => command.trigger("help.show"),
|
onSelect: () => command.trigger("help.show"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
display: "/commands",
|
||||||
|
description: "show all commands",
|
||||||
|
onSelect: () => command.show(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
display: "/exit",
|
||||||
|
aliases: ["/quit", "/q"],
|
||||||
|
description: "exit the app",
|
||||||
|
onSelect: () => command.trigger("app.exit"),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
|
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
|
||||||
if (!max) return results
|
if (!max) return results
|
||||||
@@ -273,7 +332,7 @@ export function Autocomplete(props: {
|
|||||||
const currentFilter = filter()
|
const currentFilter = filter()
|
||||||
if (!currentFilter) return mixed.slice(0, 10)
|
if (!currentFilter) return mixed.slice(0, 10)
|
||||||
const result = fuzzysort.go(currentFilter, mixed, {
|
const result = fuzzysort.go(currentFilter, mixed, {
|
||||||
keys: ["display", "description"],
|
keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""],
|
||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
return result.map((arr) => arr.obj)
|
return result.map((arr) => arr.obj)
|
||||||
@@ -301,9 +360,10 @@ export function Autocomplete(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function show(mode: "@" | "/") {
|
function show(mode: "@" | "/") {
|
||||||
|
command.keybinds(false)
|
||||||
setStore({
|
setStore({
|
||||||
visible: mode,
|
visible: mode,
|
||||||
index: props.input().visualCursor.offset,
|
index: props.input().cursorOffset,
|
||||||
position: {
|
position: {
|
||||||
x: props.anchor().x,
|
x: props.anchor().x,
|
||||||
y: props.anchor().y,
|
y: props.anchor().y,
|
||||||
@@ -318,6 +378,7 @@ export function Autocomplete(props: {
|
|||||||
const cursor = props.input().logicalCursor
|
const cursor = props.input().logicalCursor
|
||||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||||
}
|
}
|
||||||
|
command.keybinds(true)
|
||||||
setStore("visible", false)
|
setStore("visible", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,33 +387,65 @@ export function Autocomplete(props: {
|
|||||||
get visible() {
|
get visible() {
|
||||||
return store.visible
|
return store.visible
|
||||||
},
|
},
|
||||||
onInput(value: string) {
|
onInput() {
|
||||||
if (store.visible && value.length <= store.index) hide()
|
if (store.visible) {
|
||||||
|
if (props.input().cursorOffset <= store.index) {
|
||||||
|
hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Check if a space was typed after the trigger character
|
||||||
|
const currentText = props
|
||||||
|
.input()
|
||||||
|
.getTextRange(store.index + 1, props.input().cursorOffset + 1)
|
||||||
|
if (currentText.includes(" ")) {
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onKeyDown(e: KeyEvent) {
|
onKeyDown(e: KeyEvent) {
|
||||||
if (store.visible) {
|
if (store.visible) {
|
||||||
if (e.name === "up") move(-1)
|
const name = e.name?.toLowerCase()
|
||||||
if (e.name === "down") move(1)
|
const ctrlOnly = e.ctrl && !e.meta && !e.shift
|
||||||
if (e.name === "escape") hide()
|
const isNavUp = name === "up" || (ctrlOnly && name === "p")
|
||||||
if (e.name === "return" || e.name === "tab") select()
|
const isNavDown = name === "down" || (ctrlOnly && name === "n")
|
||||||
if (["up", "down", "return", "tab", "escape"].includes(e.name)) e.preventDefault()
|
|
||||||
|
if (isNavUp) {
|
||||||
|
move(-1)
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isNavDown) {
|
||||||
|
move(1)
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "escape") {
|
||||||
|
hide()
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "return" || name === "tab") {
|
||||||
|
select()
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!store.visible) {
|
if (!store.visible) {
|
||||||
if (e.name === "@") {
|
if (e.name === "@") {
|
||||||
const cursorOffset = props.input().visualCursor.offset
|
const cursorOffset = props.input().cursorOffset
|
||||||
const charBeforeCursor =
|
const charBeforeCursor =
|
||||||
cursorOffset === 0 ? undefined : props.value.at(cursorOffset - 1)
|
cursorOffset === 0
|
||||||
if (
|
? undefined
|
||||||
charBeforeCursor === " " ||
|
: props.input().getTextRange(cursorOffset - 1, cursorOffset)
|
||||||
charBeforeCursor === "\n" ||
|
const canTrigger =
|
||||||
charBeforeCursor === undefined
|
charBeforeCursor === undefined ||
|
||||||
) {
|
charBeforeCursor === "" ||
|
||||||
show("@")
|
/\s/.test(charBeforeCursor)
|
||||||
}
|
if (canTrigger) show("@")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.name === "/") {
|
if (e.name === "/") {
|
||||||
if (props.input().visualCursor.offset === 0) show("/")
|
if (props.input().cursorOffset === 0) show("/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import {
|
|||||||
dim,
|
dim,
|
||||||
fg,
|
fg,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js"
|
import { createEffect, createMemo, Match, Switch, type JSX, onMount, batch } from "solid-js"
|
||||||
import { useLocal } from "@tui/context/local"
|
import { useLocal } from "@tui/context/local"
|
||||||
import { SyntaxTheme, useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder } from "@tui/component/border"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
@@ -28,6 +28,7 @@ import { useExit } from "../../context/exit"
|
|||||||
import { Clipboard } from "../../util/clipboard"
|
import { Clipboard } from "../../util/clipboard"
|
||||||
import type { FilePart } from "@opencode-ai/sdk"
|
import type { FilePart } from "@opencode-ai/sdk"
|
||||||
import { TuiEvent } from "../../event"
|
import { TuiEvent } from "../../event"
|
||||||
|
import { iife } from "@/util/iife"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -60,7 +61,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
const history = usePromptHistory()
|
const history = usePromptHistory()
|
||||||
const command = useCommandDialog()
|
const command = useCommandDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const { theme } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
|
|
||||||
const textareaKeybindings = createMemo(() => {
|
const textareaKeybindings = createMemo(() => {
|
||||||
const newlineBindings = keybind.all.input_newline || []
|
const newlineBindings = keybind.all.input_newline || []
|
||||||
@@ -86,9 +87,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const fileStyleId = SyntaxTheme.getStyleId("extmark.file")!
|
const fileStyleId = syntax().getStyleId("extmark.file")!
|
||||||
const agentStyleId = SyntaxTheme.getStyleId("extmark.agent")!
|
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
||||||
const pasteStyleId = SyntaxTheme.getStyleId("extmark.paste")!
|
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||||
let promptPartTypeId: number
|
let promptPartTypeId: number
|
||||||
|
|
||||||
command.register(() => {
|
command.register(() => {
|
||||||
@@ -98,14 +99,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
keybind: "editor_open",
|
keybind: "editor_open",
|
||||||
value: "prompt.editor",
|
value: "prompt.editor",
|
||||||
onSelect: async (dialog) => {
|
onSelect: async (dialog, trigger) => {
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
const value = input.plainText
|
const value = trigger === "prompt" ? "" : input.plainText
|
||||||
input.clear()
|
|
||||||
setStore("prompt", {
|
|
||||||
input: "",
|
|
||||||
parts: [],
|
|
||||||
})
|
|
||||||
const content = await Editor.open({ value, renderer })
|
const content = await Editor.open({ value, renderer })
|
||||||
if (content) {
|
if (content) {
|
||||||
input.setText(content, { history: false })
|
input.setText(content, { history: false })
|
||||||
@@ -120,15 +116,11 @@ export function Prompt(props: PromptProps) {
|
|||||||
{
|
{
|
||||||
title: "Clear prompt",
|
title: "Clear prompt",
|
||||||
value: "prompt.clear",
|
value: "prompt.clear",
|
||||||
disabled: true,
|
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
|
disabled: true,
|
||||||
onSelect: (dialog) => {
|
onSelect: (dialog) => {
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
setStore("prompt", {
|
input.clear()
|
||||||
input: "",
|
|
||||||
parts: [],
|
|
||||||
})
|
|
||||||
setStore("extmarkToPartIndex", new Map())
|
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -139,6 +131,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
keybind: "input_submit",
|
keybind: "input_submit",
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
onSelect: (dialog) => {
|
onSelect: (dialog) => {
|
||||||
|
if (!input.focused) return
|
||||||
submit()
|
submit()
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -160,16 +153,29 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Interrupt session",
|
||||||
|
value: "session.interrupt",
|
||||||
|
keybind: "session_interrupt",
|
||||||
|
disabled: status() !== "working",
|
||||||
|
category: "Session",
|
||||||
|
onSelect: (dialog) => {
|
||||||
|
if (!props.sessionID) return
|
||||||
|
if (autocomplete.visible) return
|
||||||
|
if (!input.focused) return
|
||||||
|
sdk.client.session.abort({
|
||||||
|
path: {
|
||||||
|
id: props.sessionID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||||
setStore(
|
input.insertText(evt.properties.text)
|
||||||
"prompt",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.input += evt.properties.text
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -194,6 +200,16 @@ export function Prompt(props: PromptProps) {
|
|||||||
input.focus()
|
input.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
local.setInitialPrompt.listen((initialPrompt) => {
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", {
|
||||||
|
input: initialPrompt,
|
||||||
|
parts: [],
|
||||||
|
})
|
||||||
|
input.insertText(initialPrompt)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
||||||
})
|
})
|
||||||
@@ -348,8 +364,15 @@ export function Prompt(props: PromptProps) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
setStore("mode", "normal")
|
setStore("mode", "normal")
|
||||||
} else if (inputText.startsWith("/")) {
|
} else if (
|
||||||
const [command, ...args] = inputText.split(" ")
|
inputText.startsWith("/") &&
|
||||||
|
iife(() => {
|
||||||
|
const command = inputText.split(" ")[0].slice(1)
|
||||||
|
console.log(command)
|
||||||
|
return sync.data.command.some((x) => x.name === command)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
let [command, ...args] = inputText.split(" ")
|
||||||
sdk.client.session.command({
|
sdk.client.session.command({
|
||||||
path: {
|
path: {
|
||||||
id: sessionID,
|
id: sessionID,
|
||||||
@@ -530,6 +553,17 @@ export function Prompt(props: PromptProps) {
|
|||||||
setStore("extmarkToPartIndex", new Map())
|
setStore("extmarkToPartIndex", new Map())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (keybind.match("input_forward_delete", e) && store.prompt.input !== "") {
|
||||||
|
const cursorOffset = input.cursorOffset
|
||||||
|
if (cursorOffset < input.plainText.length) {
|
||||||
|
const text = input.plainText
|
||||||
|
const newText = text.slice(0, cursorOffset) + text.slice(cursorOffset + 1)
|
||||||
|
input.setText(newText)
|
||||||
|
input.cursorOffset = cursorOffset
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (keybind.match("app_exit", e)) {
|
if (keybind.match("app_exit", e)) {
|
||||||
await exit()
|
await exit()
|
||||||
return
|
return
|
||||||
@@ -552,10 +586,11 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (store.mode === "normal") autocomplete.onKeyDown(e)
|
if (store.mode === "normal") autocomplete.onKeyDown(e)
|
||||||
if (!autocomplete.visible) {
|
if (!autocomplete.visible) {
|
||||||
if (
|
if (
|
||||||
(e.name === "up" && input.cursorOffset === 0) ||
|
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
|
||||||
(e.name === "down" && input.cursorOffset === input.plainText.length)
|
(keybind.match("history_next", e) &&
|
||||||
|
input.cursorOffset === input.plainText.length)
|
||||||
) {
|
) {
|
||||||
const direction = e.name === "up" ? -1 : 1
|
const direction = keybind.match("history_previous", e) ? -1 : 1
|
||||||
const item = history.move(direction, input.plainText)
|
const item = history.move(direction, input.plainText)
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -569,20 +604,14 @@ export function Prompt(props: PromptProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.name === "up" && input.visualCursor.visualRow === 0) input.cursorOffset = 0
|
if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0)
|
||||||
if (e.name === "down" && input.visualCursor.visualRow === input.height - 1)
|
input.cursorOffset = 0
|
||||||
|
if (
|
||||||
|
keybind.match("history_next", e) &&
|
||||||
|
input.visualCursor.visualRow === input.height - 1
|
||||||
|
)
|
||||||
input.cursorOffset = input.plainText.length
|
input.cursorOffset = input.plainText.length
|
||||||
}
|
}
|
||||||
if (!autocomplete.visible) {
|
|
||||||
if (keybind.match("session_interrupt", e) && props.sessionID) {
|
|
||||||
sdk.client.session.abort({
|
|
||||||
path: {
|
|
||||||
id: props.sessionID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
onPaste={async (event: PasteEvent) => {
|
onPaste={async (event: PasteEvent) => {
|
||||||
@@ -599,14 +628,16 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
// trim ' from the beginning and end of the pasted content. just
|
// trim ' from the beginning and end of the pasted content. just
|
||||||
// ' and nothing else
|
// ' and nothing else
|
||||||
const filepath = pastedContent.replace(/^'+|'+$/g, "")
|
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
|
||||||
|
console.log(pastedContent, filepath)
|
||||||
try {
|
try {
|
||||||
const file = Bun.file(filepath)
|
const file = Bun.file(filepath)
|
||||||
if (file.type.startsWith("image/")) {
|
if (file.type.startsWith("image/")) {
|
||||||
|
event.preventDefault()
|
||||||
const content = await file
|
const content = await file
|
||||||
.arrayBuffer()
|
.arrayBuffer()
|
||||||
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
||||||
.catch(() => {})
|
.catch(console.error)
|
||||||
if (content) {
|
if (content) {
|
||||||
await pasteImage({
|
await pasteImage({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
@@ -619,7 +650,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||||
if (lineCount >= 5) {
|
if (
|
||||||
|
(lineCount >= 3 || pastedContent.length > 150) &&
|
||||||
|
!sync.data.config.experimental?.disable_paste_summary
|
||||||
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const currentOffset = input.visualCursor.offset
|
const currentOffset = input.visualCursor.offset
|
||||||
const virtualText = `[Pasted ~${lineCount} lines]`
|
const virtualText = `[Pasted ~${lineCount} lines]`
|
||||||
@@ -663,7 +697,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||||
focusedBackgroundColor={theme.backgroundElement}
|
focusedBackgroundColor={theme.backgroundElement}
|
||||||
cursorColor={theme.primary}
|
cursorColor={theme.primary}
|
||||||
syntaxStyle={SyntaxTheme}
|
syntaxStyle={syntax()}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box
|
<box
|
||||||
@@ -674,7 +708,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
></box>
|
></box>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text flexShrink={0} wrapMode="none">
|
<text flexShrink={0} wrapMode="none" fg={theme.text}>
|
||||||
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
|
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
|
||||||
<span style={{ bold: true }}>{local.model.parsed().model}</span>
|
<span style={{ bold: true }}>{local.model.parsed().model}</span>
|
||||||
</text>
|
</text>
|
||||||
@@ -684,15 +718,16 @@ export function Prompt(props: PromptProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={status() === "working"}>
|
<Match when={status() === "working"}>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
|
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.hint}>{props.hint!}</Match>
|
<Match when={props.hint}>{props.hint!}</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
|
{keybind.print("command_list")}{" "}
|
||||||
|
<span style={{ fg: theme.textMuted }}>commands</span>
|
||||||
</text>
|
</text>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
|
|||||||
return store.leader
|
return store.leader
|
||||||
},
|
},
|
||||||
parse(evt: ParsedKey): Keybind.Info {
|
parse(evt: ParsedKey): Keybind.Info {
|
||||||
|
if (evt.name === "\x1F")
|
||||||
|
return {
|
||||||
|
ctrl: true,
|
||||||
|
name: "_",
|
||||||
|
shift: false,
|
||||||
|
leader: false,
|
||||||
|
meta: false,
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ctrl: evt.ctrl,
|
ctrl: evt.ctrl,
|
||||||
name: evt.name,
|
name: evt.name,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal, type Setter } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -8,10 +8,7 @@ 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({
|
const [kvStore, setKvStore] = createStore<Record<string, any>>()
|
||||||
openrouter_warning: false,
|
|
||||||
theme: "opencode",
|
|
||||||
})
|
|
||||||
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
|
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
|
||||||
|
|
||||||
file
|
file
|
||||||
@@ -24,22 +21,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
|||||||
setReady(true)
|
setReady(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
get data() {
|
|
||||||
return kvStore
|
|
||||||
},
|
|
||||||
get ready() {
|
get ready() {
|
||||||
return ready()
|
return ready()
|
||||||
},
|
},
|
||||||
|
signal<T>(name: string, defaultValue: T) {
|
||||||
|
if (!kvStore[name]) setKvStore(name, defaultValue)
|
||||||
|
return [
|
||||||
|
function () {
|
||||||
|
return result.get(name)
|
||||||
|
},
|
||||||
|
function setter(next: Setter<T>) {
|
||||||
|
result.set(name, next)
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
},
|
||||||
|
get(key: string, defaultValue?: any) {
|
||||||
|
return kvStore[key] ?? defaultValue
|
||||||
|
},
|
||||||
set(key: string, value: any) {
|
set(key: string, value: any) {
|
||||||
setKvStore(key as any, value)
|
setKvStore(key, value)
|
||||||
Bun.write(
|
Bun.write(file, JSON.stringify(kvStore, null, 2))
|
||||||
file,
|
|
||||||
JSON.stringify({
|
|
||||||
[key]: value,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { Global } from "@/global"
|
|||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
|
import { createEventBus } from "@solid-primitives/event-bus"
|
||||||
|
import { Provider } from "@/provider/provider"
|
||||||
|
|
||||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
name: "Local",
|
name: "Local",
|
||||||
init: (props: { initialModel?: string; initialAgent?: string }) => {
|
init: (props: { initialModel?: string; initialAgent?: string; initialPrompt?: string }) => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
agent.set(props.initialAgent)
|
agent.set(props.initialAgent)
|
||||||
}
|
}
|
||||||
if (props.initialModel) {
|
if (props.initialModel) {
|
||||||
const [providerID, modelID] = props.initialModel.split("/")
|
const { providerID, modelID } = Provider.parseModel(props.initialModel)
|
||||||
if (!providerID || !modelID)
|
if (!providerID || !modelID)
|
||||||
return toast.show({
|
return toast.show({
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
@@ -147,18 +149,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
setModelStore("ready", true)
|
setModelStore("ready", true)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
Bun.write(
|
|
||||||
file,
|
|
||||||
JSON.stringify({
|
|
||||||
recent: modelStore.recent,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const fallbackModel = createMemo(() => {
|
const fallbackModel = createMemo(() => {
|
||||||
|
if (props.initialModel) {
|
||||||
|
const { providerID, modelID } = Provider.parseModel(props.initialModel)
|
||||||
|
if (isModelValid({ providerID, modelID })) {
|
||||||
|
return {
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sync.data.config.model) {
|
if (sync.data.config.model) {
|
||||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
const { providerID, modelID } = Provider.parseModel(sync.data.config.model)
|
||||||
if (isModelValid({ providerID, modelID })) {
|
if (isModelValid({ providerID, modelID })) {
|
||||||
return {
|
return {
|
||||||
providerID,
|
providerID,
|
||||||
@@ -206,6 +209,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
model: model.name ?? value.modelID,
|
model: model.name ?? value.modelID,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
cycle(direction: 1 | -1) {
|
||||||
|
const current = currentModel()
|
||||||
|
if (!current) return
|
||||||
|
const recent = modelStore.recent
|
||||||
|
const index = recent.findIndex(
|
||||||
|
(x) => x.providerID === current.providerID && x.modelID === current.modelID,
|
||||||
|
)
|
||||||
|
if (index === -1) return
|
||||||
|
let next = index + direction
|
||||||
|
if (next < 0) next = recent.length - 1
|
||||||
|
if (next >= recent.length) next = 0
|
||||||
|
const val = recent[next]
|
||||||
|
if (!val) return
|
||||||
|
setModelStore("model", agent.current().name, { ...val })
|
||||||
|
},
|
||||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
if (!isModelValid(model)) {
|
if (!isModelValid(model)) {
|
||||||
@@ -216,21 +234,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setModelStore("model", agent.current().name, model)
|
setModelStore("model", agent.current().name, model)
|
||||||
if (options?.recent) {
|
if (options?.recent) {
|
||||||
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
|
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
|
||||||
if (uniq.length > 5) uniq.pop()
|
if (uniq.length > 5) uniq.pop()
|
||||||
setModelStore("recent", uniq)
|
setModelStore("recent", uniq)
|
||||||
|
Bun.write(
|
||||||
|
file,
|
||||||
|
JSON.stringify({
|
||||||
|
recent: modelStore.recent,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const setInitialPrompt = createEventBus<string>()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (props.initialPrompt) setInitialPrompt.emit(props.initialPrompt)
|
||||||
|
})
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
model,
|
model,
|
||||||
agent,
|
agent,
|
||||||
|
get setInitialPrompt() {
|
||||||
|
return setInitialPrompt
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
Permission,
|
Permission,
|
||||||
LspStatus,
|
LspStatus,
|
||||||
McpStatus,
|
McpStatus,
|
||||||
|
FormatterStatus,
|
||||||
} from "@opencode-ai/sdk"
|
} from "@opencode-ai/sdk"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
@@ -42,6 +43,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
mcp: {
|
mcp: {
|
||||||
[key: string]: McpStatus
|
[key: string]: McpStatus
|
||||||
}
|
}
|
||||||
|
formatter: FormatterStatus[]
|
||||||
}>({
|
}>({
|
||||||
config: {},
|
config: {},
|
||||||
ready: false,
|
ready: false,
|
||||||
@@ -55,6 +57,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
part: {},
|
part: {},
|
||||||
lsp: [],
|
lsp: [],
|
||||||
mcp: {},
|
mcp: {},
|
||||||
|
formatter: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -134,7 +137,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
setStore(
|
||||||
|
"message",
|
||||||
|
event.properties.info.sessionID,
|
||||||
|
result.index,
|
||||||
|
reconcile(event.properties.info),
|
||||||
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
setStore(
|
setStore(
|
||||||
@@ -168,7 +176,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
setStore(
|
||||||
|
"part",
|
||||||
|
event.properties.part.messageID,
|
||||||
|
result.index,
|
||||||
|
reconcile(event.properties.part),
|
||||||
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
setStore(
|
setStore(
|
||||||
@@ -220,6 +233,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
|
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||||
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
|
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
|
||||||
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
|
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
|
||||||
|
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
|
||||||
])
|
])
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
@@ -245,11 +259,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return last.time.completed ? "idle" : "working"
|
return last.time.completed ? "idle" : "working"
|
||||||
},
|
},
|
||||||
async sync(sessionID: string) {
|
async sync(sessionID: string) {
|
||||||
|
const now = Date.now()
|
||||||
const [session, messages, todo] = await Promise.all([
|
const [session, messages, todo] = await Promise.all([
|
||||||
sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }),
|
sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }),
|
||||||
sdk.client.session.messages({ path: { id: sessionID } }),
|
sdk.client.session.messages({ path: { id: sessionID } }),
|
||||||
sdk.client.session.todo({ path: { id: sessionID } }),
|
sdk.client.session.todo({ path: { id: sessionID } }),
|
||||||
])
|
])
|
||||||
|
console.log("fetched in " + (Date.now() - now), sessionID)
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||||
@@ -262,6 +278,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
console.log("synced in " + (Date.now() - now), sessionID)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user