Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 web/desktop keywords
|
||||||
const versionPattern = /\b[v]?1\.0\.[x\d]\b/i;
|
const webPattern = /\b(web|desktop)\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,7 @@
|
|||||||
| 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) |
|
||||||
|
|||||||
@@ -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.23",
|
||||||
"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.23",
|
||||||
"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.23",
|
||||||
"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.23",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.0.2",
|
"version": "1.0.23",
|
||||||
"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,7 +166,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.0.2",
|
"version": "1.0.23",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -184,8 +184,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 +199,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 +243,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.0.2",
|
"version": "1.0.23",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.0.2",
|
"version": "1.0.23",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.81.0",
|
"@hey-api/openapi-ts": "0.81.0",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -274,7 +274,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.0.2",
|
"version": "1.0.23",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.0.2",
|
"version": "1.0.23",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -317,7 +317,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.0.2",
|
"version": "1.0.23",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -961,21 +961,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=="],
|
||||||
|
|
||||||
@@ -2221,7 +2221,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=="],
|
||||||
|
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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.23"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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,8 +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-"))
|
|
||||||
.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 })),
|
||||||
disabled: await Model.listDisabled(),
|
disabled: await Model.listDisabled(),
|
||||||
@@ -68,7 +75,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 +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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.23",
|
||||||
"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)}`
|
||||||
|
|||||||
@@ -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.23",
|
||||||
"$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.23",
|
||||||
"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.23",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
@@ -102,7 +102,7 @@ export function Code(props: Props) {
|
|||||||
const onSelectionChange = () => {
|
const onSelectionChange = () => {
|
||||||
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 = ctx.file.node(local.path)?.selection
|
||||||
@@ -112,7 +112,7 @@ export function Code(props: Props) {
|
|||||||
|
|
||||||
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)
|
||||||
@@ -155,7 +155,7 @@ export function Code(props: Props) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
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 = ctx.file.node(local.path)?.selection
|
||||||
|
|||||||
@@ -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),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,121 @@ 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 defaultStatus = "Working..."
|
||||||
|
const last = lastPart()
|
||||||
|
if (!last) return defaultStatus
|
||||||
|
|
||||||
|
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 defaultStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
const [status, setStatus] = createSignal(rawStatus())
|
||||||
|
let lastStatusChange = Date.now()
|
||||||
|
let statusTimeout: number | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const newStatus = rawStatus()
|
||||||
|
if (newStatus === status()) return
|
||||||
|
|
||||||
|
const timeSinceLastChange = Date.now() - lastStatusChange
|
||||||
|
|
||||||
|
if (timeSinceLastChange >= 1000) {
|
||||||
|
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()}</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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: (path) => {
|
||||||
if (!path) return
|
if (!path) return
|
||||||
@@ -307,34 +307,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<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={{
|
<div
|
||||||
"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">
|
>
|
||||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
<div class="flex items-center gap-x-2 grow min-w-0">
|
||||||
<div class="flex items-center text-14-regular">
|
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
<div class="flex items-center text-14-regular">
|
||||||
{getDirectory(i)}
|
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||||
</span>
|
{getDirectory(i)}
|
||||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
</span>
|
||||||
|
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
)}
|
||||||
</div>
|
</For>
|
||||||
)}
|
</Show>
|
||||||
</For>
|
|
||||||
</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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,18 @@ 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[]
|
// opened: string[]
|
||||||
active?: 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: [],
|
// opened: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const active = createMemo(() => {
|
// const active = createMemo(() => {
|
||||||
if (!store.active) return undefined
|
// if (!store.active) return undefined
|
||||||
return store.node[store.active]
|
// return store.node[store.active]
|
||||||
})
|
// })
|
||||||
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
// 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)))
|
||||||
|
|
||||||
@@ -281,16 +294,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,7 +325,10 @@ 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
|
||||||
@@ -341,22 +357,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
active,
|
|
||||||
opened,
|
|
||||||
node: (path: string) => store.node[path],
|
node: (path: string) => 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 +377,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,16 +414,51 @@ 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,
|
||||||
|
// active,
|
||||||
|
// opened,
|
||||||
|
// 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)
|
||||||
|
// },
|
||||||
|
// 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)
|
||||||
|
// },
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const session = (() => {
|
const session = (() => {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
active?: string
|
active?: string
|
||||||
activeMessage?: string
|
tabs: Record<
|
||||||
}>({})
|
string,
|
||||||
|
{
|
||||||
|
active?: string
|
||||||
|
opened: string[]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}>({
|
||||||
|
tabs: {
|
||||||
|
"": {
|
||||||
|
opened: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const active = createMemo(() => {
|
const active = createMemo(() => {
|
||||||
if (!store.active) return undefined
|
if (!store.active) return undefined
|
||||||
@@ -439,134 +468,69 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!store.active) return
|
if (!store.active) return
|
||||||
sync.session.sync(store.active)
|
sync.session.sync(store.active)
|
||||||
})
|
|
||||||
|
|
||||||
const valid = (part: Part) => {
|
if (!store.tabs[store.active]) {
|
||||||
if (!part) return false
|
setStore("tabs", store.active, {
|
||||||
switch (part.type) {
|
opened: [],
|
||||||
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(() => {
|
const tabs = createMemo(() => store.tabs[store.active ?? ""])
|
||||||
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 {
|
return {
|
||||||
active,
|
active,
|
||||||
activeMessage,
|
|
||||||
lastUserMessage,
|
|
||||||
cost,
|
|
||||||
last,
|
|
||||||
model,
|
|
||||||
tokens,
|
|
||||||
context,
|
|
||||||
messages,
|
|
||||||
messagesWithValidParts,
|
|
||||||
userMessages,
|
|
||||||
// working,
|
|
||||||
getMessageText,
|
|
||||||
setActive(sessionId: string | undefined) {
|
setActive(sessionId: string | undefined) {
|
||||||
setStore("active", sessionId)
|
setStore("active", sessionId)
|
||||||
setStore("activeMessage", undefined)
|
|
||||||
},
|
},
|
||||||
clearActive() {
|
clearActive() {
|
||||||
setStore("active", undefined)
|
setStore("active", undefined)
|
||||||
setStore("activeMessage", undefined)
|
|
||||||
},
|
},
|
||||||
setActiveMessage(messageId: string | undefined) {
|
tabs,
|
||||||
setStore("activeMessage", messageId)
|
copyTabs(from: string, to: string) {
|
||||||
|
setStore("tabs", to, {
|
||||||
|
opened: store.tabs[from]?.opened ?? [],
|
||||||
|
})
|
||||||
},
|
},
|
||||||
clearActiveMessage() {
|
setActiveTab(tab: string | undefined) {
|
||||||
setStore("activeMessage", undefined)
|
setStore("tabs", store.active ?? "", "active", tab)
|
||||||
|
},
|
||||||
|
async open(tab: string) {
|
||||||
|
if (tab !== "chat") {
|
||||||
|
await file.open(tab)
|
||||||
|
}
|
||||||
|
if (!tabs()?.opened?.includes(tab)) {
|
||||||
|
setStore("tabs", store.active ?? "", "opened", [...(tabs()?.opened ?? []), tab])
|
||||||
|
}
|
||||||
|
setStore("tabs", store.active ?? "", "active", tab)
|
||||||
|
},
|
||||||
|
close(tab: string) {
|
||||||
|
batch(() => {
|
||||||
|
if (!tabs()) return
|
||||||
|
setStore("tabs", store.active ?? "", {
|
||||||
|
active: tabs()!.active,
|
||||||
|
opened: tabs()!.opened.filter((x) => x !== tab),
|
||||||
|
})
|
||||||
|
if (tabs()!.active === tab) {
|
||||||
|
const index = tabs()!.opened.findIndex((f) => f === tab)
|
||||||
|
const previous = tabs()!.opened[Math.max(0, index - 1)]
|
||||||
|
setStore("tabs", store.active ?? "", "active", previous)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
move(tab: string, to: number) {
|
||||||
|
if (!tabs()) return
|
||||||
|
const index = tabs()!.opened.findIndex((f) => f === tab)
|
||||||
|
if (index === -1) return
|
||||||
|
setStore(
|
||||||
|
"tabs",
|
||||||
|
store.active ?? "",
|
||||||
|
"opened",
|
||||||
|
produce((opened) => {
|
||||||
|
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// setStore("node", path, "pinned", true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -589,9 +553,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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import Home from "@/pages"
|
|||||||
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") ? `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)) {
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import {
|
|||||||
Accordion,
|
Accordion,
|
||||||
Diff,
|
Diff,
|
||||||
Collapsible,
|
Collapsible,
|
||||||
Part,
|
|
||||||
DiffChanges,
|
DiffChanges,
|
||||||
ProgressCircle,
|
|
||||||
Message,
|
Message,
|
||||||
Typewriter,
|
Typewriter,
|
||||||
|
Card,
|
||||||
|
Code,
|
||||||
} from "@opencode-ai/ui"
|
} from "@opencode-ai/ui"
|
||||||
import { FileIcon } from "@/ui"
|
import { FileIcon } from "@/ui"
|
||||||
import FileTree from "@/components/file-tree"
|
import FileTree from "@/components/file-tree"
|
||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
} from "@thisbeyond/solid-dnd"
|
} from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { Code } from "@/components/code"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||||
@@ -54,14 +53,6 @@ export default function Page() {
|
|||||||
let messageScrollElement!: HTMLDivElement
|
let messageScrollElement!: HTMLDivElement
|
||||||
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
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"
|
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -91,26 +82,26 @@ export default function Page() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (local.file.active()) {
|
// if (local.file.active()) {
|
||||||
const active = local.file.active()!
|
// const active = local.file.active()!
|
||||||
if (event.key === "Enter" && active.selection) {
|
// if (event.key === "Enter" && active.selection) {
|
||||||
local.context.add({
|
// local.context.add({
|
||||||
type: "file",
|
// type: "file",
|
||||||
path: active.path,
|
// path: active.path,
|
||||||
selection: { ...active.selection },
|
// selection: { ...active.selection },
|
||||||
})
|
// })
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
if (event.getModifierState(MOD)) {
|
// if (event.getModifierState(MOD)) {
|
||||||
if (event.key.toLowerCase() === "a") {
|
// if (event.key.toLowerCase() === "a") {
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
if (event.key.toLowerCase() === "c") {
|
// if (event.key.toLowerCase() === "c") {
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||||
inputRef?.focus()
|
inputRef?.focus()
|
||||||
@@ -140,21 +131,22 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateChange = (dir: 1 | -1) => {
|
// const navigateChange = (dir: 1 | -1) => {
|
||||||
const active = local.file.active()
|
// const active = local.file.active()
|
||||||
if (!active) return
|
// if (!active) return
|
||||||
const current = local.file.changeIndex(active.path)
|
// const current = local.file.changeIndex(active.path)
|
||||||
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
|
// const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
|
||||||
local.file.setChangeIndex(active.path, next)
|
// local.file.setChangeIndex(active.path, next)
|
||||||
}
|
// }
|
||||||
|
|
||||||
const handleTabChange = (path: string) => {
|
const handleTabChange = (path: string) => {
|
||||||
if (path === "chat" || path === "review") return
|
local.session.setActiveTab(path)
|
||||||
local.file.open(path)
|
if (path === "chat") return
|
||||||
|
local.session.open(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTabClose = (file: LocalFile) => {
|
const handleTabClose = (file: LocalFile) => {
|
||||||
local.file.close(file.path)
|
local.session.close(file.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragStart = (event: unknown) => {
|
const handleDragStart = (event: unknown) => {
|
||||||
@@ -166,11 +158,11 @@ export default function Page() {
|
|||||||
const handleDragOver = (event: DragEvent) => {
|
const handleDragOver = (event: DragEvent) => {
|
||||||
const { draggable, droppable } = event
|
const { draggable, droppable } = event
|
||||||
if (draggable && droppable) {
|
if (draggable && droppable) {
|
||||||
const currentFiles = local.file.opened().map((file) => file.path)
|
const currentFiles = local.session.tabs()?.opened.map((file) => file)
|
||||||
const fromIndex = currentFiles.indexOf(draggable.id.toString())
|
const fromIndex = currentFiles?.indexOf(draggable.id.toString())
|
||||||
const toIndex = currentFiles.indexOf(droppable.id.toString())
|
const toIndex = currentFiles?.indexOf(droppable.id.toString())
|
||||||
if (fromIndex !== toIndex) {
|
if (fromIndex !== toIndex && toIndex !== undefined) {
|
||||||
local.file.move(draggable.id.toString(), toIndex)
|
local.session.move(draggable.id.toString(), toIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,20 +171,20 @@ export default function Page() {
|
|||||||
setActiveItem(undefined)
|
setActiveItem(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollDiffItem = (element: HTMLElement) => {
|
// const scrollDiffItem = (element: HTMLElement) => {
|
||||||
element.scrollIntoView({ block: "start", behavior: "instant" })
|
// element.scrollIntoView({ block: "start", behavior: "instant" })
|
||||||
}
|
// }
|
||||||
|
|
||||||
const handleDiffTriggerClick = (event: MouseEvent) => {
|
const handleDiffTriggerClick = (event: MouseEvent) => {
|
||||||
// disabling scroll to diff for now
|
// disabling scroll to diff for now
|
||||||
return
|
return
|
||||||
const target = event.currentTarget as HTMLElement
|
// const target = event.currentTarget as HTMLElement
|
||||||
queueMicrotask(() => {
|
// queueMicrotask(() => {
|
||||||
if (target.getAttribute("aria-expanded") !== "true") return
|
// if (target.getAttribute("aria-expanded") !== "true") return
|
||||||
const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
|
// const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
|
||||||
if (!item) return
|
// if (!item) return
|
||||||
scrollDiffItem(item)
|
// scrollDiffItem(item)
|
||||||
})
|
// })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePromptSubmit = async (parts: ContentPart[]) => {
|
const handlePromptSubmit = async (parts: ContentPart[]) => {
|
||||||
@@ -205,6 +197,10 @@ export default function Page() {
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
local.session.setActive(session.id)
|
local.session.setActive(session.id)
|
||||||
|
if (!existingSession) {
|
||||||
|
local.session.copyTabs("", session.id)
|
||||||
|
}
|
||||||
|
local.session.setActiveTab(undefined)
|
||||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||||
|
|
||||||
const text = parts.map((part) => part.content).join("")
|
const text = parts.map((part) => part.content).join("")
|
||||||
@@ -427,20 +423,26 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
<DragDropSensors />
|
<DragDropSensors />
|
||||||
<ConstrainDragYAxis />
|
<ConstrainDragYAxis />
|
||||||
<Tabs onChange={handleTabChange}>
|
<Tabs value={local.session.tabs()?.active ?? "chat"} onChange={handleTabChange}>
|
||||||
<div class="sticky top-0 shrink-0 flex">
|
<div class="sticky top-0 shrink-0 flex">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
|
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
|
||||||
<div>Chat</div>
|
<div>Chat</div>
|
||||||
<Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5">
|
{/* <Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5"> */}
|
||||||
<ProgressCircle percentage={local.session.context() ?? 0} />
|
{/* <ProgressCircle percentage={local.session.context() ?? 0} /> */}
|
||||||
<div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div>
|
{/* <div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div> */}
|
||||||
</Tooltip>
|
{/* </Tooltip> */}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
||||||
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
|
<SortableProvider ids={local.session.tabs()?.opened ?? []}>
|
||||||
<For each={local.file.opened()}>
|
<For each={local.session.tabs()?.opened ?? []}>
|
||||||
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
|
{(file) => (
|
||||||
|
<SortableTab
|
||||||
|
file={local.file.node(file)}
|
||||||
|
onTabClick={handleFileClick}
|
||||||
|
onTabClose={handleTabClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||||
@@ -452,64 +454,6 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.List>
|
</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>
|
</div>
|
||||||
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
|
<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">
|
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
|
||||||
@@ -537,206 +481,289 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(activeSession) => (
|
{(session) => {
|
||||||
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
const [store, setStore] = createStore<{
|
||||||
<div class="flex-1 min-h-0">
|
messageId?: string
|
||||||
<Show when={local.session.userMessages().length > 1}>
|
}>()
|
||||||
<ul
|
|
||||||
role="list"
|
const messages = createMemo(() => sync.data.message[session().id] ?? [])
|
||||||
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
|
const userMessages = createMemo(() =>
|
||||||
>
|
messages()
|
||||||
<For each={local.session.userMessages()}>
|
.filter((m) => m.role === "user")
|
||||||
{(message) => {
|
.sort((a, b) => b.id.localeCompare(a.id)),
|
||||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
)
|
||||||
const working = createMemo(() => !message.summary?.title)
|
const lastUserMessage = createMemo(() => {
|
||||||
return (
|
return userMessages()?.at(0)
|
||||||
<li class="group/li flex items-center self-stretch">
|
})
|
||||||
<button
|
const activeMessage = createMemo(() => {
|
||||||
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
|
if (!store.messageId) return lastUserMessage()
|
||||||
onClick={() => local.session.setActiveMessage(message.id)}
|
return userMessages()?.find((m) => m.id === store.messageId)
|
||||||
>
|
})
|
||||||
<Switch>
|
|
||||||
<Match when={working()}>
|
return (
|
||||||
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
|
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
||||||
</Match>
|
<div class="flex-1 min-h-0">
|
||||||
<Match when={true}>
|
<Show when={userMessages().length > 1}>
|
||||||
<DiffChanges diff={diffs()} variant="bars" />
|
<ul
|
||||||
</Match>
|
role="list"
|
||||||
</Switch>
|
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
|
||||||
<div
|
>
|
||||||
data-active={local.session.activeMessage()?.id === message.id}
|
<For each={userMessages()}>
|
||||||
classList={{
|
{(message) => {
|
||||||
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
const assistantMessages = createMemo(() => {
|
||||||
}}
|
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={() => setStore("messageId", message.id)}
|
||||||
>
|
>
|
||||||
{message.summary?.title ?? local.session.getMessageText(message)}
|
<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={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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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={userMessages()}>
|
||||||
|
{(message) => {
|
||||||
|
const isActive = createMemo(() => activeMessage()?.id === message.id)
|
||||||
|
const [titled, setTitled] = createSignal(!!message.summary?.title)
|
||||||
|
const assistantMessages = createMemo(() => {
|
||||||
|
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 [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 hasToolPart = createMemo(() =>
|
||||||
|
assistantMessages()
|
||||||
|
?.flatMap((m) => sync.data.part[m.id])
|
||||||
|
.some((p) => p?.type === "tool"),
|
||||||
|
)
|
||||||
|
const working = createMemo(() => !summary() && !error())
|
||||||
|
|
||||||
|
// allowing time for the animations to finish
|
||||||
|
createEffect(() => {
|
||||||
|
title()
|
||||||
|
setTimeout(() => setTitled(!!title()), 10_000)
|
||||||
|
})
|
||||||
|
createEffect(() => {
|
||||||
|
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={title()}
|
||||||
|
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
|
||||||
|
{title()}
|
||||||
|
</h1>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<div class="-mt-8">
|
||||||
</li>
|
<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={diffs().length}>Summary</Match>
|
||||||
|
<Match when={true}>Response</Match>
|
||||||
|
</Switch>
|
||||||
|
</h2>
|
||||||
|
<Show when={summary()}>
|
||||||
|
{(summary) => (
|
||||||
|
<Markdown
|
||||||
|
classList={{ "[&>*]:fade-up-text": !diffs().length }}
|
||||||
|
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>
|
||||||
|
<Show when={error() && !expanded()}>
|
||||||
|
<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={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 details</Match>
|
||||||
|
<Match when={!expanded()}>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>
|
</For>
|
||||||
</ul>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
)
|
||||||
)}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
|
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
|
||||||
<For each={local.file.opened()}>
|
<For each={local.session.tabs()?.opened}>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<Tabs.Content value={file.path} class="select-text">
|
<Tabs.Content value={file} class="select-text">
|
||||||
{(() => {
|
{(() => {
|
||||||
const view = local.file.view(file.path)
|
{
|
||||||
const showRaw = view === "raw" || !file.content?.diff
|
/* const view = local.file.view(file) */
|
||||||
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
|
}
|
||||||
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
|
{
|
||||||
|
/* const showRaw = view === "raw" || !file.content?.diff */
|
||||||
|
}
|
||||||
|
{
|
||||||
|
/* const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") */
|
||||||
|
}
|
||||||
|
const node = local.file.node(file)
|
||||||
|
return (
|
||||||
|
<Code
|
||||||
|
file={{ name: node.path, contents: node.content?.content ?? "" }}
|
||||||
|
disableFileHeader
|
||||||
|
overflow="scroll"
|
||||||
|
class="pt-3 pb-40"
|
||||||
|
/>
|
||||||
|
)
|
||||||
})()}
|
})()}
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
)}
|
)}
|
||||||
@@ -800,10 +827,10 @@ export default function Page() {
|
|||||||
title="Select file"
|
title="Select file"
|
||||||
placeholder="Search files"
|
placeholder="Search files"
|
||||||
emptyMessage="No files found"
|
emptyMessage="No files found"
|
||||||
items={local.file.search}
|
items={local.file.searchFiles}
|
||||||
key={(x) => x}
|
key={(x) => x}
|
||||||
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
||||||
onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
|
onSelect={(x) => (x ? local.session.open(x) : undefined)}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.0.2",
|
"version": "1.0.23",
|
||||||
"$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.23",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -54,8 +54,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",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@pierre/precision-diffs": "catalog:",
|
"@pierre/precision-diffs": "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",
|
||||||
|
|||||||
@@ -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,10 +125,8 @@ 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() {",
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ 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"
|
||||||
@@ -89,7 +87,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 +113,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 +196,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,
|
||||||
@@ -375,11 +381,6 @@ 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",
|
||||||
@@ -404,7 +405,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) {
|
||||||
@@ -585,14 +587,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 +659,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
|
||||||
}
|
|
||||||
@@ -23,21 +23,84 @@ 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 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 +116,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 +157,7 @@ export function tui(input: {
|
|||||||
targetFps: 60,
|
targetFps: 60,
|
||||||
gatherStats: false,
|
gatherStats: false,
|
||||||
exitOnCtrlC: false,
|
exitOnCtrlC: false,
|
||||||
|
useKittyKeyboard: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -104,9 +176,11 @@ function App() {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [sessionExists, setSessionExists] = createSignal(false)
|
const [sessionExists, setSessionExists] = createSignal(false)
|
||||||
const { theme } = useTheme()
|
const { theme, mode, setMode } = 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
|
||||||
@@ -168,6 +242,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 +306,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 +322,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 +358,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",
|
||||||
@@ -274,8 +381,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" })
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -304,7 +412,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 +429,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 +439,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"
|
||||||
@@ -198,7 +210,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 +222,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 +238,22 @@ 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: "/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 +269,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 +323,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: ["display", "description", (obj) => obj.aliases?.join(" ") ?? ""],
|
||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
return result.map((arr) => arr.obj)
|
return result.map((arr) => arr.obj)
|
||||||
@@ -301,9 +351,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 +369,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 +378,63 @@ 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"
|
||||||
@@ -60,7 +60,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 +86,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 +98,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 +115,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 +130,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 +152,27 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Interrupt session",
|
||||||
|
value: "session.interrupt",
|
||||||
|
keybind: "session_interrupt",
|
||||||
|
category: "Session",
|
||||||
|
disabled: true,
|
||||||
|
onSelect: (dialog) => {
|
||||||
|
if (!props.sessionID) 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 +197,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")
|
||||||
})
|
})
|
||||||
@@ -530,6 +543,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 +576,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,8 +594,12 @@ 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 (!autocomplete.visible) {
|
||||||
@@ -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,7 @@ 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 >= 5 && !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 +694,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 +705,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 +715,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()
|
||||||
@@ -220,6 +223,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 = {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
import { Prompt } from "@tui/component/prompt"
|
||||||
import { createEffect, createMemo, Match, Show, Switch, type ParentProps } from "solid-js"
|
import { createMemo, Match, Show, Switch, type ParentProps } from "solid-js"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { useKeybind } from "../context/keybind"
|
import { useKeybind } from "../context/keybind"
|
||||||
import type { KeybindsConfig } from "@opencode-ai/sdk"
|
import type { KeybindsConfig } from "@opencode-ai/sdk"
|
||||||
@@ -7,27 +7,18 @@ import { Logo } from "../component/logo"
|
|||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { useSync } from "../context/sync"
|
import { useSync } from "../context/sync"
|
||||||
import { Toast } from "../ui/toast"
|
import { Toast } from "../ui/toast"
|
||||||
import { useDialog } from "../ui/dialog"
|
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dialog = useDialog()
|
|
||||||
const mcpError = createMemo(() => {
|
const mcpError = createMemo(() => {
|
||||||
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
||||||
})
|
})
|
||||||
let promptRef: PromptRef | undefined = undefined
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
dialog.allClosedEvent.listen(() => {
|
|
||||||
promptRef?.focus()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const Hint = (
|
const Hint = (
|
||||||
<Show when={Object.keys(sync.data.mcp).length > 0}>
|
<Show when={Object.keys(sync.data.mcp).length > 0}>
|
||||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={mcpError()}>
|
<Match when={mcpError()}>
|
||||||
<span style={{ fg: theme.error }}>•</span> mcp errors{" "}
|
<span style={{ fg: theme.error }}>•</span> mcp errors{" "}
|
||||||
@@ -64,7 +55,7 @@ export function Home() {
|
|||||||
<HelpRow keybind="agent_cycle">Switch agent</HelpRow>
|
<HelpRow keybind="agent_cycle">Switch agent</HelpRow>
|
||||||
</box>
|
</box>
|
||||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
|
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
|
||||||
<Prompt hint={Hint} ref={(r) => (promptRef = r)} />
|
<Prompt hint={Hint} />
|
||||||
</box>
|
</box>
|
||||||
<Toast />
|
<Toast />
|
||||||
</box>
|
</box>
|
||||||
@@ -76,7 +67,7 @@ function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
|
|||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" justifyContent="space-between" width="100%">
|
<box flexDirection="row" justifyContent="space-between" width="100%">
|
||||||
<text>{props.children}</text>
|
<text fg={theme.text}>{props.children}</text>
|
||||||
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
|
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,38 @@
|
|||||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
import { type Accessor, createMemo, Match, Show, Switch } from "solid-js"
|
||||||
import { useRouteData } from "@tui/context/route"
|
import { useRouteData } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { pipe, sumBy } from "remeda"
|
import { pipe, sumBy } from "remeda"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder } from "@tui/component/border"
|
||||||
import type { AssistantMessage } from "@opencode-ai/sdk"
|
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
|
||||||
|
|
||||||
|
const Title = (props: { session: Accessor<Session> }) => {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
return (
|
||||||
|
<text fg={theme.text}>
|
||||||
|
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
|
||||||
|
<span style={{ bold: true }}>{props.session().title}</span>
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Accessor<string> }) => {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
return (
|
||||||
|
<Show when={props.context()}>
|
||||||
|
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||||
|
{props.context()} ({props.cost()})
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const { theme } = useTheme()
|
|
||||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||||
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
const total = pipe(
|
const total = pipe(
|
||||||
@@ -43,6 +64,8 @@ export function Header() {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -51,31 +74,34 @@ export function Header() {
|
|||||||
borderColor={theme.backgroundElement}
|
borderColor={theme.backgroundElement}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
<text>
|
<Show
|
||||||
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
|
when={shareEnabled()}
|
||||||
<span style={{ bold: true }}>{session().title}</span>
|
fallback={
|
||||||
</text>
|
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
<Title session={session} />
|
||||||
<box flexGrow={1} flexShrink={1}>
|
<ContextInfo context={context} cost={cost} />
|
||||||
<Switch>
|
</box>
|
||||||
<Match when={session().share?.url}>
|
}
|
||||||
<text fg={theme.textMuted} wrapMode="word">
|
>
|
||||||
{session().share!.url}
|
<Title session={session} />
|
||||||
</text>
|
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||||
</Match>
|
<box flexGrow={1} flexShrink={1}>
|
||||||
<Match when={true}>
|
<Switch>
|
||||||
<text wrapMode="word">
|
<Match when={session().share?.url}>
|
||||||
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
|
<text fg={theme.textMuted} wrapMode="word">
|
||||||
</text>
|
{session().share!.url}
|
||||||
</Match>
|
</text>
|
||||||
</Switch>
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<text fg={theme.text} wrapMode="word">
|
||||||
|
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
|
||||||
|
</text>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</box>
|
||||||
|
<ContextInfo context={context} cost={cost} />
|
||||||
</box>
|
</box>
|
||||||
<Show when={context()}>
|
</Show>
|
||||||
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
|
||||||
{context()} ({cost()})
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import {
|
|||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { useRouteData } from "@tui/context/route"
|
import { useRoute, useRouteData } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder } from "@tui/component/border"
|
||||||
import { SyntaxTheme, useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core"
|
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core"
|
||||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||||
import type {
|
import type {
|
||||||
@@ -62,7 +62,9 @@ import { DialogTimeline } from "./dialog-timeline"
|
|||||||
import { Sidebar } from "./sidebar"
|
import { Sidebar } from "./sidebar"
|
||||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||||
import parsers from "../../../../../../parsers-config.ts"
|
import parsers from "../../../../../../parsers-config.ts"
|
||||||
import { Toast } from "../../ui/toast"
|
import { Clipboard } from "../../util/clipboard"
|
||||||
|
import { Toast, useToast } from "../../ui/toast"
|
||||||
|
import { useKV } from "../../context/kv.tsx"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
@@ -79,7 +81,9 @@ function use() {
|
|||||||
|
|
||||||
export function Session() {
|
export function Session() {
|
||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
|
const { navigate } = useRoute()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const kv = useKV()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||||
@@ -90,7 +94,7 @@ export function Session() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">("auto")
|
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
|
||||||
const [conceal, setConceal] = createSignal(true)
|
const [conceal, setConceal] = createSignal(true)
|
||||||
|
|
||||||
const wide = createMemo(() => dimensions().width > 120)
|
const wide = createMemo(() => dimensions().width > 120)
|
||||||
@@ -99,18 +103,14 @@ export function Session() {
|
|||||||
|
|
||||||
createEffect(() => sync.session.sync(route.sessionID))
|
createEffect(() => sync.session.sync(route.sessionID))
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
let scroll: ScrollBoxRenderable
|
let scroll: ScrollBoxRenderable
|
||||||
let prompt: PromptRef
|
let prompt: PromptRef
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
dialog.allClosedEvent.listen(() => {
|
|
||||||
prompt.focus()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (dialog.stack.length > 0) return
|
if (dialog.stack.length > 0) return
|
||||||
|
|
||||||
@@ -150,6 +150,23 @@ export function Session() {
|
|||||||
|
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
|
||||||
|
function moveChild(direction: number) {
|
||||||
|
const parentID = session()?.parentID ?? session()?.id
|
||||||
|
let children = sync.data.session
|
||||||
|
.filter((x) => x.parentID === parentID || x.id === parentID)
|
||||||
|
.toSorted((b, a) => a.id.localeCompare(b.id))
|
||||||
|
if (children.length === 1) return
|
||||||
|
let next = children.findIndex((x) => x.id === session()?.id) + direction
|
||||||
|
if (next >= children.length) next = 0
|
||||||
|
if (next < 0) next = children.length - 1
|
||||||
|
if (children[next]) {
|
||||||
|
navigate({
|
||||||
|
type: "session",
|
||||||
|
sessionID: children[next].id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const command = useCommandDialog()
|
const command = useCommandDialog()
|
||||||
command.register(() => [
|
command.register(() => [
|
||||||
{
|
{
|
||||||
@@ -195,12 +212,20 @@ export function Session() {
|
|||||||
keybind: "session_share",
|
keybind: "session_share",
|
||||||
disabled: !!session()?.share?.url,
|
disabled: !!session()?.share?.url,
|
||||||
category: "Session",
|
category: "Session",
|
||||||
onSelect: (dialog) => {
|
onSelect: async (dialog) => {
|
||||||
sdk.client.session.share({
|
await sdk.client.session
|
||||||
path: {
|
.share({
|
||||||
id: route.sessionID,
|
path: {
|
||||||
},
|
id: route.sessionID,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
.then((res) =>
|
||||||
|
Clipboard.copy(res.data!.share!.url).catch(() =>
|
||||||
|
toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
|
||||||
|
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -240,7 +265,9 @@ export function Session() {
|
|||||||
prompt.set(
|
prompt.set(
|
||||||
parts.reduce(
|
parts.reduce(
|
||||||
(agg, part) => {
|
(agg, part) => {
|
||||||
if (part.type === "text") agg.input += part.text
|
if (part.type === "text") {
|
||||||
|
if (!part.synthetic) agg.input += part.text
|
||||||
|
}
|
||||||
if (part.type === "file") agg.parts.push(part)
|
if (part.type === "file") agg.parts.push(part)
|
||||||
return agg
|
return agg
|
||||||
},
|
},
|
||||||
@@ -291,6 +318,8 @@ export function Session() {
|
|||||||
if (prev === "show") return "hide"
|
if (prev === "show") return "hide"
|
||||||
return "show"
|
return "show"
|
||||||
})
|
})
|
||||||
|
if (sidebar() === "show") kv.set("sidebar", "auto")
|
||||||
|
if (sidebar() === "hide") kv.set("sidebar", "hide")
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -370,6 +399,74 @@ export function Session() {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Copy last assistant message",
|
||||||
|
value: "messages.copy",
|
||||||
|
keybind: "messages_copy",
|
||||||
|
category: "Session",
|
||||||
|
onSelect: (dialog) => {
|
||||||
|
const lastAssistantMessage = messages().findLast((msg) => msg.role === "assistant")
|
||||||
|
if (!lastAssistantMessage) {
|
||||||
|
toast.show({ message: "No assistant messages found", variant: "error" })
|
||||||
|
dialog.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = sync.data.part[lastAssistantMessage.id] ?? []
|
||||||
|
const textParts = parts.filter((part) => part.type === "text")
|
||||||
|
if (textParts.length === 0) {
|
||||||
|
toast.show({ message: "No text parts found in last assistant message", variant: "error" })
|
||||||
|
dialog.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = textParts
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("\n")
|
||||||
|
.trim()
|
||||||
|
if (!text) {
|
||||||
|
toast.show({
|
||||||
|
message: "No text content found in last assistant message",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
dialog.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(text)
|
||||||
|
const base64 = Buffer.from(text).toString("base64")
|
||||||
|
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||||
|
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||||
|
/* @ts-expect-error */
|
||||||
|
renderer.writeOut(finalOsc52)
|
||||||
|
Clipboard.copy(text)
|
||||||
|
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
|
||||||
|
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Next child session",
|
||||||
|
value: "session.child.next",
|
||||||
|
keybind: "session_child_cycle",
|
||||||
|
category: "Session",
|
||||||
|
disabled: true,
|
||||||
|
onSelect: (dialog) => {
|
||||||
|
moveChild(1)
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Previous child session",
|
||||||
|
value: "session.child.previous",
|
||||||
|
keybind: "session_child_cycle_reverse",
|
||||||
|
category: "Session",
|
||||||
|
disabled: true,
|
||||||
|
onSelect: (dialog) => {
|
||||||
|
moveChild(-1)
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const revert = createMemo(() => {
|
const revert = createMemo(() => {
|
||||||
@@ -383,22 +480,26 @@ export function Session() {
|
|||||||
const diffText = s.revert?.diff || ""
|
const diffText = s.revert?.diff || ""
|
||||||
if (!diffText) return []
|
if (!diffText) return []
|
||||||
|
|
||||||
const patches = parsePatch(diffText)
|
try {
|
||||||
return patches.map((patch) => {
|
const patches = parsePatch(diffText)
|
||||||
const filename = patch.newFileName || patch.oldFileName || "unknown"
|
return patches.map((patch) => {
|
||||||
const cleanFilename = filename.replace(/^[ab]\//, "")
|
const filename = patch.newFileName || patch.oldFileName || "unknown"
|
||||||
return {
|
const cleanFilename = filename.replace(/^[ab]\//, "")
|
||||||
filename: cleanFilename,
|
return {
|
||||||
additions: patch.hunks.reduce(
|
filename: cleanFilename,
|
||||||
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
|
additions: patch.hunks.reduce(
|
||||||
0,
|
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
|
||||||
),
|
0,
|
||||||
deletions: patch.hunks.reduce(
|
),
|
||||||
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
|
deletions: patch.hunks.reduce(
|
||||||
0,
|
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
|
||||||
),
|
0,
|
||||||
}
|
),
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -431,6 +532,34 @@ export function Session() {
|
|||||||
>
|
>
|
||||||
<box flexGrow={1} gap={1}>
|
<box flexGrow={1} gap={1}>
|
||||||
<Show when={session()}>
|
<Show when={session()}>
|
||||||
|
<Show when={session().parentID}>
|
||||||
|
<box
|
||||||
|
backgroundColor={theme.backgroundPanel}
|
||||||
|
justifyContent="space-between"
|
||||||
|
flexDirection="row"
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
flexShrink={0}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
Previous{" "}
|
||||||
|
<span style={{ fg: theme.textMuted }}>
|
||||||
|
{keybind.print("session_child_cycle_reverse")}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
<b>Viewing subagent session</b>
|
||||||
|
</text>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
<span style={{ fg: theme.textMuted }}>
|
||||||
|
{keybind.print("session_child_cycle")}
|
||||||
|
</span>{" "}
|
||||||
|
Next
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
<Show when={!sidebarVisible()}>
|
<Show when={!sidebarVisible()}>
|
||||||
<Header />
|
<Header />
|
||||||
</Show>
|
</Show>
|
||||||
@@ -614,7 +743,7 @@ function UserMessage(props: {
|
|||||||
borderColor={color()}
|
borderColor={color()}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
<text>{text()?.text}</text>
|
<text fg={theme.text}>{text()?.text}</text>
|
||||||
<Show when={files().length}>
|
<Show when={files().length}>
|
||||||
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
@@ -625,7 +754,7 @@ function UserMessage(props: {
|
|||||||
return theme.secondary
|
return theme.secondary
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<span style={{ bg: bg(), fg: theme.background }}>
|
<span style={{ bg: bg(), fg: theme.background }}>
|
||||||
{" "}
|
{" "}
|
||||||
{MIME_BADGE[file.mime] ?? file.mime}{" "}
|
{MIME_BADGE[file.mime] ?? file.mime}{" "}
|
||||||
@@ -640,7 +769,7 @@ function UserMessage(props: {
|
|||||||
</For>
|
</For>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
{sync.data.config.username ?? "You"}{" "}
|
{sync.data.config.username ?? "You"}{" "}
|
||||||
<Show
|
<Show
|
||||||
when={queued()}
|
when={queued()}
|
||||||
@@ -755,7 +884,7 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
|
|||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
backgroundColor={theme.backgroundPanel}
|
backgroundColor={theme.backgroundPanel}
|
||||||
>
|
>
|
||||||
<text>{props.part.text.trim()}</text>
|
<text fg={theme.text}>{props.part.text.trim()}</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -764,13 +893,14 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
|
|||||||
|
|
||||||
function TextPart(props: { part: TextPart; message: AssistantMessage }) {
|
function TextPart(props: { part: TextPart; message: AssistantMessage }) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
|
const { syntax } = useTheme()
|
||||||
return (
|
return (
|
||||||
<Show when={props.part.text.trim()}>
|
<Show when={props.part.text.trim()}>
|
||||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
|
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||||
<code
|
<code
|
||||||
filetype="markdown"
|
filetype="markdown"
|
||||||
drawUnstyledText={false}
|
drawUnstyledText={false}
|
||||||
syntaxStyle={SyntaxTheme}
|
syntaxStyle={syntax()}
|
||||||
content={props.part.text.trim()}
|
content={props.part.text.trim()}
|
||||||
conceal={ctx.conceal()}
|
conceal={ctx.conceal()}
|
||||||
/>
|
/>
|
||||||
@@ -789,7 +919,7 @@ function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
|
|||||||
const render = ToolRegistry.render(props.part.tool) ?? GenericTool
|
const render = ToolRegistry.render(props.part.tool) ?? GenericTool
|
||||||
|
|
||||||
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
|
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
|
||||||
const input = props.part.state.input
|
const input = props.part.state.input ?? {}
|
||||||
const container = ToolRegistry.container(props.part.tool)
|
const container = ToolRegistry.container(props.part.tool)
|
||||||
const permissions = sync.data.permission[props.message.sessionID] ?? []
|
const permissions = sync.data.permission[props.message.sessionID] ?? []
|
||||||
const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
|
const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
|
||||||
@@ -893,16 +1023,14 @@ function GenericTool(props: ToolProps<any>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToolRegistration<T extends Tool.Info = any> = {
|
||||||
|
name: string
|
||||||
|
container: "inline" | "block"
|
||||||
|
render?: Component<ToolProps<T>>
|
||||||
|
}
|
||||||
const ToolRegistry = (() => {
|
const ToolRegistry = (() => {
|
||||||
const state: Record<
|
const state: Record<string, ToolRegistration> = {}
|
||||||
string,
|
function register<T extends Tool.Info>(input: ToolRegistration<T>) {
|
||||||
{ name: string; container: "inline" | "block"; render?: Component<ToolProps<any>> }
|
|
||||||
> = {}
|
|
||||||
function register<T extends Tool.Info>(input: {
|
|
||||||
name: string
|
|
||||||
container: "inline" | "block"
|
|
||||||
render?: Component<ToolProps<T>>
|
|
||||||
}) {
|
|
||||||
state[input.name] = input
|
state[input.name] = input
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
@@ -970,7 +1098,7 @@ ToolRegistry.register<typeof WriteTool>({
|
|||||||
name: "write",
|
name: "write",
|
||||||
container: "block",
|
container: "block",
|
||||||
render(props) {
|
render(props) {
|
||||||
const { theme } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
const lines = createMemo(() => {
|
const lines = createMemo(() => {
|
||||||
return props.input.content?.split("\n") ?? []
|
return props.input.content?.split("\n") ?? []
|
||||||
})
|
})
|
||||||
@@ -1001,7 +1129,7 @@ ToolRegistry.register<typeof WriteTool>({
|
|||||||
<box paddingLeft={1} flexGrow={1}>
|
<box paddingLeft={1} flexGrow={1}>
|
||||||
<code
|
<code
|
||||||
filetype={filetype(props.input.filePath!)}
|
filetype={filetype(props.input.filePath!)}
|
||||||
syntaxStyle={SyntaxTheme}
|
syntaxStyle={syntax()}
|
||||||
content={code()}
|
content={code()}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
@@ -1066,10 +1194,16 @@ ToolRegistry.register<typeof TaskTool>({
|
|||||||
container: "block",
|
container: "block",
|
||||||
render(props) {
|
render(props) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const keybind = useKeybind()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ToolTitle icon="%" fallback="Delegating..." when={props.input.description}>
|
<ToolTitle
|
||||||
Task {props.input.description}
|
icon="%"
|
||||||
|
fallback="Delegating..."
|
||||||
|
when={props.input.subagent_type ?? props.input.description}
|
||||||
|
>
|
||||||
|
Task [{props.input.subagent_type ?? "unknown"}] {props.input.description}
|
||||||
</ToolTitle>
|
</ToolTitle>
|
||||||
<Show when={props.metadata.summary?.length}>
|
<Show when={props.metadata.summary?.length}>
|
||||||
<box>
|
<box>
|
||||||
@@ -1082,6 +1216,10 @@ ToolRegistry.register<typeof TaskTool>({
|
|||||||
</For>
|
</For>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
{keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
|
||||||
|
<span style={{ fg: theme.textMuted }}> to navigate between subagent sessions</span>
|
||||||
|
</text>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1104,64 +1242,70 @@ ToolRegistry.register<typeof EditTool>({
|
|||||||
container: "block",
|
container: "block",
|
||||||
render(props) {
|
render(props) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
|
const { theme, syntax } = useTheme()
|
||||||
|
|
||||||
const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
|
const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
|
||||||
|
|
||||||
const diff = createMemo(() => {
|
const diff = createMemo(() => {
|
||||||
const diff = props.metadata.diff ?? props.permission["diff"]
|
const diff = props.metadata.diff ?? props.permission["diff"]
|
||||||
if (!diff) return null
|
if (!diff) return null
|
||||||
const patches = parsePatch(diff)
|
|
||||||
if (patches.length === 0) return null
|
|
||||||
|
|
||||||
const patch = patches[0]
|
try {
|
||||||
const oldLines: string[] = []
|
const patches = parsePatch(diff)
|
||||||
const newLines: string[] = []
|
if (patches.length === 0) return null
|
||||||
|
|
||||||
for (const hunk of patch.hunks) {
|
const patch = patches[0]
|
||||||
let i = 0
|
const oldLines: string[] = []
|
||||||
while (i < hunk.lines.length) {
|
const newLines: string[] = []
|
||||||
const line = hunk.lines[i]
|
|
||||||
|
|
||||||
if (line.startsWith("-")) {
|
for (const hunk of patch.hunks) {
|
||||||
const removedLines: string[] = []
|
let i = 0
|
||||||
while (i < hunk.lines.length && hunk.lines[i].startsWith("-")) {
|
while (i < hunk.lines.length) {
|
||||||
removedLines.push("- " + hunk.lines[i].slice(1))
|
const line = hunk.lines[i]
|
||||||
|
|
||||||
|
if (line.startsWith("-")) {
|
||||||
|
const removedLines: string[] = []
|
||||||
|
while (i < hunk.lines.length && hunk.lines[i].startsWith("-")) {
|
||||||
|
removedLines.push("- " + hunk.lines[i].slice(1))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
const addedLines: string[] = []
|
||||||
|
while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
|
||||||
|
addedLines.push("+ " + hunk.lines[i].slice(1))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLen = Math.max(removedLines.length, addedLines.length)
|
||||||
|
for (let j = 0; j < maxLen; j++) {
|
||||||
|
oldLines.push(removedLines[j] ?? "")
|
||||||
|
newLines.push(addedLines[j] ?? "")
|
||||||
|
}
|
||||||
|
} else if (line.startsWith("+")) {
|
||||||
|
const addedLines: string[] = []
|
||||||
|
while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
|
||||||
|
addedLines.push("+ " + hunk.lines[i].slice(1))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const added of addedLines) {
|
||||||
|
oldLines.push("")
|
||||||
|
newLines.push(added)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
oldLines.push(" " + line.slice(1))
|
||||||
|
newLines.push(" " + line.slice(1))
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
const addedLines: string[] = []
|
|
||||||
while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
|
|
||||||
addedLines.push("+ " + hunk.lines[i].slice(1))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxLen = Math.max(removedLines.length, addedLines.length)
|
|
||||||
for (let j = 0; j < maxLen; j++) {
|
|
||||||
oldLines.push(removedLines[j] ?? "")
|
|
||||||
newLines.push(addedLines[j] ?? "")
|
|
||||||
}
|
|
||||||
} else if (line.startsWith("+")) {
|
|
||||||
const addedLines: string[] = []
|
|
||||||
while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
|
|
||||||
addedLines.push("+ " + hunk.lines[i].slice(1))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const added of addedLines) {
|
|
||||||
oldLines.push("")
|
|
||||||
newLines.push(added)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
oldLines.push(" " + line.slice(1))
|
|
||||||
newLines.push(" " + line.slice(1))
|
|
||||||
i++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oldContent: oldLines.join("\n"),
|
oldContent: oldLines.join("\n"),
|
||||||
newContent: newLines.join("\n"),
|
newContent: newLines.join("\n"),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1183,21 +1327,21 @@ ToolRegistry.register<typeof EditTool>({
|
|||||||
</ToolTitle>
|
</ToolTitle>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.permission["diff"]}>
|
<Match when={props.permission["diff"]}>
|
||||||
<text>{props.permission["diff"]?.trim()}</text>
|
<text fg={theme.text}>{props.permission["diff"]?.trim()}</text>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={diff() && style() === "split"}>
|
<Match when={diff() && style() === "split"}>
|
||||||
<box paddingLeft={1} flexDirection="row" gap={2}>
|
<box paddingLeft={1} flexDirection="row" gap={2}>
|
||||||
<box flexGrow={1} flexBasis={0}>
|
<box flexGrow={1} flexBasis={0}>
|
||||||
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.oldContent} />
|
<code filetype={ft()} syntaxStyle={syntax()} content={diff()!.oldContent} />
|
||||||
</box>
|
</box>
|
||||||
<box flexGrow={1} flexBasis={0}>
|
<box flexGrow={1} flexBasis={0}>
|
||||||
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.newContent} />
|
<code filetype={ft()} syntaxStyle={syntax()} content={diff()!.newContent} />
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={code()}>
|
<Match when={code()}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={code()} />
|
<code filetype={ft()} syntaxStyle={syntax()} content={code()} />
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -1210,6 +1354,7 @@ ToolRegistry.register<typeof PatchTool>({
|
|||||||
name: "patch",
|
name: "patch",
|
||||||
container: "block",
|
container: "block",
|
||||||
render(props) {
|
render(props) {
|
||||||
|
const { theme } = useTheme()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ToolTitle icon="%" fallback="Preparing patch..." when={true}>
|
<ToolTitle icon="%" fallback="Preparing patch..." when={true}>
|
||||||
@@ -1217,7 +1362,7 @@ ToolRegistry.register<typeof PatchTool>({
|
|||||||
</ToolTitle>
|
</ToolTitle>
|
||||||
<Show when={props.output}>
|
<Show when={props.output}>
|
||||||
<box>
|
<box>
|
||||||
<text>{props.output?.trim()}</text>
|
<text fg={theme.text}>{props.output?.trim()}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function Sidebar(props: { sessionID: string }) {
|
|||||||
<Show when={session()}>
|
<Show when={session()}>
|
||||||
<box flexShrink={0} gap={1} width={40}>
|
<box flexShrink={0} gap={1} width={40}>
|
||||||
<box>
|
<box>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<b>{session().title}</b>
|
<b>{session().title}</b>
|
||||||
</text>
|
</text>
|
||||||
<Show when={session().share?.url}>
|
<Show when={session().share?.url}>
|
||||||
@@ -50,7 +50,7 @@ export function Sidebar(props: { sessionID: string }) {
|
|||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<box>
|
<box>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<b>Context</b>
|
<b>Context</b>
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
|
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
|
||||||
@@ -59,7 +59,7 @@ export function Sidebar(props: { sessionID: string }) {
|
|||||||
</box>
|
</box>
|
||||||
<Show when={Object.keys(sync.data.mcp).length > 0}>
|
<Show when={Object.keys(sync.data.mcp).length > 0}>
|
||||||
<box>
|
<box>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<b>MCP</b>
|
<b>MCP</b>
|
||||||
</text>
|
</text>
|
||||||
<For each={Object.entries(sync.data.mcp)}>
|
<For each={Object.entries(sync.data.mcp)}>
|
||||||
@@ -77,7 +77,7 @@ export function Sidebar(props: { sessionID: string }) {
|
|||||||
>
|
>
|
||||||
•
|
•
|
||||||
</text>
|
</text>
|
||||||
<text wrapMode="word">
|
<text fg={theme.text} wrapMode="word">
|
||||||
{key}{" "}
|
{key}{" "}
|
||||||
<span style={{ fg: theme.textMuted }}>
|
<span style={{ fg: theme.textMuted }}>
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -96,7 +96,7 @@ export function Sidebar(props: { sessionID: string }) {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={sync.data.lsp.length > 0}>
|
<Show when={sync.data.lsp.length > 0}>
|
||||||
<box>
|
<box>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<b>LSP</b>
|
<b>LSP</b>
|
||||||
</text>
|
</text>
|
||||||
<For each={sync.data.lsp}>
|
<For each={sync.data.lsp}>
|
||||||
@@ -123,7 +123,7 @@ export function Sidebar(props: { sessionID: string }) {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={session().summary?.diffs}>
|
<Show when={session().summary?.diffs}>
|
||||||
<box>
|
<box>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<b>Modified Files</b>
|
<b>Modified Files</b>
|
||||||
</text>
|
</text>
|
||||||
<For each={session().summary?.diffs || []}>
|
<For each={session().summary?.diffs || []}>
|
||||||
@@ -155,7 +155,7 @@ export function Sidebar(props: { sessionID: string }) {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={todo().length > 0}>
|
<Show when={todo().length > 0}>
|
||||||
<box>
|
<box>
|
||||||
<text>
|
<text fg={theme.text}>
|
||||||
<b>Todo</b>
|
<b>Todo</b>
|
||||||
</text>
|
</text>
|
||||||
<For each={todo()}>
|
<For each={todo()}>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export const TuiSpawnCommand = 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",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { bootstrap } from "@/cli/bootstrap"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { UI } from "@/cli/ui"
|
import { UI } from "@/cli/ui"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const OPENCODE_WORKER_PATH: string
|
||||||
|
}
|
||||||
|
|
||||||
export const TuiThreadCommand = cmd({
|
export const TuiThreadCommand = cmd({
|
||||||
command: "$0 [project]",
|
command: "$0 [project]",
|
||||||
describe: "start opencode tui",
|
describe: "start opencode tui",
|
||||||
@@ -32,6 +36,11 @@ export const TuiThreadCommand = cmd({
|
|||||||
describe: "session id to continue",
|
describe: "session id to continue",
|
||||||
type: "string",
|
type: "string",
|
||||||
})
|
})
|
||||||
|
.option("prompt", {
|
||||||
|
alias: ["p"],
|
||||||
|
type: "string",
|
||||||
|
describe: "prompt to use",
|
||||||
|
})
|
||||||
.option("agent", {
|
.option("agent", {
|
||||||
type: "string",
|
type: "string",
|
||||||
describe: "agent to use",
|
describe: "agent to use",
|
||||||
@@ -42,19 +51,32 @@ export const TuiThreadCommand = 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",
|
||||||
}),
|
}),
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
const prompt = await (async () => {
|
||||||
|
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
|
||||||
|
if (!args.prompt) return piped
|
||||||
|
return piped ? piped + "\n" + args.prompt : args.prompt
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
|
||||||
|
const baseCwd = process.env.PWD ?? process.cwd()
|
||||||
|
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
|
||||||
|
let workerPath: string | URL = new URL("./worker.ts", import.meta.url)
|
||||||
|
|
||||||
|
if (typeof OPENCODE_WORKER_PATH !== "undefined") {
|
||||||
|
workerPath = OPENCODE_WORKER_PATH
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
process.chdir(cwd)
|
process.chdir(cwd)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
UI.error("Failed to change directory to " + cwd)
|
UI.error("Failed to change directory to " + cwd)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await bootstrap(cwd, async () => {
|
await bootstrap(cwd, async () => {
|
||||||
upgrade()
|
upgrade()
|
||||||
|
|
||||||
@@ -78,7 +100,13 @@ export const TuiThreadCommand = cmd({
|
|||||||
return undefined
|
return undefined
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const worker = new Worker("./src/cli/cmd/tui/worker.ts")
|
const worker = new Worker(workerPath, {
|
||||||
|
env: Object.fromEntries(
|
||||||
|
Object.entries(process.env).filter(
|
||||||
|
(entry): entry is [string, string] => entry[1] !== undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
worker.onerror = console.error
|
worker.onerror = console.error
|
||||||
const client = Rpc.client<typeof rpc>(worker)
|
const client = Rpc.client<typeof rpc>(worker)
|
||||||
process.on("uncaughtException", (e) => {
|
process.on("uncaughtException", (e) => {
|
||||||
@@ -96,6 +124,7 @@ export const TuiThreadCommand = cmd({
|
|||||||
sessionID,
|
sessionID,
|
||||||
model: args.model,
|
model: args.model,
|
||||||
agent: args.agent,
|
agent: args.agent,
|
||||||
|
prompt,
|
||||||
onExit: async () => {
|
onExit: async () => {
|
||||||
await client.call("shutdown", undefined)
|
await client.call("shutdown", undefined)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||||
|
import { useTheme } from "../context/theme"
|
||||||
|
import { useDialog, type DialogContext } from "./dialog"
|
||||||
|
import { onMount } from "solid-js"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
|
||||||
|
export type DialogPromptProps = {
|
||||||
|
title: string
|
||||||
|
value?: string
|
||||||
|
onConfirm?: (value: string) => void
|
||||||
|
onCancel?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogPrompt(props: DialogPromptProps) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
let textarea: TextareaRenderable
|
||||||
|
|
||||||
|
useKeyboard((evt) => {
|
||||||
|
if (evt.name === "return") {
|
||||||
|
props.onConfirm?.(textarea.plainText)
|
||||||
|
dialog.clear()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
dialog.setSize("large")
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus()
|
||||||
|
}, 1)
|
||||||
|
textarea.gotoLineEnd()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||||
|
<text fg={theme.textMuted}>esc</text>
|
||||||
|
</box>
|
||||||
|
<box>
|
||||||
|
<textarea
|
||||||
|
onSubmit={() => {
|
||||||
|
props.onConfirm?.(textarea.plainText)
|
||||||
|
dialog.clear()
|
||||||
|
}}
|
||||||
|
keyBindings={[{ name: "return", action: "submit" }]}
|
||||||
|
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||||
|
initialValue={props.value}
|
||||||
|
placeholder="Enter text"
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
<box paddingBottom={1}>
|
||||||
|
<text fg={theme.textMuted}>Press enter to confirm, esc to cancel</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => {
|
||||||
|
return new Promise<string | null>((resolve) => {
|
||||||
|
dialog.replace(
|
||||||
|
() => (
|
||||||
|
<DialogPrompt
|
||||||
|
title={title}
|
||||||
|
value={value}
|
||||||
|
onConfirm={(value) => resolve(value)}
|
||||||
|
onCancel={() => resolve(null)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
() => resolve(null),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export interface DialogSelectOption<T = any> {
|
|||||||
category?: string
|
category?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
bg?: RGBA
|
bg?: RGBA
|
||||||
onSelect?: (ctx: DialogContext) => void
|
onSelect?: (ctx: DialogContext, trigger?: "prompt") => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DialogSelectRef<T> = {
|
export type DialogSelectRef<T> = {
|
||||||
@@ -59,7 +59,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
props.options,
|
props.options,
|
||||||
filter((x) => x.disabled !== true),
|
filter((x) => x.disabled !== true),
|
||||||
take(props.limit ?? Infinity),
|
take(props.limit ?? Infinity),
|
||||||
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
|
(x) =>
|
||||||
|
!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
@@ -122,20 +123,26 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
|
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (evt.name === "up") move(-1)
|
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
|
||||||
if (evt.name === "down") move(1)
|
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
|
||||||
if (evt.name === "pageup") move(-10)
|
if (evt.name === "pageup") move(-10)
|
||||||
if (evt.name === "pagedown") move(10)
|
if (evt.name === "pagedown") move(10)
|
||||||
if (evt.name === "return") {
|
if (evt.name === "return") {
|
||||||
const option = selected()
|
const option = selected()
|
||||||
if (option.onSelect) option.onSelect(dialog)
|
if (option) {
|
||||||
props.onSelect?.(option)
|
// evt.preventDefault()
|
||||||
|
if (option.onSelect) option.onSelect(dialog)
|
||||||
|
props.onSelect?.(option)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of props.keybind ?? []) {
|
for (const item of props.keybind ?? []) {
|
||||||
if (Keybind.match(item.keybind, keybind.parse(evt))) {
|
if (Keybind.match(item.keybind, keybind.parse(evt))) {
|
||||||
const s = selected()
|
const s = selected()
|
||||||
if (s) item.onTrigger(s)
|
if (s) {
|
||||||
|
evt.preventDefault()
|
||||||
|
item.onTrigger(s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -155,7 +162,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<box paddingLeft={3} paddingRight={2}>
|
<box paddingLeft={3} paddingRight={2}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||||
|
{props.title}
|
||||||
|
</text>
|
||||||
<text fg={theme.textMuted}>esc</text>
|
<text fg={theme.textMuted}>esc</text>
|
||||||
</box>
|
</box>
|
||||||
<box paddingTop={1} paddingBottom={1}>
|
<box paddingTop={1} paddingBottom={1}>
|
||||||
@@ -166,12 +175,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
props.onFilter?.(e)
|
props.onFilter?.(e)
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {}}
|
||||||
focusedBackgroundColor={theme.backgroundPanel}
|
focusedBackgroundColor={theme.backgroundPanel}
|
||||||
cursorColor={theme.primary}
|
cursorColor={theme.primary}
|
||||||
focusedTextColor={theme.textMuted}
|
focusedTextColor={theme.textMuted}
|
||||||
ref={(r) => {
|
ref={(r) => {
|
||||||
input = r
|
input = r
|
||||||
input.focus()
|
setTimeout(() => input.focus(), 1)
|
||||||
}}
|
}}
|
||||||
placeholder="Enter search term"
|
placeholder="Enter search term"
|
||||||
/>
|
/>
|
||||||
@@ -206,11 +216,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
props.onSelect?.(option)
|
props.onSelect?.(option)
|
||||||
}}
|
}}
|
||||||
onMouseOver={() => {
|
onMouseOver={() => {
|
||||||
const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
|
const index = filtered().findIndex((x) =>
|
||||||
|
isDeepEqual(x.value, option.value),
|
||||||
|
)
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
moveTo(index)
|
moveTo(index)
|
||||||
}}
|
}}
|
||||||
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
backgroundColor={
|
||||||
|
active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)
|
||||||
|
}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
gap={1}
|
gap={1}
|
||||||
@@ -218,7 +232,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
<Option
|
<Option
|
||||||
title={option.title}
|
title={option.title}
|
||||||
footer={option.footer}
|
footer={option.footer}
|
||||||
description={option.description !== category ? option.description : undefined}
|
description={
|
||||||
|
option.description !== category ? option.description : undefined
|
||||||
|
}
|
||||||
active={active()}
|
active={active()}
|
||||||
current={isDeepEqual(option.value, props.current)}
|
current={isDeepEqual(option.value, props.current)}
|
||||||
/>
|
/>
|
||||||
@@ -230,11 +246,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1}>
|
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1} gap={1}>
|
||||||
<For each={props.keybind ?? []}>
|
<For each={props.keybind ?? []}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<text>
|
<text>
|
||||||
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
|
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>
|
||||||
|
{Keybind.toString(item.keybind)}
|
||||||
|
</span>
|
||||||
<span style={{ fg: theme.textMuted }}> {item.title}</span>
|
<span style={{ fg: theme.textMuted }}> {item.title}</span>
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
@@ -263,7 +281,10 @@ function Option(props: {
|
|||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
>
|
>
|
||||||
{Locale.truncate(props.title, 62)}
|
{Locale.truncate(props.title, 62)}
|
||||||
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
|
<span style={{ fg: props.active ? theme.background : theme.textMuted }}>
|
||||||
|
{" "}
|
||||||
|
{props.description}
|
||||||
|
</span>
|
||||||
</text>
|
</text>
|
||||||
<Show when={props.footer}>
|
<Show when={props.footer}>
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
|
|||||||
@@ -1,23 +1,9 @@
|
|||||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { batch, createContext, createEffect, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { Renderable, RGBA } from "@opentui/core"
|
import { Renderable, RGBA } from "@opentui/core"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createEventBus } from "@solid-primitives/event-bus"
|
|
||||||
|
|
||||||
const Border = {
|
|
||||||
topLeft: "┃",
|
|
||||||
topRight: "┃",
|
|
||||||
bottomLeft: "┃",
|
|
||||||
bottomRight: "┃",
|
|
||||||
horizontal: "",
|
|
||||||
vertical: "┃",
|
|
||||||
topT: "+",
|
|
||||||
bottomT: "+",
|
|
||||||
leftT: "+",
|
|
||||||
rightT: "+",
|
|
||||||
cross: "+",
|
|
||||||
}
|
|
||||||
export function Dialog(
|
export function Dialog(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
size?: "medium" | "large"
|
size?: "medium" | "large"
|
||||||
@@ -45,11 +31,9 @@ export function Dialog(
|
|||||||
onMouseUp={async (e) => {
|
onMouseUp={async (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}}
|
}}
|
||||||
customBorderChars={Border}
|
|
||||||
width={props.size === "large" ? 80 : 60}
|
width={props.size === "large" ? 80 : 60}
|
||||||
maxWidth={dimensions().width - 2}
|
maxWidth={dimensions().width - 2}
|
||||||
backgroundColor={theme.backgroundPanel}
|
backgroundColor={theme.backgroundPanel}
|
||||||
borderColor={theme.border}
|
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
@@ -66,7 +50,6 @@ function init() {
|
|||||||
}[],
|
}[],
|
||||||
size: "medium" as "medium" | "large",
|
size: "medium" as "medium" | "large",
|
||||||
})
|
})
|
||||||
const allClosedEvent = createEventBus<void>()
|
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (evt.name === "escape" && store.stack.length > 0) {
|
if (evt.name === "escape" && store.stack.length > 0) {
|
||||||
@@ -97,12 +80,6 @@ function init() {
|
|||||||
}, 1)
|
}, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (store.stack.length === 0) {
|
|
||||||
allClosedEvent.emit()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clear() {
|
clear() {
|
||||||
for (const item of store.stack) {
|
for (const item of store.stack) {
|
||||||
@@ -115,7 +92,9 @@ function init() {
|
|||||||
refocus()
|
refocus()
|
||||||
},
|
},
|
||||||
replace(input: any, onClose?: () => void) {
|
replace(input: any, onClose?: () => void) {
|
||||||
if (store.stack.length === 0) focus = renderer.currentFocusedRenderable
|
if (store.stack.length === 0) {
|
||||||
|
focus = renderer.currentFocusedRenderable
|
||||||
|
}
|
||||||
for (const item of store.stack) {
|
for (const item of store.stack) {
|
||||||
if (item.onClose) item.onClose()
|
if (item.onClose) item.onClose()
|
||||||
}
|
}
|
||||||
@@ -136,9 +115,6 @@ function init() {
|
|||||||
setSize(size: "medium" | "large") {
|
setSize(size: "medium" | "large") {
|
||||||
setStore("size", size)
|
setStore("size", size)
|
||||||
},
|
},
|
||||||
get allClosedEvent() {
|
|
||||||
return allClosedEvent
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function init() {
|
|||||||
|
|
||||||
let timeoutHandle: NodeJS.Timeout | null = null
|
let timeoutHandle: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
return {
|
const toast = {
|
||||||
show(options: ToastOptions) {
|
show(options: ToastOptions) {
|
||||||
const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
|
const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
|
||||||
const { duration, ...currentToast } = parsedOptions
|
const { duration, ...currentToast } = parsedOptions
|
||||||
@@ -59,10 +59,22 @@ function init() {
|
|||||||
setStore("currentToast", null)
|
setStore("currentToast", null)
|
||||||
}, duration).unref()
|
}, duration).unref()
|
||||||
},
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
if (err instanceof Error)
|
||||||
|
return toast.show({
|
||||||
|
variant: "error",
|
||||||
|
message: err.message,
|
||||||
|
})
|
||||||
|
toast.show({
|
||||||
|
variant: "error",
|
||||||
|
message: "An unknown error has occurred",
|
||||||
|
})
|
||||||
|
},
|
||||||
get currentToast(): ToastOptions | null {
|
get currentToast(): ToastOptions | null {
|
||||||
return store.currentToast
|
return store.currentToast
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
return toast
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToastContext = ReturnType<typeof init>
|
export type ToastContext = ReturnType<typeof init>
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ export namespace Clipboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (os === "linux") {
|
if (os === "linux") {
|
||||||
const wayland = await $`wl-paste -t image/png`.nothrow().text()
|
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
|
||||||
if (wayland) {
|
if (wayland && wayland.byteLength > 0) {
|
||||||
return { data: Buffer.from(wayland).toString("base64url"), mime: "image/png" }
|
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
|
||||||
}
|
}
|
||||||
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().text()
|
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
|
||||||
if (x11) {
|
if (x11 && x11.byteLength > 0) {
|
||||||
return { data: Buffer.from(x11).toString("base64url"), mime: "image/png" }
|
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export namespace Clipboard {
|
|||||||
if (base64) {
|
if (base64) {
|
||||||
const imageBuffer = Buffer.from(base64.trim(), "base64")
|
const imageBuffer = Buffer.from(base64.trim(), "base64")
|
||||||
if (imageBuffer.length > 0) {
|
if (imageBuffer.length > 0) {
|
||||||
return { data: imageBuffer.toString("base64url"), mime: "image/png" }
|
return { data: imageBuffer.toString("base64"), mime: "image/png" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ export namespace Clipboard {
|
|||||||
const getCopyMethod = lazy(() => {
|
const getCopyMethod = lazy(() => {
|
||||||
const os = platform()
|
const os = platform()
|
||||||
|
|
||||||
if (os === "darwin") {
|
if (os === "darwin" && Bun.which("oascript")) {
|
||||||
console.log("clipboard: using osascript")
|
console.log("clipboard: using osascript")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||||
@@ -70,13 +70,13 @@ export namespace Clipboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (os === "linux") {
|
if (os === "linux") {
|
||||||
if (process.env["WAYLAND_DISPLAY"]) {
|
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
|
||||||
console.log("clipboard: using wl-copy")
|
console.log("clipboard: using wl-copy")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||||
proc.stdin.write(text)
|
proc.stdin.write(text)
|
||||||
proc.stdin.end()
|
proc.stdin.end()
|
||||||
await proc.exited
|
await proc.exited.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Bun.which("xclip")) {
|
if (Bun.which("xclip")) {
|
||||||
@@ -89,7 +89,7 @@ export namespace Clipboard {
|
|||||||
})
|
})
|
||||||
proc.stdin.write(text)
|
proc.stdin.write(text)
|
||||||
proc.stdin.end()
|
proc.stdin.end()
|
||||||
await proc.exited
|
await proc.exited.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Bun.which("xsel")) {
|
if (Bun.which("xsel")) {
|
||||||
@@ -102,7 +102,7 @@ export namespace Clipboard {
|
|||||||
})
|
})
|
||||||
proc.stdin.write(text)
|
proc.stdin.write(text)
|
||||||
proc.stdin.end()
|
proc.stdin.end()
|
||||||
await proc.exited
|
await proc.exited.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export namespace Editor {
|
|||||||
})
|
})
|
||||||
await proc.exited
|
await proc.exited
|
||||||
const content = await Bun.file(filepath).text()
|
const content = await Bun.file(filepath).text()
|
||||||
|
opts.renderer.currentRenderBuffer.clear()
|
||||||
opts.renderer.resume()
|
opts.renderer.resume()
|
||||||
opts.renderer.requestRender()
|
opts.renderer.requestRender()
|
||||||
return content || undefined
|
return content || undefined
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Server } from "../../server/server"
|
||||||
|
import { UI } from "../ui"
|
||||||
|
import { cmd } from "./cmd"
|
||||||
|
import open from "open"
|
||||||
|
|
||||||
|
export const WebCommand = cmd({
|
||||||
|
command: "web",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.option("port", {
|
||||||
|
alias: ["p"],
|
||||||
|
type: "number",
|
||||||
|
describe: "port to listen on",
|
||||||
|
default: 0,
|
||||||
|
})
|
||||||
|
.option("hostname", {
|
||||||
|
type: "string",
|
||||||
|
describe: "hostname to listen on",
|
||||||
|
default: "127.0.0.1",
|
||||||
|
}),
|
||||||
|
describe: "starts a headless opencode server",
|
||||||
|
handler: async (args) => {
|
||||||
|
const hostname = args.hostname
|
||||||
|
const port = args.port
|
||||||
|
const server = Server.listen({
|
||||||
|
port,
|
||||||
|
hostname,
|
||||||
|
})
|
||||||
|
UI.empty()
|
||||||
|
UI.println(UI.logo(" "))
|
||||||
|
UI.empty()
|
||||||
|
UI.println(
|
||||||
|
UI.Style.TEXT_INFO_BOLD + " Web interface: ",
|
||||||
|
UI.Style.TEXT_NORMAL,
|
||||||
|
server.url.toString(),
|
||||||
|
)
|
||||||
|
open(server.url.toString()).catch(() => {})
|
||||||
|
await new Promise(() => {})
|
||||||
|
await server.stop()
|
||||||
|
},
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user