mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 01:06:16 -04:00
Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 056186225b | |||
| b982ab2fbc | |||
| 9a89cd91d7 | |||
| 65ac318282 | |||
| e491f5cc16 | |||
| ebe86e40a0 | |||
| 9407a6fd7c | |||
| d75dca29e9 | |||
| 471fc06f01 | |||
| 4c2d597ae6 | |||
| 2b07291e17 | |||
| d25120680d | |||
| a900c89245 | |||
| caecc7911d | |||
| f7a4cdcd32 | |||
| e9152b174f | |||
| dcc8d1a638 | |||
| ddc4e89359 | |||
| a5c058e584 | |||
| 0bc4a43320 | |||
| e2d0d85d93 | |||
| 2917a2fa61 | |||
| 12473561ba | |||
| 397ee419d1 | |||
| b1072053ba | |||
| a64f8d1b11 | |||
| 7f55a9736d | |||
| 460513a835 | |||
| 33298e8775 | |||
| 2f9f588f77 | |||
| d6efb797b5 | |||
| 399fec770f | |||
| 8d1a66d043 | |||
| e1fe86e6d7 | |||
| 93e948ae12 | |||
| 8714b1a3ac | |||
| 4ded06f05d | |||
| 6d0fecb985 | |||
| e223d1a0e5 | |||
| 3fdd6ec120 | |||
| 2f1be914cd | |||
| 1269766cb8 | |||
| ae11cad13b | |||
| 10d227b8d6 | |||
| d97cd56867 | |||
| 43906f56c8 | |||
| 241087d1dc | |||
| fba77a364c | |||
| 3d956c5f7e | |||
| 4a4c1b31a7 | |||
| 30111d2b16 | |||
| b695216063 | |||
| c2ec608212 | |||
| c1af7ddc6b | |||
| cf7c6417f8 | |||
| 937474aff0 | |||
| a878b8d7ac | |||
| b824fc5516 | |||
| c56f6127c7 | |||
| 8845f2b926 | |||
| df4d839577 | |||
| 8fe42cd5dc | |||
| a169c2987b | |||
| a5c08bc4f8 | |||
| 02aea77e92 | |||
| a98add29d1 | |||
| d01df32e36 | |||
| 2c620e1742 | |||
| 262084d7e6 | |||
| b089358503 | |||
| 02456376ce | |||
| faf2609bc5 | |||
| aeeb05e4a0 | |||
| 847a7ca009 | |||
| dc1ff0e63e | |||
| 7ba25c6afb | |||
| b951187a6e | |||
| 8f99e9a606 | |||
| 27b45d070d | |||
| 07d7dc083c | |||
| eaa622e852 | |||
| ff9c186485 | |||
| 41f2653a30 | |||
| 0d9ca0ea31 | |||
| 68bd16df69 | |||
| b3901ac38b | |||
| 48236ee0ef | |||
| e2bffc29f2 | |||
| fda897eac4 | |||
| e5d2d984b6 | |||
| bfb0885371 | |||
| 0d41f1fc24 | |||
| 363ff153a4 | |||
| f4bcf0062a | |||
| 9759afad83 | |||
| ac204ed89d | |||
| 1080f37f9c | |||
| d90b4c9ebd | |||
| 42b802b688 | |||
| fa1a54ba3d | |||
| 8f0d08fae0 | |||
| 15801a01ba | |||
| 32e6bcae3b | |||
| 087d7da14d | |||
| 442a735883 | |||
| 67ea21b55a | |||
| f4cf3f4976 | |||
| e3c1861a3e | |||
| d9eebba90e | |||
| c5ef6452b3 | |||
| ad27427b48 | |||
| 88bcd04659 | |||
| 077d17d433 | |||
| 6511243152 | |||
| ea8d727e28 | |||
| b590bda5ed | |||
| d8bbb6df60 | |||
| 7c2e59de68 | |||
| fa510161f6 | |||
| 6abe86806f | |||
| 6d8e994383 | |||
| ae77ef3370 | |||
| 68e504bdc2 | |||
| 91287dd7bc | |||
| 09f45320b7 | |||
| 962ab3bc8c | |||
| da8f3e92a7 | |||
| 04b511e1fe | |||
| 456469d541 | |||
| d96877f173 | |||
| 98b66ff933 | |||
| 5f7111fe93 | |||
| d5f78a7278 | |||
| 1533c50ac3 | |||
| 0cc206a1a5 | |||
| d4443d79c7 | |||
| c9215e8dc3 | |||
| 58788192f4 | |||
| 40ab6ac862 | |||
| 31f80a45af | |||
| 0a9f51f87f | |||
| af6bd9d3b1 | |||
| c66da17364 | |||
| b280207481 | |||
| 75cccc305a | |||
| 18ea09868a | |||
| 1df697dec7 | |||
| 1476c4ca49 | |||
| 3b3ab29d8c | |||
| 258d207fd6 | |||
| 4b64bff11b | |||
| c70e8b5880 | |||
| 42a1a1202c | |||
| d3490cfd29 | |||
| 5384040051 | |||
| 1bf4caa0c1 | |||
| 35a3c98221 | |||
| 56ece04dd5 | |||
| 328bd3fb02 | |||
| 2daa3652bb | |||
| ae84e9909a | |||
| b978ca11da | |||
| e452b3cae0 | |||
| e2d8310b76 | |||
| 1d09343f17 | |||
| 6633f0e6fa | |||
| 4173adf5e2 | |||
| cf7e10c4e8 | |||
| af5e405391 | |||
| 8a216a6ad5 | |||
| 225b72ca36 | |||
| 8105f186dc | |||
| 4f1bdf1c59 | |||
| 472695caca | |||
| 469fd43c71 | |||
| 24d942349f | |||
| 65c236c071 | |||
| d6c5ddd6dc | |||
| e5fe50f7da | |||
| b6beda1569 | |||
| f34b509fe7 | |||
| 2a2d800ac4 | |||
| 4afb46f571 | |||
| c4d223eb99 | |||
| 3fbda54045 | |||
| 41ede06b20 | |||
| 82ec84982e | |||
| df7b6792cd | |||
| c72d9a473c | |||
| d3688b150a | |||
| e376e1de16 |
@@ -53,7 +53,6 @@ jobs:
|
||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}\\packages\\opencode\\test\\tool\\fixtures\\models-api.json" >> "$GITHUB_ENV"
|
||||
else
|
||||
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
|
||||
@@ -61,7 +60,6 @@ jobs:
|
||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Seed opencode data
|
||||
@@ -69,8 +67,6 @@ jobs:
|
||||
working-directory: packages/opencode
|
||||
run: bun script/seed-e2e.ts
|
||||
env:
|
||||
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
@@ -90,8 +86,6 @@ jobs:
|
||||
working-directory: packages/opencode
|
||||
run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 &
|
||||
env:
|
||||
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
@@ -117,8 +111,6 @@ jobs:
|
||||
run: ${{ matrix.settings.command }}
|
||||
env:
|
||||
CI: true
|
||||
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: git commit and push
|
||||
model: opencode/glm-4.6
|
||||
model: opencode/glm-4.7
|
||||
subtask: true
|
||||
---
|
||||
|
||||
@@ -26,3 +26,15 @@ about what user facing changes were made
|
||||
|
||||
if there are changes do a git pull --rebase
|
||||
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
|
||||
|
||||
## GIT DIFF
|
||||
|
||||
!`git diff`
|
||||
|
||||
## GIT DIFF --cached
|
||||
|
||||
!`git diff --cached`
|
||||
|
||||
## GIT STATUS --short
|
||||
|
||||
!`git status --short`
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
// "enterprise": {
|
||||
// "url": "https://enterprise.dev.opencode.ai",
|
||||
// },
|
||||
"instructions": ["STYLE_GUIDE.md"],
|
||||
"provider": {
|
||||
"opencode": {
|
||||
"options": {},
|
||||
|
||||
@@ -1,4 +1,75 @@
|
||||
- To test opencode in `packages/opencode`, run `bun dev`.
|
||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
- The default branch in this repo is `dev`.
|
||||
|
||||
## Style Guide
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Prefer single word variable names where possible
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
|
||||
# Avoid let statements
|
||||
|
||||
We don't like `let` statements, especially combined with if/else statements.
|
||||
Prefer `const`.
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
const foo = condition ? 1 : 2
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
let foo
|
||||
|
||||
if (condition) foo = 1
|
||||
else foo = 2
|
||||
```
|
||||
|
||||
# Avoid else statements
|
||||
|
||||
Prefer early returns or using an `iife` to avoid else statements.
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
return 2
|
||||
}
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
else return 2
|
||||
}
|
||||
```
|
||||
|
||||
# Prefer single word naming
|
||||
|
||||
Try your best to find a single word name for your variables, functions, etc.
|
||||
Only use multiple words if you cannot.
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
const foo = 1
|
||||
const bar = 2
|
||||
const baz = 3
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
const fooBar = 1
|
||||
const barBaz = 2
|
||||
const bazFoo = 3
|
||||
```
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ This runs `bun run --cwd packages/desktop build` automatically via Tauri’s `be
|
||||
> [!NOTE]
|
||||
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
|
||||
|
||||
Please try to follow the [style guide](./STYLE_GUIDE.md)
|
||||
Please try to follow the [style guide](./AGENTS.md)
|
||||
|
||||
### Setting up a Debugger
|
||||
|
||||
|
||||
@@ -208,3 +208,6 @@
|
||||
| 2026-01-20 | 5,128,999 (+267,891) | 1,903,665 (+40,553) | 7,032,664 (+308,444) |
|
||||
| 2026-01-21 | 5,444,842 (+315,843) | 1,962,531 (+58,866) | 7,407,373 (+374,709) |
|
||||
| 2026-01-22 | 5,766,340 (+321,498) | 2,029,487 (+66,956) | 7,795,827 (+388,454) |
|
||||
| 2026-01-23 | 6,096,236 (+329,896) | 2,096,235 (+66,748) | 8,192,471 (+396,644) |
|
||||
| 2026-01-24 | 6,371,019 (+274,783) | 2,156,870 (+60,635) | 8,527,889 (+335,418) |
|
||||
| 2026-01-25 | 6,639,082 (+268,063) | 2,187,853 (+30,983) | 8,826,935 (+299,046) |
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
## Style Guide
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Prefer single word variable names where possible
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
|
||||
# Avoid let statements
|
||||
|
||||
We don't like `let` statements, especially combined with if/else statements.
|
||||
Prefer `const`.
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
const foo = condition ? 1 : 2
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
let foo
|
||||
|
||||
if (condition) foo = 1
|
||||
else foo = 2
|
||||
```
|
||||
|
||||
# Avoid else statements
|
||||
|
||||
Prefer early returns or using an `iife` to avoid else statements.
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
return 2
|
||||
}
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
function foo() {
|
||||
if (condition) return 1
|
||||
else return 2
|
||||
}
|
||||
```
|
||||
|
||||
# Prefer single word naming
|
||||
|
||||
Try your best to find a single word name for your variables, functions, etc.
|
||||
Only use multiple words if you cannot.
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
const foo = 1
|
||||
const bar = 2
|
||||
const baz = 3
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
const fooBar = 1
|
||||
const barBaz = 2
|
||||
const bazFoo = 3
|
||||
```
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -211,7 +211,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -240,7 +240,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -256,7 +256,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -284,7 +284,7 @@
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.2.0",
|
||||
"@gitlab/gitlab-ai-provider": "3.3.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
@@ -360,7 +360,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -380,9 +380,9 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.4",
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -391,7 +391,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -404,7 +404,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -423,6 +423,7 @@
|
||||
"marked": "catalog:",
|
||||
"marked-katex-extension": "5.1.6",
|
||||
"marked-shiki": "catalog:",
|
||||
"morphdom": "2.7.8",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -445,7 +446,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -456,7 +457,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -922,17 +923,19 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.2.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-sqP34jDSWWEHygmYbM2rzIcRjhA+1FHVHj8mxUvVz1s7o2Cgb1NnOaUXU7eWTI0AGhO+tPYHDTqI/mRC4cdjlQ=="],
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
|
||||
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.5.2", "", { "dependencies": { "ansi-colors": "4.1.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-88cqrrB2cLXN8nMOHidQTcVOnZsJ5kebEbBefjMCifaUCwTA30ouSSWvTZqrOX4O104zjJyu7M8Gcv/NNYQuaA=="],
|
||||
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.5.5", "", { "dependencies": { "@hey-api/types": "0.1.2", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-f2ZHucnA2wBGAY8ipB4wn/mrEYW+WUxU2huJmUvfDO6AE2vfILSHeF3wCO39Pz4wUYPoAWZByaauftLrOfC12Q=="],
|
||||
|
||||
"@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.2", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.1", "lodash": "^4.17.21" } }, "sha512-oS+5yAdwnK20lSeFO1d53Ku+yaGCsY8PcrmSq2GtSs3bsBfRnHAbpPKSVzQcaxAOrzj5NB+f34WhZglVrNayBA=="],
|
||||
|
||||
"@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.90.4", "", { "dependencies": { "@hey-api/codegen-core": "^0.5.2", "@hey-api/json-schema-ref-parser": "1.2.2", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.3" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-9l++kjcb0ui4JqPlueZ6OZ9zKn6eK/8//Z2jHcIXb5MRwDRgubOOSpTU5llEv3uvWfT10VzcMp99dySWq0AASw=="],
|
||||
"@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.90.10", "", { "dependencies": { "@hey-api/codegen-core": "^0.5.5", "@hey-api/json-schema-ref-parser": "1.2.2", "@hey-api/types": "0.1.2", "ansi-colors": "4.1.3", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.3" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-o0wlFxuLt1bcyIV/ZH8DQ1wrgODTnUYj/VfCHOOYgXUQlLp9Dm2PjihOz+WYrZLowhqUhSKeJRArOGzvLuOTsg=="],
|
||||
|
||||
"@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
|
||||
|
||||
@@ -3102,6 +3105,8 @@
|
||||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
@@ -77,6 +77,8 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
||||
"checkout.session.expired",
|
||||
"charge.refunded",
|
||||
"invoice.payment_succeeded",
|
||||
"invoice.payment_failed",
|
||||
"invoice.payment_action_required",
|
||||
"customer.created",
|
||||
"customer.deleted",
|
||||
"customer.updated",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-wSkJcUnS0ODOYkbkjRnxnjfWYKQOVXwkDNB8qrikuLA=",
|
||||
"aarch64-linux": "sha256-4BlpH/oIXRJEjkQydXDv1oi1Yx7li3k1dKHUy2/Gb10=",
|
||||
"aarch64-darwin": "sha256-awW0ooZo/QfB2xmRdZ9XLNzQ9sP/mbN+rTg215id6nc=",
|
||||
"x86_64-darwin": "sha256-CHrE2z+LqY2WXTQeGWG5LNMF1AY4UGSwViJAy4IwIVw="
|
||||
"x86_64-linux": "sha256-olTZ+tKugAY3LxizsJMlbK3TW78HZUoM03PigvQLP4A=",
|
||||
"aarch64-linux": "sha256-xdKDeqMEnYM2+vGySfb8pbcYyo/xMmgxG/ZhPCKaZEg=",
|
||||
"aarch64-darwin": "sha256-fihCTrHIiUG+py4vuqdr+YshqSKm2/B5onY50b97sPM=",
|
||||
"x86_64-darwin": "sha256-inlQQPNAOdkmKK6HQAMI2bG/ZFlfwmUQu9a6vm6Q0jQ="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -45,7 +45,6 @@ async function waitForHealth(url: string) {
|
||||
const appDir = process.cwd()
|
||||
const repoDir = path.resolve(appDir, "../..")
|
||||
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
||||
const modelsJson = path.join(opencodeDir, "test", "tool", "fixtures", "models-api.json")
|
||||
|
||||
const extraArgs = (() => {
|
||||
const args = process.argv.slice(2)
|
||||
@@ -59,8 +58,6 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
||||
|
||||
const serverEnv = {
|
||||
...process.env,
|
||||
MODELS_DEV_API_JSON: modelsJson,
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true",
|
||||
OPENCODE_DISABLE_SHARE: "true",
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
|
||||
@@ -187,7 +187,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2">
|
||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||
<div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||
</div>
|
||||
<span>{methodLabel(i)}</span>
|
||||
</div>
|
||||
@@ -373,6 +373,9 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
})
|
||||
|
||||
onMount(async () => {
|
||||
if (store.authorization?.url) {
|
||||
platform.openLink(store.authorization.url)
|
||||
}
|
||||
const result = await globalSDK.client.provider.oauth
|
||||
.callback({
|
||||
providerID: props.provider,
|
||||
|
||||
@@ -32,8 +32,8 @@ export function DialogSelectFile() {
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const [grouped, setGrouped] = createSignal(false)
|
||||
const common = [
|
||||
|
||||
@@ -35,9 +35,10 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("dialog.model.select.title")}>
|
||||
<div class="flex flex-col gap-3 px-2.5">
|
||||
<div class="flex flex-col gap-3 px-2.5 flex-1 min-h-0">
|
||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||
<List
|
||||
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
|
||||
ref={(ref) => (listRef = ref)}
|
||||
items={local.model.list}
|
||||
current={local.model.current()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createMemo, createSignal, JSX, Show, ValidComponent } from "solid-js"
|
||||
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
@@ -92,26 +93,118 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
triggerAs?: T
|
||||
triggerProps?: ComponentProps<T>
|
||||
}) {
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [store, setStore] = createStore<{
|
||||
open: boolean
|
||||
dismiss: "escape" | "outside" | null
|
||||
trigger?: HTMLElement
|
||||
content?: HTMLElement
|
||||
}>({
|
||||
open: false,
|
||||
dismiss: null,
|
||||
trigger: undefined,
|
||||
content: undefined,
|
||||
})
|
||||
const dialog = useDialog()
|
||||
|
||||
const handleManage = () => {
|
||||
setOpen(false)
|
||||
setStore("open", false)
|
||||
dialog.show(() => <DialogManageModels />)
|
||||
}
|
||||
const language = useLanguage()
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.open) return
|
||||
|
||||
const inside = (node: Node | null | undefined) => {
|
||||
if (!node) return false
|
||||
const el = store.content
|
||||
if (el && el.contains(node)) return true
|
||||
const anchor = store.trigger
|
||||
if (anchor && anchor.contains(node)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return
|
||||
setStore("dismiss", "escape")
|
||||
setStore("open", false)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (inside(target)) return
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}
|
||||
|
||||
const onFocusIn = (event: FocusEvent) => {
|
||||
if (!store.content) return
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (inside(target)) return
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, true)
|
||||
window.addEventListener("pointerdown", onPointerDown, true)
|
||||
window.addEventListener("focusin", onFocusIn, true)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", onKeyDown, true)
|
||||
window.removeEventListener("pointerdown", onPointerDown, true)
|
||||
window.removeEventListener("focusin", onFocusIn, true)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...(props.triggerProps as any)}>
|
||||
<Kobalte
|
||||
open={store.open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) setStore("dismiss", null)
|
||||
setStore("open", next)
|
||||
}}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
gutter={8}
|
||||
>
|
||||
<Kobalte.Trigger
|
||||
ref={(el) => setStore("trigger", el)}
|
||||
as={props.triggerAs ?? "div"}
|
||||
{...(props.triggerProps as any)}
|
||||
>
|
||||
{props.children}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
|
||||
<Kobalte.Content
|
||||
ref={(el) => setStore("content", el)}
|
||||
class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||
onEscapeKeyDown={(event) => {
|
||||
setStore("dismiss", "escape")
|
||||
setStore("open", false)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerDownOutside={() => {
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}}
|
||||
onFocusOutside={() => {
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (store.dismiss === "outside") event.preventDefault()
|
||||
setStore("dismiss", null)
|
||||
}}
|
||||
>
|
||||
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
|
||||
<ModelList
|
||||
provider={props.provider}
|
||||
onSelect={() => setOpen(false)}
|
||||
onSelect={() => setStore("open", false)}
|
||||
class="p-1"
|
||||
action={
|
||||
<IconButton
|
||||
|
||||
@@ -56,6 +56,12 @@ export const DialogSelectProvider: Component = () => {
|
||||
<Show when={i.id === "anthropic"}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
|
||||
</Show>
|
||||
<Show when={i.id === "openai"}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
|
||||
</Show>
|
||||
<Show when={i.id.startsWith("github-copilot")}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||
import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
|
||||
type ServerStatus = { healthy: boolean; version?: string }
|
||||
|
||||
async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> {
|
||||
interface AddRowProps {
|
||||
value: string
|
||||
placeholder: string
|
||||
adding: boolean
|
||||
error: string
|
||||
status: boolean | undefined
|
||||
onChange: (value: string) => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
onBlur: () => void
|
||||
}
|
||||
|
||||
interface EditRowProps {
|
||||
value: string
|
||||
placeholder: string
|
||||
busy: boolean
|
||||
error: string
|
||||
status: boolean | undefined
|
||||
onChange: (value: string) => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
onBlur: () => void
|
||||
}
|
||||
|
||||
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
fetch,
|
||||
fetch: platform.fetch,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return sdk.global
|
||||
@@ -26,21 +51,158 @@ async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promis
|
||||
.catch(() => ({ healthy: false }))
|
||||
}
|
||||
|
||||
function AddRow(props: AddRowProps) {
|
||||
return (
|
||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full absolute left-3 z-10 pointer-events-none": true,
|
||||
"bg-icon-success-base": props.status === true,
|
||||
"bg-icon-critical-base": props.status === false,
|
||||
"bg-border-weak-base": props.status === undefined,
|
||||
}}
|
||||
style={{ top: "50%", transform: "translateY(-50%)" }}
|
||||
ref={(el) => {
|
||||
// Position relative to input-wrapper
|
||||
requestAnimationFrame(() => {
|
||||
const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
|
||||
if (wrapper instanceof HTMLElement) {
|
||||
wrapper.style.position = "relative"
|
||||
wrapper.appendChild(el)
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="text"
|
||||
hideLabel
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
autofocus
|
||||
validationState={props.error ? "invalid" : "valid"}
|
||||
error={props.error}
|
||||
disabled={props.adding}
|
||||
onChange={props.onChange}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onBlur={props.onBlur}
|
||||
class="pl-7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditRow(props: EditRowProps) {
|
||||
return (
|
||||
<div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": props.status === true,
|
||||
"bg-icon-critical-base": props.status === false,
|
||||
"bg-border-weak-base": props.status === undefined,
|
||||
}}
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<TextField
|
||||
type="text"
|
||||
hideLabel
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
autofocus
|
||||
validationState={props.error ? "invalid" : "valid"}
|
||||
error={props.error}
|
||||
disabled={props.busy}
|
||||
onChange={props.onChange}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onBlur={props.onBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogSelectServer() {
|
||||
const navigate = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
url: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
status: {} as Record<string, ServerStatus | undefined>,
|
||||
addServer: {
|
||||
url: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
editServer: {
|
||||
id: undefined as string | undefined,
|
||||
value: "",
|
||||
error: "",
|
||||
busy: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
|
||||
const [defaultUrl, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
const url = await platform.getDefaultServerUrl?.()
|
||||
if (!url) return null
|
||||
return normalizeServerUrl(url) ?? null
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
const isDesktop = platform.platform === "desktop"
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return false
|
||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||
return host.includes(".") || host.includes(":")
|
||||
}
|
||||
|
||||
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
||||
setStatus(undefined)
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const result = await checkHealth(normalized, platform)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
const resetAdd = () => {
|
||||
setStore("addServer", {
|
||||
url: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const resetEdit = () => {
|
||||
setStore("editServer", {
|
||||
id: undefined,
|
||||
value: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
busy: false,
|
||||
})
|
||||
}
|
||||
|
||||
const replaceServer = (original: string, next: string) => {
|
||||
const active = server.url
|
||||
const nextActive = active === original ? next : active
|
||||
|
||||
server.add(next)
|
||||
if (nextActive) server.setActive(nextActive)
|
||||
server.remove(original)
|
||||
}
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.url
|
||||
const list = server.list
|
||||
@@ -74,7 +236,7 @@ export function DialogSelectServer() {
|
||||
const results: Record<string, ServerStatus> = {}
|
||||
await Promise.all(
|
||||
items().map(async (url) => {
|
||||
results[url] = await checkHealth(url, platform.fetch)
|
||||
results[url] = await checkHealth(url, platform)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
@@ -87,7 +249,7 @@ export function DialogSelectServer() {
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
function select(value: string, persist?: boolean) {
|
||||
async function select(value: string, persist?: boolean) {
|
||||
if (!persist && store.status[value]?.healthy === false) return
|
||||
dialog.close()
|
||||
if (persist) {
|
||||
@@ -99,24 +261,101 @@ export function DialogSelectServer() {
|
||||
navigate("/")
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const value = normalizeServerUrl(store.url)
|
||||
if (!value) return
|
||||
const handleAddChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
setStore("addServer", { url: value, error: "" })
|
||||
void previewStatus(value, (next) => setStore("addServer", { status: next }))
|
||||
}
|
||||
|
||||
setStore("adding", true)
|
||||
setStore("error", "")
|
||||
const scrollListToBottom = () => {
|
||||
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
|
||||
if (!scroll) return
|
||||
requestAnimationFrame(() => {
|
||||
scroll.scrollTop = scroll.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
const result = await checkHealth(value, platform.fetch)
|
||||
setStore("adding", false)
|
||||
const handleEditChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
setStore("editServer", { value, error: "" })
|
||||
void previewStatus(value, (next) => setStore("editServer", { status: next }))
|
||||
}
|
||||
|
||||
if (!result.healthy) {
|
||||
setStore("error", language.t("dialog.server.add.error"))
|
||||
async function handleAdd(value: string) {
|
||||
if (store.addServer.adding) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("url", "")
|
||||
select(value, true)
|
||||
setStore("addServer", { adding: true, error: "" })
|
||||
|
||||
const result = await checkHealth(normalized, platform)
|
||||
setStore("addServer", { adding: false })
|
||||
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
await select(normalized, true)
|
||||
}
|
||||
|
||||
async function handleEdit(original: string, value: string) {
|
||||
if (store.editServer.busy) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
if (normalized === original) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("editServer", { busy: true, error: "" })
|
||||
|
||||
const result = await checkHealth(normalized, platform)
|
||||
setStore("editServer", { busy: false })
|
||||
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
replaceServer(original, normalized)
|
||||
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const handleAddKey = (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
handleAdd(store.addServer.url)
|
||||
}
|
||||
|
||||
const blurAdd = () => {
|
||||
if (!store.addServer.url.trim()) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
handleAdd(store.addServer.url)
|
||||
}
|
||||
|
||||
const handleEditKey = (event: KeyboardEvent, original: string) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
handleEdit(original, store.editServer.value)
|
||||
}
|
||||
|
||||
async function handleRemove(url: string) {
|
||||
@@ -124,125 +363,203 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("dialog.server.title")} description={language.t("dialog.server.description")}>
|
||||
<div class="flex flex-col gap-4 pb-4">
|
||||
<Dialog title={language.t("dialog.server.title")}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<List
|
||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: true }}
|
||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={sortedItems}
|
||||
key={(x) => x}
|
||||
current={current()}
|
||||
onSelect={(x) => {
|
||||
if (x) select(x)
|
||||
}}
|
||||
onFilter={(value) => {
|
||||
if (value && store.addServer.showForm && !store.addServer.adding) {
|
||||
resetAdd()
|
||||
}
|
||||
}}
|
||||
divider={true}
|
||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
||||
add={
|
||||
store.addServer.showForm
|
||||
? {
|
||||
render: () => (
|
||||
<AddRow
|
||||
value={store.addServer.url}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
adding={store.addServer.adding}
|
||||
error={store.addServer.error}
|
||||
status={store.addServer.status}
|
||||
onChange={handleAddChange}
|
||||
onKeyDown={handleAddKey}
|
||||
onBlur={blurAdd}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1 group/item">
|
||||
<div
|
||||
class="flex items-center gap-2 min-w-0 flex-1"
|
||||
classList={{ "opacity-50": store.status[i]?.healthy === false }}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": store.status[i]?.healthy === true,
|
||||
"bg-icon-critical-base": store.status[i]?.healthy === false,
|
||||
"bg-border-weak-base": store.status[i] === undefined,
|
||||
}}
|
||||
/>
|
||||
<span class="truncate">{serverDisplayName(i)}</span>
|
||||
<span class="text-text-weak">{store.status[i]?.version}</span>
|
||||
{(i) => {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
|
||||
setTruncated(nameTruncated || versionTruncated)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
check()
|
||||
window.addEventListener("resize", check)
|
||||
onCleanup(() => window.removeEventListener("resize", check))
|
||||
})
|
||||
|
||||
const tooltipValue = () => {
|
||||
const name = serverDisplayName(i)
|
||||
const version = store.status[i]?.version
|
||||
return (
|
||||
<span class="flex items-center gap-2">
|
||||
<span>{name}</span>
|
||||
<Show when={version}>
|
||||
<span class="text-text-invert-base">{version}</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||
<Show
|
||||
when={store.editServer.id !== i}
|
||||
fallback={
|
||||
<EditRow
|
||||
value={store.editServer.value}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={store.editServer.busy}
|
||||
error={store.editServer.error}
|
||||
status={store.editServer.status}
|
||||
onChange={handleEditChange}
|
||||
onKeyDown={(event) => handleEditKey(event, i)}
|
||||
onBlur={() => handleEdit(i, store.editServer.value)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||
classList={{ "opacity-50": store.status[i]?.healthy === false }}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": store.status[i]?.healthy === true,
|
||||
"bg-icon-critical-base": store.status[i]?.healthy === false,
|
||||
"bg-border-weak-base": store.status[i] === undefined,
|
||||
}}
|
||||
/>
|
||||
<span ref={nameRef} class="truncate">
|
||||
{serverDisplayName(i)}
|
||||
</span>
|
||||
<Show when={store.status[i]?.version}>
|
||||
<span ref={versionRef} class="text-text-weak text-14-regular truncate">
|
||||
{store.status[i]?.version}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={defaultUrl() === i}>
|
||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={store.editServer.id !== i}>
|
||||
<div class="flex items-center justify-center gap-5 pl-4">
|
||||
<Show when={current() === i}>
|
||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||
</Show>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setStore("editServer", {
|
||||
id: i,
|
||||
value: i,
|
||||
error: "",
|
||||
status: store.status[i]?.healthy,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={isDesktop && defaultUrl() !== i}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={async () => {
|
||||
await platform.setDefaultServerUrl?.(i)
|
||||
defaultUrlActions.mutate(i)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={isDesktop && defaultUrl() === i}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={async () => {
|
||||
await platform.setDefaultServerUrl?.(null)
|
||||
defaultUrlActions.mutate(null)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => handleRemove(i)}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={current() !== i && server.list.includes(i)}>
|
||||
<IconButton
|
||||
icon="circle-x"
|
||||
variant="ghost"
|
||||
class="bg-transparent transition-opacity shrink-0 hover:scale-110"
|
||||
aria-label={language.t("dialog.server.action.remove")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(i)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
|
||||
<div class="mt-6 px-3 flex flex-col gap-1.5">
|
||||
<div class="px-3">
|
||||
<h3 class="text-14-regular text-text-weak">{language.t("dialog.server.add.title")}</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1 min-w-0 h-auto">
|
||||
<TextField
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.url")}
|
||||
hideLabel
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
value={store.url}
|
||||
onChange={(v) => {
|
||||
setStore("url", v)
|
||||
setStore("error", "")
|
||||
}}
|
||||
validationState={store.error ? "invalid" : "valid"}
|
||||
error={store.error}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
|
||||
{store.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="px-5 pb-5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setStore("addServer", { showForm: true, url: "", error: "" })
|
||||
scrollListToBottom()
|
||||
}}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={isDesktop}>
|
||||
<div class="mt-6 px-3 flex flex-col gap-1.5">
|
||||
<div class="px-3">
|
||||
<h3 class="text-14-regular text-text-weak">{language.t("dialog.server.default.title")}</h3>
|
||||
<p class="text-12-regular text-text-weak mt-1">{language.t("dialog.server.default.description")}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-2">
|
||||
<Show
|
||||
when={defaultUrl()}
|
||||
fallback={
|
||||
<Show
|
||||
when={server.url}
|
||||
fallback={
|
||||
<span class="text-14-regular text-text-weak">{language.t("dialog.server.default.none")}</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
await platform.setDefaultServerUrl?.(server.url)
|
||||
defaultUrlActions.refetch(server.url)
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.server.default.set")}
|
||||
</Button>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
await platform.setDefaultServerUrl?.(null)
|
||||
defaultUrlActions.refetch()
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.server.default.clear")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
@@ -48,6 +48,7 @@ import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -61,6 +62,13 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
cleanup: VoidFunction
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingPrompt>()
|
||||
|
||||
interface PromptInputProps {
|
||||
class?: string
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
@@ -115,6 +123,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const local = useLocal()
|
||||
const files = useFile()
|
||||
const prompt = usePrompt()
|
||||
const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length)
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
const params = useParams()
|
||||
@@ -128,6 +137,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
let scrollRef!: HTMLDivElement
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
|
||||
const mirror = { input: false }
|
||||
|
||||
const scrollCursorIntoView = () => {
|
||||
const container = scrollRef
|
||||
const selection = window.getSelection()
|
||||
@@ -159,8 +170,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
const commentInReview = (path: string) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return false
|
||||
|
||||
const diffs = sync.data.session_diff[sessionID]
|
||||
if (!diffs) return false
|
||||
return diffs.some((diff) => diff.file === path)
|
||||
}
|
||||
|
||||
const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => {
|
||||
if (!item.commentID) return
|
||||
|
||||
const focus = { file: item.path, id: item.commentID }
|
||||
comments.setActive(focus)
|
||||
view().reviewPanel.open()
|
||||
|
||||
if (item.commentOrigin === "review") {
|
||||
tabs().open("review")
|
||||
requestAnimationFrame(() => comments.setFocus(focus))
|
||||
return
|
||||
}
|
||||
|
||||
if (item.commentOrigin !== "file" && commentInReview(item.path)) {
|
||||
tabs().open("review")
|
||||
requestAnimationFrame(() => comments.setFocus(focus))
|
||||
return
|
||||
}
|
||||
|
||||
const tab = files.tab(item.path)
|
||||
tabs().open(tab)
|
||||
files.load(item.path)
|
||||
requestAnimationFrame(() => comments.setFocus(focus))
|
||||
}
|
||||
|
||||
const recent = createMemo(() => {
|
||||
const all = tabs().all()
|
||||
@@ -611,6 +656,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
() => prompt.current(),
|
||||
(currentParts) => {
|
||||
const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
|
||||
|
||||
if (mirror.input) {
|
||||
mirror.input = false
|
||||
if (isNormalizedEditor()) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
let cursorPosition: number | null = null
|
||||
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
|
||||
cursorPosition = getCursorPosition(editorRef)
|
||||
}
|
||||
|
||||
renderEditor(inputParts)
|
||||
|
||||
if (cursorPosition !== null) {
|
||||
setCursorPosition(editorRef, cursorPosition)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const domParts = parseFromDOM()
|
||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||
|
||||
@@ -725,6 +789,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setStore("savedPrompt", null)
|
||||
}
|
||||
if (prompt.dirty()) {
|
||||
mirror.input = true
|
||||
prompt.set(DEFAULT_PROMPT, 0)
|
||||
}
|
||||
queueScroll()
|
||||
@@ -755,6 +820,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setStore("savedPrompt", null)
|
||||
}
|
||||
|
||||
mirror.input = true
|
||||
prompt.set([...rawParts, ...images], cursorPosition)
|
||||
queueScroll()
|
||||
}
|
||||
@@ -846,12 +912,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setStore("popover", null)
|
||||
}
|
||||
|
||||
const abort = () =>
|
||||
sdk.client.session
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
const queued = pending.get(sessionID)
|
||||
if (queued) {
|
||||
queued.abort.abort()
|
||||
queued.cleanup()
|
||||
pending.delete(sessionID)
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk.client.session
|
||||
.abort({
|
||||
sessionID: params.id!,
|
||||
sessionID,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
const text = prompt
|
||||
@@ -1111,6 +1187,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
return
|
||||
}
|
||||
WorktreeState.pending(createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
@@ -1369,10 +1446,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
model,
|
||||
}
|
||||
|
||||
const setSyncStore = sessionDirectory === projectDirectory ? sync.set : globalSync.child(sessionDirectory)[1]
|
||||
|
||||
const addOptimisticMessage = () => {
|
||||
setSyncStore(
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session.id]
|
||||
if (!messages) {
|
||||
draft.message[session.id] = [optimisticMessage]
|
||||
} else {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, optimisticMessage)
|
||||
}
|
||||
draft.part[messageID] = optimisticParts
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.child(sessionDirectory)[1](
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session.id]
|
||||
if (!messages) {
|
||||
@@ -1390,7 +1484,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
setSyncStore(
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session.id]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
delete draft.part[messageID]
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.child(sessionDirectory)[1](
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session.id]
|
||||
if (messages) {
|
||||
@@ -1409,20 +1517,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
clearInput()
|
||||
addOptimisticMessage()
|
||||
|
||||
client.session
|
||||
.prompt({
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sessionDirectory)
|
||||
if (!worktree || worktree.status !== "pending") return true
|
||||
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
const cleanup = () => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
for (const item of commentItems) {
|
||||
prompt.context.add({
|
||||
@@ -1431,11 +1539,79 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
})
|
||||
}
|
||||
restoreInput()
|
||||
}
|
||||
|
||||
pending.set(session.id, { abort: controller, cleanup })
|
||||
|
||||
const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ status: "failed", message: "Workspace is still preparing" })
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout])
|
||||
pending.delete(session.id)
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") throw new Error(result.message)
|
||||
return true
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const ok = await waitForWorktree()
|
||||
if (!ok) return
|
||||
await client.session.prompt({
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
|
||||
void send().catch((err) => {
|
||||
pending.delete(session.id)
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
for (const item of commentItems) {
|
||||
prompt.context.add({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
})
|
||||
}
|
||||
restoreInput()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1545,7 +1721,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
classList={{
|
||||
"group/prompt-input": true,
|
||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
||||
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
|
||||
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
|
||||
"border-icon-info-active border-dashed": store.dragging,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
@@ -1559,54 +1735,78 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={prompt.context.items().length > 0}>
|
||||
<div class="flex flex-nowrap items-start gap-1.5 px-3 pt-3 overflow-x-auto no-scrollbar">
|
||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||
<For each={prompt.context.items()}>
|
||||
{(item) => {
|
||||
const active = () => {
|
||||
const a = comments.active()
|
||||
return !!item.commentID && item.commentID === a?.id && item.path === a?.file
|
||||
}
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]": true,
|
||||
"cursor-pointer hover:bg-surface-raised-base-hover": !!item.commentID,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!item.commentID) return
|
||||
comments.setFocus({ file: item.path, id: item.commentID })
|
||||
view().reviewPanel.open()
|
||||
tabs().open("review")
|
||||
}}
|
||||
<Tooltip
|
||||
value={
|
||||
<span class="flex max-w-[300px]">
|
||||
<span
|
||||
class="text-text-invert-base truncate min-w-0"
|
||||
style={{ direction: "rtl", "text-align": "left", "unicode-bidi": "plaintext" }}
|
||||
>
|
||||
{getDirectory(item.path)}
|
||||
</span>
|
||||
<span class="shrink-0">{getFilename(item.path)}</span>
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap ml-1">
|
||||
{sel().startLine === sel().endLine
|
||||
? `:${sel().startLine}`
|
||||
: `:${sel().startLine}-${sel().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !active(),
|
||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
||||
active(),
|
||||
"bg-background-stronger": !active(),
|
||||
}}
|
||||
onClick={() => {
|
||||
openComment(item)
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div
|
||||
class="flex items-center text-11-regular min-w-0"
|
||||
style={{ "font-weight": "var(--font-weight-medium)" }}
|
||||
>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||
{sel().startLine === sel().endLine
|
||||
? `:${sel().startLine}`
|
||||
: `:${sel().startLine}-${sel().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
aria-label={language.t("prompt.context.removeFile")}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="close"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
aria-label={language.t("prompt.context.removeFile")}
|
||||
/>
|
||||
<Show when={item.comment}>
|
||||
{(comment) => (
|
||||
<div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={item.comment}>
|
||||
{(comment) => <div class="text-11-regular text-text-strong">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
@@ -1662,7 +1862,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
aria-label={
|
||||
store.mode === "shell"
|
||||
? language.t("prompt.placeholder.shell")
|
||||
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
|
||||
: commentCount() > 1
|
||||
? "Summarize comments…"
|
||||
: commentCount() === 1
|
||||
? "Summarize comment…"
|
||||
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
|
||||
}
|
||||
contenteditable="true"
|
||||
onInput={handleInput}
|
||||
@@ -1672,17 +1876,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
"w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
/>
|
||||
<Show when={!prompt.dirty()}>
|
||||
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
||||
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
||||
{store.mode === "shell"
|
||||
? language.t("prompt.placeholder.shell")
|
||||
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
|
||||
: commentCount() > 1
|
||||
? "Summarize comments…"
|
||||
: commentCount() === 1
|
||||
? "Summarize comment…"
|
||||
: language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -1789,7 +1997,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 absolute right-2 bottom-2">
|
||||
<div class="flex items-center gap-3 absolute right-3 bottom-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
@@ -21,22 +22,26 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(language.locale(), {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}),
|
||||
)
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const locale = language.locale()
|
||||
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
return usd().format(total)
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const locale = language.locale()
|
||||
const last = messages().findLast((x) => {
|
||||
const last = findLast(messages(), (x) => {
|
||||
if (x.role !== "assistant") return false
|
||||
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
|
||||
return total > 0
|
||||
@@ -84,9 +89,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
<span class="text-text-invert-strong">{cost()}</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.cost")}</span>
|
||||
</div>
|
||||
<Show when={variant() === "button"}>
|
||||
<div class="text-11-regular text-text-invert-base mt-1">{language.t("context.usage.clickToView")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
|
||||
export function SessionLspIndicator() {
|
||||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
|
||||
const lspStats = createMemo(() => {
|
||||
const lsp = sync.data.lsp ?? []
|
||||
const connected = lsp.filter((s) => s.status === "connected").length
|
||||
const hasError = lsp.some((s) => s.status === "error")
|
||||
const total = lsp.length
|
||||
return { connected, hasError, total }
|
||||
})
|
||||
|
||||
const tooltipContent = createMemo(() => {
|
||||
const lsp = sync.data.lsp ?? []
|
||||
if (lsp.length === 0) return language.t("lsp.tooltip.none")
|
||||
return lsp.map((s) => s.name).join(", ")
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={lspStats().total > 0}>
|
||||
<Tooltip placement="top" value={tooltipContent()}>
|
||||
<div class="flex items-center gap-1 px-2 cursor-default select-none">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-critical-base": lspStats().hasError,
|
||||
"bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
|
||||
}}
|
||||
/>
|
||||
<span class="text-12-regular text-text-weak">
|
||||
{language.t("lsp.label.connected", { count: lspStats().connected })}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||
|
||||
export function SessionMcpIndicator() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
|
||||
const mcpStats = createMemo(() => {
|
||||
const mcp = sync.data.mcp ?? {}
|
||||
const entries = Object.entries(mcp)
|
||||
const enabled = entries.filter(([, status]) => status.status === "connected").length
|
||||
const failed = entries.some(([, status]) => status.status === "failed")
|
||||
const total = entries.length
|
||||
return { enabled, failed, total }
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={mcpStats().total > 0}>
|
||||
<Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-critical-base": mcpStats().failed,
|
||||
"bg-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
|
||||
}}
|
||||
/>
|
||||
<span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
|
||||
</Button>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { DateTime } from "luxon"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
@@ -25,8 +26,16 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(language.locale(), {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}),
|
||||
)
|
||||
|
||||
const ctx = createMemo(() => {
|
||||
const last = props.messages().findLast((x) => {
|
||||
const last = findLast(props.messages(), (x) => {
|
||||
if (x.role !== "assistant") return false
|
||||
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
|
||||
return total > 0
|
||||
@@ -61,12 +70,8 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
})
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const locale = language.locale()
|
||||
const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
return usd().format(total)
|
||||
})
|
||||
|
||||
const counts = createMemo(() => {
|
||||
@@ -81,7 +86,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
})
|
||||
|
||||
const systemPrompt = createMemo(() => {
|
||||
const msg = props.visibleUserMessages().findLast((m) => !!m.system)
|
||||
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
|
||||
const system = msg?.system
|
||||
if (!system) return
|
||||
const trimmed = system.trim()
|
||||
|
||||
@@ -5,8 +5,6 @@ import { useParams } from "@solidjs/router"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
// import { useServer } from "@/context/server"
|
||||
// import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
@@ -20,14 +18,13 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
|
||||
export function SessionHeader() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const command = useCommand()
|
||||
// const server = useServer()
|
||||
// const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
@@ -50,7 +47,7 @@ export function SessionHeader() {
|
||||
const showShare = createMemo(() => shareEnabled() && !!currentSession())
|
||||
const showReview = createMemo(() => !!currentSession())
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
const [state, setState] = createStore({
|
||||
share: false,
|
||||
@@ -154,65 +151,104 @@ export function SessionHeader() {
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<div class="flex items-center gap-3">
|
||||
{/* <div class="hidden md:flex items-center gap-1"> */}
|
||||
{/* <Button */}
|
||||
{/* size="small" */}
|
||||
{/* variant="ghost" */}
|
||||
{/* onClick={() => { */}
|
||||
{/* dialog.show(() => <DialogSelectServer />) */}
|
||||
{/* }} */}
|
||||
{/* > */}
|
||||
{/* <div */}
|
||||
{/* classList={{ */}
|
||||
{/* "size-1.5 rounded-full": true, */}
|
||||
{/* "bg-icon-success-base": server.healthy() === true, */}
|
||||
{/* "bg-icon-critical-base": server.healthy() === false, */}
|
||||
{/* "bg-border-weak-base": server.healthy() === undefined, */}
|
||||
{/* }} */}
|
||||
{/* /> */}
|
||||
{/* <Icon name="server" size="small" class="text-icon-weak" /> */}
|
||||
{/* <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */}
|
||||
{/* </Button> */}
|
||||
{/* <SessionLspIndicator /> */}
|
||||
{/* <SessionMcpIndicator /> */}
|
||||
{/* </div> */}
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="hidden md:block shrink-0">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.review.toggle")}
|
||||
keybind={command.keybind("review.toggle")}
|
||||
<StatusPopover />
|
||||
<Show when={showShare()}>
|
||||
<div class="flex items-center">
|
||||
<Popover
|
||||
title={language.t("session.share.popover.title")}
|
||||
description={
|
||||
shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")
|
||||
}
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
shift={-64}
|
||||
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "secondary",
|
||||
class: "rounded-sm w-[60px] h-[24px]",
|
||||
classList: { "rounded-r-none": shareUrl() !== undefined },
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={language.t("session.share.action.share")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/review-toggle size-6 p-0"
|
||||
onClick={() => view().reviewPanel.toggle()}
|
||||
aria-label={language.t("command.review.toggle")}
|
||||
aria-expanded={view().reviewPanel.opened()}
|
||||
aria-controls="review-panel"
|
||||
tabIndex={showReview() ? 0 : -1}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={shareSession}
|
||||
disabled={state.share}
|
||||
>
|
||||
{state.share
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField value={shareUrl() ?? ""} readOnly copyable tabIndex={-1} class="w-full" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{state.unshare
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
|
||||
<Tooltip
|
||||
value={
|
||||
state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-right-partial"
|
||||
class="hidden group-hover/review-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "link"}
|
||||
variant="secondary"
|
||||
class="rounded-l-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
aria-label={
|
||||
state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="hidden md:flex items-center gap-3 ml-2 shrink-0">
|
||||
<TooltipKeybind
|
||||
class="hidden md:block shrink-0"
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
@@ -244,96 +280,37 @@ export function SessionHeader() {
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<Show when={showShare()}>
|
||||
<div class="flex items-center">
|
||||
<Popover
|
||||
title={language.t("session.share.popover.title")}
|
||||
description={
|
||||
shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")
|
||||
}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "secondary",
|
||||
classList: { "rounded-r-none": shareUrl() !== undefined },
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={language.t("session.share.action.share")}
|
||||
<div class="hidden md:block shrink-0">
|
||||
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/review-toggle size-6 p-0"
|
||||
onClick={() => view().reviewPanel.toggle()}
|
||||
aria-label={language.t("command.review.toggle")}
|
||||
aria-expanded={view().reviewPanel.opened()}
|
||||
aria-controls="review-panel"
|
||||
tabIndex={showReview() ? 0 : -1}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={shareSession}
|
||||
disabled={state.share}
|
||||
>
|
||||
{state.share
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2 w-72">
|
||||
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{state.unshare
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
|
||||
<Tooltip
|
||||
value={
|
||||
state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "copy"}
|
||||
variant="secondary"
|
||||
class="rounded-l-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
aria-label={
|
||||
state.copied
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("session.share.copy.copyLink")
|
||||
}
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-right-partial"
|
||||
class="hidden group-hover/review-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
|
||||
const MAIN_WORKTREE = "main"
|
||||
const CREATE_WORKTREE = "create"
|
||||
@@ -60,18 +59,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
</div>
|
||||
<div class="flex justify-center items-center gap-1">
|
||||
<Icon name="branch" size="small" />
|
||||
<Select
|
||||
options={options()}
|
||||
current={current()}
|
||||
value={(x) => x}
|
||||
label={label}
|
||||
onSelect={(value) => {
|
||||
props.onWorktreeChange(value ?? MAIN_WORKTREE)
|
||||
}}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
class="text-12-medium"
|
||||
/>
|
||||
<div class="text-12-medium text-text-weak select-text ml-2">{label(current())}</div>
|
||||
</div>
|
||||
<Show when={sync.project}>
|
||||
{(project) => (
|
||||
|
||||
@@ -7,6 +7,25 @@ import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
timeout: undefined as NodeJS.Timeout | undefined,
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const playDemoSound = (src: string) => {
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
|
||||
clearTimeout(demoSoundState.timeout)
|
||||
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
demoSoundState.cleanup = playSound(src)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
export const SettingsGeneral: Component = () => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
@@ -36,6 +55,7 @@ export const SettingsGeneral: Component = () => {
|
||||
{ value: "hack", label: "font.option.hack" },
|
||||
{ value: "inconsolata", label: "font.option.inconsolata" },
|
||||
{ value: "intel-one-mono", label: "font.option.intelOneMono" },
|
||||
{ value: "iosevka", label: "font.option.iosevka" },
|
||||
{ value: "jetbrains-mono", label: "font.option.jetbrainsMono" },
|
||||
{ value: "meslo-lgs", label: "font.option.mesloLgs" },
|
||||
{ value: "roboto-mono", label: "font.option.robotoMono" },
|
||||
@@ -210,12 +230,12 @@ export const SettingsGeneral: Component = () => {
|
||||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setAgent(option.id)
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
@@ -234,12 +254,12 @@ export const SettingsGeneral: Component = () => {
|
||||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setPermissions(option.id)
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
@@ -258,12 +278,12 @@ export const SettingsGeneral: Component = () => {
|
||||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setErrors(option.id)
|
||||
playSound(option.src)
|
||||
playDemoSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
|
||||
type ServerStatus = { healthy: boolean; version?: string }
|
||||
|
||||
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: url,
|
||||
fetch: platform.fetch,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return sdk.global
|
||||
.health()
|
||||
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||
.catch(() => ({ healthy: false }))
|
||||
}
|
||||
|
||||
export function StatusPopover() {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<string, ServerStatus | undefined>,
|
||||
})
|
||||
|
||||
const servers = createMemo(() => {
|
||||
const current = server.url
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((x) => x !== current)]
|
||||
})
|
||||
|
||||
const sortedServers = createMemo(() => {
|
||||
const list = servers()
|
||||
if (!list.length) return list
|
||||
const active = server.url
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerStatus) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff = rank(store.status[a]) - rank(store.status[b])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
async function refreshHealth() {
|
||||
const results: Record<string, ServerStatus> = {}
|
||||
await Promise.all(
|
||||
servers().map(async (url) => {
|
||||
results[url] = await checkHealth(url, platform)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
servers()
|
||||
refreshHealth()
|
||||
const interval = setInterval(refreshHealth, 10_000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const mcpItems = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
.map(([name, status]) => ({ name, status: status.status }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
|
||||
|
||||
const toggleMcp = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
} else {
|
||||
await sdk.client.mcp.connect({ name })
|
||||
}
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
setLoading(null)
|
||||
}
|
||||
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
|
||||
const overallHealthy = createMemo(() => {
|
||||
const serverHealthy = server.healthy() === true
|
||||
const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled")
|
||||
return serverHealthy && !anyMcpIssue
|
||||
})
|
||||
|
||||
const serverCount = createMemo(() => sortedServers().length)
|
||||
|
||||
const [defaultServerUrl, setDefaultServerUrl] = createSignal<string | undefined>()
|
||||
|
||||
createEffect(() => {
|
||||
const result = platform.getDefaultServerUrl?.()
|
||||
if (result instanceof Promise) {
|
||||
result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined))
|
||||
return
|
||||
}
|
||||
if (result) setDefaultServerUrl(normalizeServerUrl(result))
|
||||
})
|
||||
|
||||
return (
|
||||
<Popover
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-sm w-[75px] h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none data-[expanded]:bg-surface-raised-base-active",
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
}}
|
||||
/>
|
||||
<span class="text-12-regular text-text-strong">Status</span>
|
||||
</div>
|
||||
}
|
||||
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
shift={-136}
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1 w-[360px] rounded-xl"
|
||||
style={{ "box-shadow": "var(--shadow-lg-border-base)" }}
|
||||
>
|
||||
<Tabs
|
||||
aria-label="Server Configurations"
|
||||
class="tabs"
|
||||
data-component="tabs"
|
||||
data-active="servers"
|
||||
defaultValue="servers"
|
||||
variant="alt"
|
||||
style={{
|
||||
"background-color": "var(--background-strong)",
|
||||
"border-radius": "12px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Tabs.List
|
||||
data-slot="tablist"
|
||||
style={{
|
||||
"background-color": "transparent",
|
||||
"border-bottom": "none",
|
||||
padding: "8px 16px 0",
|
||||
gap: "16px",
|
||||
height: "40px",
|
||||
}}
|
||||
>
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{serverCount() > 0 ? `${serverCount()} ` : ""}Servers
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}MCP
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}LSP
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}Plugins
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="servers">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={sortedServers()}>
|
||||
{(url) => {
|
||||
const isActive = () => url === server.url
|
||||
const isDefault = () => url === defaultServerUrl()
|
||||
const status = () => store.status[url]
|
||||
const isBlocked = () => status()?.healthy === false
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
|
||||
setTruncated(nameTruncated || versionTruncated)
|
||||
}
|
||||
check()
|
||||
window.addEventListener("resize", check)
|
||||
onCleanup(() => window.removeEventListener("resize", check))
|
||||
})
|
||||
|
||||
const tooltipValue = () => {
|
||||
const name = serverDisplayName(url)
|
||||
const version = status()?.version
|
||||
return (
|
||||
<span class="flex items-center gap-2">
|
||||
<span>{name}</span>
|
||||
<Show when={version}>
|
||||
<span class="text-text-invert-base">{version}</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"opacity-50": isBlocked(),
|
||||
"hover:bg-surface-raised-base-hover": !isBlocked(),
|
||||
"cursor-not-allowed": isBlocked(),
|
||||
}}
|
||||
aria-disabled={isBlocked()}
|
||||
onClick={() => {
|
||||
if (isBlocked()) return
|
||||
server.setActive(url)
|
||||
navigate("/")
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": status()?.healthy === true,
|
||||
"bg-icon-critical-base": status()?.healthy === false,
|
||||
"bg-border-weak-base": status() === undefined,
|
||||
}}
|
||||
/>
|
||||
<span ref={nameRef} class="text-14-regular text-text-base truncate">
|
||||
{serverDisplayName(url)}
|
||||
</span>
|
||||
<Show when={status()?.version}>
|
||||
<span ref={versionRef} class="text-12-regular text-text-weak truncate">
|
||||
{status()?.version}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={isDefault()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
Default
|
||||
</span>
|
||||
</Show>
|
||||
<div class="flex-1" />
|
||||
<Show when={isActive()}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
>
|
||||
Manage servers
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="mcp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={mcpItems().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">No MCP servers configured</div>
|
||||
}
|
||||
>
|
||||
<For each={mcpItems()}>
|
||||
{(item) => {
|
||||
const enabled = () => item.status === "connected"
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => toggleMcp(item.name)}
|
||||
disabled={loading() === item.name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": item.status === "connected",
|
||||
"bg-icon-critical-base": item.status === "failed",
|
||||
"bg-border-weak-base": item.status === "disabled",
|
||||
"bg-icon-warning-base":
|
||||
item.status === "needs_auth" || item.status === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{item.name}</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={loading() === item.name}
|
||||
onChange={() => toggleMcp(item.name)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="lsp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={lspItems().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">
|
||||
LSPs auto-detected from file types
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={lspItems()}>
|
||||
{(item) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": item.status === "connected",
|
||||
"bg-icon-critical-base": item.status === "error",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">
|
||||
Plugins configured in{" "}
|
||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">opencode.json</code>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -111,6 +111,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const mod = await import("ghostty-web")
|
||||
ghostty = await mod.Ghostty.load()
|
||||
|
||||
const once = { value: false }
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
||||
if (window.__OPENCODE__?.serverPassword) {
|
||||
url.username = "opencode"
|
||||
@@ -258,6 +260,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
})
|
||||
socket.addEventListener("error", (error) => {
|
||||
if (disposed) return
|
||||
if (once.value) return
|
||||
once.value = true
|
||||
console.error("WebSocket error:", error)
|
||||
local.onConnectError?.(error)
|
||||
})
|
||||
@@ -266,6 +270,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
|
||||
// For other codes (network issues, server restart), trigger error handler
|
||||
if (event.code !== 1000) {
|
||||
if (once.value) return
|
||||
once.value = true
|
||||
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,9 +19,6 @@ export function Titlebar() {
|
||||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
const reserve = createMemo(
|
||||
() => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"),
|
||||
)
|
||||
const web = createMemo(() => platform.platform === "web")
|
||||
|
||||
const getWin = () => {
|
||||
@@ -81,7 +78,7 @@ export function Titlebar() {
|
||||
classList={{
|
||||
"flex items-center w-full min-w-0": true,
|
||||
"pl-2": !mac(),
|
||||
"pr-2": !windows(),
|
||||
"pr-6": !windows(),
|
||||
}}
|
||||
onMouseDown={drag}
|
||||
data-tauri-drag-region
|
||||
@@ -145,6 +142,7 @@ export function Titlebar() {
|
||||
data-tauri-drag-region
|
||||
/>
|
||||
<Show when={windows()}>
|
||||
<div class="w-6 shrink-0" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,7 @@ function createCommentSession(dir: string, id: string | undefined) {
|
||||
)
|
||||
|
||||
const [focus, setFocus] = createSignal<CommentFocus | null>(null)
|
||||
const [active, setActive] = createSignal<CommentFocus | null>(null)
|
||||
|
||||
const list = (file: string) => store.comments[file] ?? []
|
||||
|
||||
@@ -76,6 +77,9 @@ function createCommentSession(dir: string, id: string | undefined) {
|
||||
focus: createMemo(() => focus()),
|
||||
setFocus,
|
||||
clearFocus: () => setFocus(null),
|
||||
active: createMemo(() => active()),
|
||||
setActive,
|
||||
clearActive: () => setActive(null),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +139,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
focus: () => session().focus(),
|
||||
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
|
||||
clearFocus: () => session().clearFocus(),
|
||||
active: () => session().active(),
|
||||
setActive: (active: CommentFocus | null) => session().setActive(active),
|
||||
clearActive: () => session().clearActive(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -189,6 +189,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
|
||||
const scope = createMemo(() => sdk.directory)
|
||||
|
||||
const directory = createMemo(() => sync.data.path.directory)
|
||||
|
||||
function normalize(input: string) {
|
||||
@@ -234,6 +236,12 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
file: {},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
scope()
|
||||
inflight.clear()
|
||||
setStore("file", {})
|
||||
})
|
||||
|
||||
const viewCache = new Map<string, ViewCacheEntry>()
|
||||
|
||||
const disposeViews = () => {
|
||||
@@ -284,12 +292,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
const path = normalize(input)
|
||||
if (!path) return Promise.resolve()
|
||||
|
||||
const directory = scope()
|
||||
const key = `${directory}\n${path}`
|
||||
const client = sdk.client
|
||||
|
||||
ensure(path)
|
||||
|
||||
const current = store.file[path]
|
||||
if (!options?.force && current?.loaded) return Promise.resolve()
|
||||
|
||||
const pending = inflight.get(path)
|
||||
const pending = inflight.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
setStore(
|
||||
@@ -301,9 +313,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
}),
|
||||
)
|
||||
|
||||
const promise = sdk.client.file
|
||||
const promise = client.file
|
||||
.read({ path })
|
||||
.then((x) => {
|
||||
if (scope() !== directory) return
|
||||
setStore(
|
||||
"file",
|
||||
path,
|
||||
@@ -315,6 +328,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (scope() !== directory) return
|
||||
setStore(
|
||||
"file",
|
||||
path,
|
||||
@@ -330,10 +344,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(path)
|
||||
inflight.delete(key)
|
||||
})
|
||||
|
||||
inflight.set(path, promise)
|
||||
inflight.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
type Queued = { directory: string; payload: Event }
|
||||
|
||||
let queue: Array<Queued | undefined> = []
|
||||
let buffer: Array<Queued | undefined> = []
|
||||
const coalesced = new Map<string, number>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
@@ -41,10 +42,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
queue = []
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
if (events.length === 0) return
|
||||
|
||||
last = Date.now()
|
||||
batch(() => {
|
||||
@@ -53,6 +57,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
emitter.emit(event.directory, event.payload)
|
||||
}
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
@@ -61,10 +67,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
flush()
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const events = await eventSdk.global.event()
|
||||
let yielded = Date.now()
|
||||
@@ -87,12 +89,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
})()
|
||||
.finally(stop)
|
||||
.finally(flush)
|
||||
.catch(() => undefined)
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
stop()
|
||||
flush()
|
||||
})
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
|
||||
@@ -119,6 +119,16 @@ type ChildOptions = {
|
||||
bootstrap?: boolean
|
||||
}
|
||||
|
||||
function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||
return {
|
||||
...input,
|
||||
all: input.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
@@ -129,6 +139,21 @@ function createGlobalSync() {
|
||||
const metaCache = new Map<string, MetaCache>()
|
||||
const iconCache = new Map<string, IconCache>()
|
||||
|
||||
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
|
||||
const sdkFor = (directory: string) => {
|
||||
const cached = sdkCache.get(directory)
|
||||
if (cached) return cached
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
sdkCache.set(directory, sdk)
|
||||
return sdk
|
||||
}
|
||||
|
||||
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
|
||||
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
||||
createStore({ value: [] as Project[] }),
|
||||
@@ -183,7 +208,7 @@ function createGlobalSync() {
|
||||
setProjectCache("value", projects.map(sanitizeProject))
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
createEffect(() => {
|
||||
if (globalStore.reload !== "complete") return
|
||||
if (bootstrapQueue.length) {
|
||||
for (const directory of bootstrapQueue) {
|
||||
@@ -203,14 +228,16 @@ function createGlobalSync() {
|
||||
function ensureChild(directory: string) {
|
||||
if (!directory) console.error("No directory provided")
|
||||
if (!children[directory]) {
|
||||
const cache = runWithOwner(owner, () =>
|
||||
const vcs = runWithOwner(owner, () =>
|
||||
persisted(
|
||||
Persist.workspace(directory, "vcs", ["vcs.v1"]),
|
||||
createStore({ value: undefined as VcsInfo | undefined }),
|
||||
),
|
||||
)
|
||||
if (!cache) throw new Error("Failed to create persisted cache")
|
||||
vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
|
||||
if (!vcs) throw new Error("Failed to create persisted cache")
|
||||
const vcsStore = vcs[0]
|
||||
const vcsReady = vcs[3]
|
||||
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
|
||||
|
||||
const meta = runWithOwner(owner, () =>
|
||||
persisted(
|
||||
@@ -250,7 +277,7 @@ function createGlobalSync() {
|
||||
question: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: cache[0].value,
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
@@ -258,6 +285,13 @@ function createGlobalSync() {
|
||||
|
||||
children[directory] = child
|
||||
|
||||
createEffect(() => {
|
||||
if (!vcsReady()) return
|
||||
const cached = vcsStore.value
|
||||
if (!cached?.branch) return
|
||||
child[1]("vcs", (value) => value ?? cached)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
child[1]("projectMeta", meta[0].value)
|
||||
})
|
||||
@@ -297,7 +331,6 @@ function createGlobalSync() {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
// Read the current limit at resolve-time so callers that bump the limit while
|
||||
@@ -348,38 +381,18 @@ function createGlobalSync() {
|
||||
if (!cache) return
|
||||
const meta = metaCache.get(directory)
|
||||
if (!meta) return
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
const sdk = sdkFor(directory)
|
||||
|
||||
setStore("status", "loading")
|
||||
|
||||
createEffect(() => {
|
||||
if (!cache.ready()) return
|
||||
const cached = cache.store.value
|
||||
if (!cached?.branch) return
|
||||
setStore("vcs", (value) => value ?? cached)
|
||||
})
|
||||
|
||||
// projectMeta is synced from persisted storage in ensureChild.
|
||||
// vcs is seeded from persisted storage in ensureChild.
|
||||
|
||||
const blockingRequests = {
|
||||
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
||||
provider: () =>
|
||||
sdk.provider.list().then((x) => {
|
||||
const data = x.data!
|
||||
setStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
})
|
||||
setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
@@ -432,10 +445,7 @@ function createGlobalSync() {
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
@@ -464,10 +474,7 @@ function createGlobalSync() {
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions
|
||||
.filter((q) => !!q?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
@@ -750,13 +757,9 @@ function createGlobalSync() {
|
||||
break
|
||||
}
|
||||
case "lsp.updated": {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
|
||||
sdkFor(directory)
|
||||
.lsp.status()
|
||||
.then((x) => setStore("lsp", x.data ?? []))
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -796,16 +799,7 @@ function createGlobalSync() {
|
||||
),
|
||||
retry(() =>
|
||||
globalSDK.client.provider.list().then((x) => {
|
||||
const data = x.data!
|
||||
setGlobalStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
})
|
||||
setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
@@ -209,6 +209,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
})
|
||||
|
||||
const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
|
||||
const colorRequested = new Map<string, AvatarColorKey>()
|
||||
|
||||
function pickAvailableColor(used: Set<string>): AvatarColorKey {
|
||||
const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
|
||||
@@ -267,17 +268,36 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
return map
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const rootFor = (directory: string) => {
|
||||
const map = roots()
|
||||
if (map.size === 0) return
|
||||
if (map.size === 0) return directory
|
||||
|
||||
const visited = new Set<string>()
|
||||
const chain = [directory]
|
||||
|
||||
while (chain.length) {
|
||||
const current = chain[chain.length - 1]
|
||||
if (!current) return directory
|
||||
|
||||
const next = map.get(current)
|
||||
if (!next) return current
|
||||
|
||||
if (visited.has(next)) return directory
|
||||
visited.add(next)
|
||||
chain.push(next)
|
||||
}
|
||||
|
||||
return directory
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const projects = server.projects.list()
|
||||
const seen = new Set(projects.map((project) => project.worktree))
|
||||
|
||||
batch(() => {
|
||||
for (const project of projects) {
|
||||
const root = map.get(project.worktree)
|
||||
if (!root) continue
|
||||
const root = rootFor(project.worktree)
|
||||
if (root === project.worktree) continue
|
||||
|
||||
server.projects.close(project.worktree)
|
||||
|
||||
@@ -305,13 +325,21 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
createEffect(() => {
|
||||
const projects = enriched()
|
||||
if (projects.length === 0) return
|
||||
if (!globalSync.ready) return
|
||||
|
||||
if (globalSync.ready) {
|
||||
for (const project of projects) {
|
||||
if (!project.id) continue
|
||||
if (project.id === "global") continue
|
||||
globalSync.project.icon(project.worktree, project.icon?.override)
|
||||
}
|
||||
for (const project of projects) {
|
||||
if (!project.id) continue
|
||||
if (project.id === "global") continue
|
||||
globalSync.project.icon(project.worktree, project.icon?.override)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const projects = enriched()
|
||||
if (projects.length === 0) return
|
||||
|
||||
for (const project of projects) {
|
||||
if (project.icon?.color) colorRequested.delete(project.worktree)
|
||||
}
|
||||
|
||||
const used = new Set<string>()
|
||||
@@ -322,18 +350,29 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
for (const project of projects) {
|
||||
if (project.icon?.color) continue
|
||||
const existing = colors[project.worktree]
|
||||
const worktree = project.worktree
|
||||
const existing = colors[worktree]
|
||||
const color = existing ?? pickAvailableColor(used)
|
||||
if (!existing) {
|
||||
used.add(color)
|
||||
setColors(project.worktree, color)
|
||||
setColors(worktree, color)
|
||||
}
|
||||
if (!project.id) continue
|
||||
|
||||
const requested = colorRequested.get(worktree)
|
||||
if (requested === color) continue
|
||||
colorRequested.set(worktree, color)
|
||||
|
||||
if (project.id === "global") {
|
||||
globalSync.project.meta(project.worktree, { icon: { color } })
|
||||
globalSync.project.meta(worktree, { icon: { color } })
|
||||
continue
|
||||
}
|
||||
void globalSdk.client.project.update({ projectID: project.id, directory: project.worktree, icon: { color } })
|
||||
|
||||
void globalSdk.client.project
|
||||
.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||
.catch(() => {
|
||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -350,7 +389,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
projects: {
|
||||
list,
|
||||
open(directory: string) {
|
||||
const root = roots().get(directory) ?? directory
|
||||
const root = rootFor(directory)
|
||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||
globalSync.project.loadSessions(root)
|
||||
server.projects.open(root)
|
||||
@@ -384,7 +423,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setStore("sidebar", "width", width)
|
||||
},
|
||||
workspaces(directory: string) {
|
||||
return createMemo(() => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false)
|
||||
return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
|
||||
},
|
||||
setWorkspaces(directory: string, value: boolean) {
|
||||
setStore("sidebar", "workspaces", directory, value)
|
||||
@@ -432,10 +471,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setStore("mobileSidebar", "opened", (x) => !x)
|
||||
},
|
||||
},
|
||||
view(sessionKey: string) {
|
||||
touch(sessionKey)
|
||||
scroll.seed(sessionKey)
|
||||
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
||||
view(sessionKey: string | Accessor<string>) {
|
||||
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
||||
|
||||
touch(key())
|
||||
scroll.seed(key())
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
key,
|
||||
(value) => {
|
||||
touch(value)
|
||||
scroll.seed(value)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
|
||||
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
|
||||
|
||||
@@ -465,10 +518,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
return {
|
||||
scroll(tab: string) {
|
||||
return scroll.scroll(sessionKey, tab)
|
||||
return scroll.scroll(key(), tab)
|
||||
},
|
||||
setScroll(tab: string, pos: SessionScroll) {
|
||||
scroll.setScroll(sessionKey, tab, pos)
|
||||
scroll.setScroll(key(), tab, pos)
|
||||
},
|
||||
terminal: {
|
||||
opened: terminalOpened,
|
||||
@@ -497,9 +550,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
review: {
|
||||
open: createMemo(() => s().reviewOpen),
|
||||
setOpen(open: string[]) {
|
||||
const current = store.sessionView[sessionKey]
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
reviewOpen: open,
|
||||
})
|
||||
@@ -507,93 +561,111 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
if (same(current.reviewOpen, open)) return
|
||||
setStore("sessionView", sessionKey, "reviewOpen", open)
|
||||
setStore("sessionView", session, "reviewOpen", open)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
tabs(sessionKey: string) {
|
||||
touch(sessionKey)
|
||||
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
|
||||
tabs(sessionKey: string | Accessor<string>) {
|
||||
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
||||
|
||||
touch(key())
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
key,
|
||||
(value) => {
|
||||
touch(value)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||
return {
|
||||
tabs,
|
||||
active: createMemo(() => tabs().active),
|
||||
all: createMemo(() => tabs().all),
|
||||
setActive(tab: string | undefined) {
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all: [], active: tab })
|
||||
const session = key()
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: [], active: tab })
|
||||
} else {
|
||||
setStore("sessionTabs", sessionKey, "active", tab)
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
}
|
||||
},
|
||||
setAll(all: string[]) {
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all, active: undefined })
|
||||
const session = key()
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all, active: undefined })
|
||||
} else {
|
||||
setStore("sessionTabs", sessionKey, "all", all)
|
||||
setStore("sessionTabs", session, "all", all)
|
||||
}
|
||||
},
|
||||
async open(tab: string) {
|
||||
const current = store.sessionTabs[sessionKey] ?? { all: [] }
|
||||
const session = key()
|
||||
const current = store.sessionTabs[session] ?? { all: [] }
|
||||
|
||||
if (tab === "review") {
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all: [], active: tab })
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: [], active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", sessionKey, "active", tab)
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
return
|
||||
}
|
||||
|
||||
if (tab === "context") {
|
||||
const all = [tab, ...current.all.filter((x) => x !== tab)]
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all, active: tab })
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all, active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", sessionKey, "all", all)
|
||||
setStore("sessionTabs", sessionKey, "active", tab)
|
||||
setStore("sessionTabs", session, "all", all)
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
return
|
||||
}
|
||||
|
||||
if (!current.all.includes(tab)) {
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: [tab], active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
|
||||
setStore("sessionTabs", sessionKey, "active", tab)
|
||||
setStore("sessionTabs", session, "all", [...current.all, tab])
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
return
|
||||
}
|
||||
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all: current.all, active: tab })
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: current.all, active: tab })
|
||||
return
|
||||
}
|
||||
setStore("sessionTabs", sessionKey, "active", tab)
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
},
|
||||
close(tab: string) {
|
||||
const current = store.sessionTabs[sessionKey]
|
||||
const session = key()
|
||||
const current = store.sessionTabs[session]
|
||||
if (!current) return
|
||||
|
||||
const all = current.all.filter((x) => x !== tab)
|
||||
batch(() => {
|
||||
setStore("sessionTabs", sessionKey, "all", all)
|
||||
setStore("sessionTabs", session, "all", all)
|
||||
if (current.active !== tab) return
|
||||
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
const next = all[index - 1] ?? all[0]
|
||||
setStore("sessionTabs", sessionKey, "active", next)
|
||||
setStore("sessionTabs", session, "active", next)
|
||||
})
|
||||
},
|
||||
move(tab: string, to: number) {
|
||||
const current = store.sessionTabs[sessionKey]
|
||||
const session = key()
|
||||
const current = store.sessionTabs[session]
|
||||
if (!current) return
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"sessionTabs",
|
||||
sessionKey,
|
||||
session,
|
||||
"all",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { batch, createMemo, onCleanup } from "solid-js"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
@@ -126,7 +126,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
)
|
||||
|
||||
const [ephemeral, setEphemeral] = createStore<{
|
||||
model: Record<string, ModelKey>
|
||||
model: Record<string, ModelKey | undefined>
|
||||
}>({
|
||||
model: {},
|
||||
})
|
||||
@@ -182,7 +182,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const fallbackModel = createMemo(() => {
|
||||
const fallbackModel = createMemo<ModelKey | undefined>(() => {
|
||||
if (sync.data.config.model) {
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
@@ -199,16 +199,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
const defaults = providers.default()
|
||||
for (const p of providers.connected()) {
|
||||
if (p.id in providers.default()) {
|
||||
return {
|
||||
providerID: p.id,
|
||||
modelID: providers.default()[p.id],
|
||||
}
|
||||
const configured = defaults[p.id]
|
||||
if (configured) {
|
||||
const key = { providerID: p.id, modelID: configured }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
const first = Object.values(p.models)[0]
|
||||
if (!first) continue
|
||||
const key = { providerID: p.id, modelID: first.id }
|
||||
if (isModelValid(key)) return key
|
||||
}
|
||||
|
||||
throw new Error("No default model found")
|
||||
return undefined
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
@@ -266,7 +271,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
const currentAgent = agent.current()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, model ?? fallbackModel())
|
||||
const next = model ?? fallbackModel()
|
||||
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
||||
if (model) updateVisibility(model, "show")
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
@@ -338,6 +344,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||
})
|
||||
|
||||
const scope = createMemo(() => sdk.directory)
|
||||
createEffect(() => {
|
||||
scope()
|
||||
setStore("node", {})
|
||||
})
|
||||
|
||||
// const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
// const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||
|
||||
@@ -394,10 +406,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
|
||||
|
||||
const load = async (path: string) => {
|
||||
const directory = scope()
|
||||
const client = sdk.client
|
||||
const relativePath = relative(path)
|
||||
await sdk.client.file
|
||||
await client.file
|
||||
.read({ path: relativePath })
|
||||
.then((x) => {
|
||||
if (scope() !== directory) return
|
||||
if (!store.node[relativePath]) return
|
||||
setStore(
|
||||
"node",
|
||||
@@ -409,6 +424,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (scope() !== directory) return
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
@@ -453,9 +469,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const list = async (path: string) => {
|
||||
return sdk.client.file
|
||||
const directory = scope()
|
||||
const client = sdk.client
|
||||
return client.file
|
||||
.list({ path: path + "/" })
|
||||
.then((x) => {
|
||||
if (scope() !== directory) return
|
||||
setStore(
|
||||
"node",
|
||||
produce((draft) => {
|
||||
|
||||
@@ -44,6 +44,7 @@ export type FileContextItem = {
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
@@ -10,22 +10,39 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
init: (props: { directory: string }) => {
|
||||
const platform = usePlatform()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory: props.directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
const directory = createMemo(() => props.directory)
|
||||
const client = createMemo(() =>
|
||||
createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory: directory(),
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}>()
|
||||
|
||||
const unsub = globalSDK.event.on(props.directory, (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
createEffect(() => {
|
||||
const unsub = globalSDK.event.on(directory(), (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
|
||||
return {
|
||||
get directory() {
|
||||
return directory()
|
||||
},
|
||||
get client() {
|
||||
return client()
|
||||
},
|
||||
event: emitter,
|
||||
get url() {
|
||||
return globalSDK.url
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -65,6 +65,7 @@ const monoFonts: Record<string, string> = {
|
||||
hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
iosevka: `"Iosevka Nerd Font", "Iosevka Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
|
||||
@@ -7,13 +7,20 @@ import { useGlobalSync } from "./global-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
init: () => {
|
||||
const globalSync = useGlobalSync()
|
||||
const sdk = useSDK()
|
||||
const [store, setStore] = globalSync.child(sdk.directory)
|
||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||
|
||||
type Child = ReturnType<(typeof globalSync)["child"]>
|
||||
type Store = Child[0]
|
||||
type Setter = Child[1]
|
||||
|
||||
const current = createMemo(() => globalSync.child(sdk.directory))
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const chunk = 400
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
@@ -25,6 +32,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const getSession = (sessionID: string) => {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
@@ -35,66 +43,75 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return Math.ceil(count / chunk) * chunk
|
||||
}
|
||||
|
||||
const hydrateMessages = (sessionID: string) => {
|
||||
if (meta.limit[sessionID] !== undefined) return
|
||||
const hydrateMessages = (directory: string, store: Store, sessionID: string) => {
|
||||
const key = keyFor(directory, sessionID)
|
||||
if (meta.limit[key] !== undefined) return
|
||||
|
||||
const messages = store.message[sessionID]
|
||||
if (!messages) return
|
||||
|
||||
const limit = limitFor(messages.length)
|
||||
setMeta("limit", sessionID, limit)
|
||||
setMeta("complete", sessionID, messages.length < limit)
|
||||
setMeta("limit", key, limit)
|
||||
setMeta("complete", key, messages.length < limit)
|
||||
}
|
||||
|
||||
const loadMessages = async (sessionID: string, limit: number) => {
|
||||
if (meta.loading[sessionID]) return
|
||||
const loadMessages = async (input: {
|
||||
directory: string
|
||||
client: typeof sdk.client
|
||||
setStore: Setter
|
||||
sessionID: string
|
||||
limit: number
|
||||
}) => {
|
||||
const key = keyFor(input.directory, input.sessionID)
|
||||
if (meta.loading[key]) return
|
||||
|
||||
setMeta("loading", sessionID, true)
|
||||
await retry(() => sdk.client.session.messages({ sessionID, limit }))
|
||||
setMeta("loading", key, true)
|
||||
await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }))
|
||||
.then((messages) => {
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const next = items
|
||||
.map((x) => x.info)
|
||||
.filter((m) => !!m?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
batch(() => {
|
||||
setStore("message", sessionID, reconcile(next, { key: "id" }))
|
||||
input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
|
||||
|
||||
for (const message of items) {
|
||||
setStore(
|
||||
input.setStore(
|
||||
"part",
|
||||
message.info.id,
|
||||
reconcile(
|
||||
message.parts
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
setMeta("limit", sessionID, limit)
|
||||
setMeta("complete", sessionID, next.length < limit)
|
||||
setMeta("limit", key, input.limit)
|
||||
setMeta("complete", key, next.length < input.limit)
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setMeta("loading", sessionID, false)
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
data: store,
|
||||
set: setStore,
|
||||
get data() {
|
||||
return current()[0]
|
||||
},
|
||||
get set(): Setter {
|
||||
return current()[1]
|
||||
},
|
||||
get status() {
|
||||
return store.status
|
||||
return current()[0].status
|
||||
},
|
||||
get ready() {
|
||||
return store.status !== "loading"
|
||||
return current()[0].status !== "loading"
|
||||
},
|
||||
get project() {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
|
||||
if (match.found) return globalSync.data.project[match.index]
|
||||
return undefined
|
||||
@@ -116,7 +133,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
}
|
||||
setStore(
|
||||
current()[1](
|
||||
produce((draft) => {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (!messages) {
|
||||
@@ -125,28 +142,33 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, message)
|
||||
}
|
||||
draft.part[input.messageID] = input.parts
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id))
|
||||
}),
|
||||
)
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
const hasSession = getSession(sessionID) !== undefined
|
||||
hydrateMessages(sessionID)
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const hasSession = (() => {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
return match.found
|
||||
})()
|
||||
|
||||
hydrateMessages(directory, store, sessionID)
|
||||
|
||||
const hasMessages = store.message[sessionID] !== undefined
|
||||
if (hasSession && hasMessages) return
|
||||
|
||||
const pending = inflight.get(sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const pending = inflight.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
const limit = meta.limit[sessionID] ?? chunk
|
||||
const limit = meta.limit[key] ?? chunk
|
||||
|
||||
const sessionReq = hasSession
|
||||
? Promise.resolve()
|
||||
: retry(() => sdk.client.session.get({ sessionID })).then((session) => {
|
||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
@@ -162,83 +184,117 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
)
|
||||
})
|
||||
|
||||
const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
|
||||
const messagesReq = hasMessages
|
||||
? Promise.resolve()
|
||||
: loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
|
||||
const promise = Promise.all([sessionReq, messagesReq])
|
||||
.then(() => {})
|
||||
.finally(() => {
|
||||
inflight.delete(sessionID)
|
||||
inflight.delete(key)
|
||||
})
|
||||
|
||||
inflight.set(sessionID, promise)
|
||||
inflight.set(key, promise)
|
||||
return promise
|
||||
},
|
||||
async diff(sessionID: string) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
if (store.session_diff[sessionID] !== undefined) return
|
||||
|
||||
const pending = inflightDiff.get(sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const pending = inflightDiff.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
const promise = retry(() => sdk.client.session.diff({ sessionID }))
|
||||
const promise = retry(() => client.session.diff({ sessionID }))
|
||||
.then((diff) => {
|
||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||
})
|
||||
.finally(() => {
|
||||
inflightDiff.delete(sessionID)
|
||||
inflightDiff.delete(key)
|
||||
})
|
||||
|
||||
inflightDiff.set(sessionID, promise)
|
||||
inflightDiff.set(key, promise)
|
||||
return promise
|
||||
},
|
||||
async todo(sessionID: string) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
if (store.todo[sessionID] !== undefined) return
|
||||
|
||||
const pending = inflightTodo.get(sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const pending = inflightTodo.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
const promise = retry(() => sdk.client.session.todo({ sessionID }))
|
||||
const promise = retry(() => client.session.todo({ sessionID }))
|
||||
.then((todo) => {
|
||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||
})
|
||||
.finally(() => {
|
||||
inflightTodo.delete(sessionID)
|
||||
inflightTodo.delete(key)
|
||||
})
|
||||
|
||||
inflightTodo.set(sessionID, promise)
|
||||
inflightTodo.set(key, promise)
|
||||
return promise
|
||||
},
|
||||
history: {
|
||||
more(sessionID: string) {
|
||||
const store = current()[0]
|
||||
const key = keyFor(sdk.directory, sessionID)
|
||||
if (store.message[sessionID] === undefined) return false
|
||||
if (meta.limit[sessionID] === undefined) return false
|
||||
if (meta.complete[sessionID]) return false
|
||||
if (meta.limit[key] === undefined) return false
|
||||
if (meta.complete[key]) return false
|
||||
return true
|
||||
},
|
||||
loading(sessionID: string) {
|
||||
return meta.loading[sessionID] ?? false
|
||||
const key = keyFor(sdk.directory, sessionID)
|
||||
return meta.loading[key] ?? false
|
||||
},
|
||||
async loadMore(sessionID: string, count = chunk) {
|
||||
if (meta.loading[sessionID]) return
|
||||
if (meta.complete[sessionID]) return
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
|
||||
const current = meta.limit[sessionID] ?? chunk
|
||||
await loadMessages(sessionID, current + count)
|
||||
const currentLimit = meta.limit[key] ?? chunk
|
||||
await loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit: currentLimit + count,
|
||||
})
|
||||
},
|
||||
},
|
||||
fetch: async (count = 10) => {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
setStore("limit", (x) => x + count)
|
||||
await sdk.client.session.list().then((x) => {
|
||||
await client.session.list().then((x) => {
|
||||
const sessions = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.slice(0, store.limit)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
})
|
||||
},
|
||||
more: createMemo(() => store.session.length >= store.limit),
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
await sdk.client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
await client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
@@ -249,7 +305,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
},
|
||||
absolute,
|
||||
get directory() {
|
||||
return store.path.directory
|
||||
return current()[0].path.directory
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ export type LocalPTY = {
|
||||
cols?: number
|
||||
buffer?: string
|
||||
scrollY?: number
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
@@ -151,13 +150,18 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess
|
||||
return undefined
|
||||
})
|
||||
if (!clone?.data) return
|
||||
setStore("all", index, {
|
||||
...pty,
|
||||
...clone.data,
|
||||
|
||||
const active = store.active === pty.id
|
||||
|
||||
batch(() => {
|
||||
setStore("all", index, {
|
||||
...pty,
|
||||
...clone.data,
|
||||
})
|
||||
if (active) {
|
||||
setStore("active", clone.data.id)
|
||||
}
|
||||
})
|
||||
if (store.active === pty.id) {
|
||||
setStore("active", clone.data.id)
|
||||
}
|
||||
},
|
||||
open(id: string) {
|
||||
setStore("active", id)
|
||||
|
||||
@@ -88,6 +88,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "آخر",
|
||||
"dialog.provider.tag.recommended": "موصى به",
|
||||
"dialog.provider.anthropic.note": "اتصل باستخدام Claude Pro/Max أو مفتاح API",
|
||||
"dialog.provider.openai.note": "اتصل باستخدام ChatGPT Pro/Plus أو مفتاح API",
|
||||
"dialog.provider.copilot.note": "اتصل باستخدام Copilot أو مفتاح API",
|
||||
|
||||
"dialog.model.select.title": "تحديد نموذج",
|
||||
"dialog.model.search.placeholder": "البحث عن نماذج",
|
||||
@@ -221,6 +223,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "{{enabled}} من {{total}} مفعل",
|
||||
"dialog.mcp.empty": "لم يتم تكوين MCPs",
|
||||
|
||||
"dialog.lsp.empty": "تم الكشف تلقائيًا عن LSPs من أنواع الملفات",
|
||||
"dialog.plugins.empty": "الإضافات المكونة في opencode.json",
|
||||
|
||||
"mcp.status.connected": "متصل",
|
||||
"mcp.status.failed": "فشل",
|
||||
"mcp.status.needs_auth": "يحتاج إلى مصادقة",
|
||||
@@ -240,7 +245,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "تعذر الاتصال بالخادم",
|
||||
"dialog.server.add.checking": "جارٍ التحقق...",
|
||||
"dialog.server.add.button": "إضافة",
|
||||
"dialog.server.add.button": "إضافة خادم",
|
||||
"dialog.server.default.title": "الخادم الافتراضي",
|
||||
"dialog.server.default.description":
|
||||
"الاتصال بهذا الخادم عند بدء تشغيل التطبيق بدلاً من بدء خادم محلي. يتطلب إعادة التشغيل.",
|
||||
@@ -249,6 +254,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "مسح",
|
||||
"dialog.server.action.remove": "إزالة الخادم",
|
||||
|
||||
"dialog.server.menu.edit": "تعديل",
|
||||
"dialog.server.menu.default": "تعيين كافتراضي",
|
||||
"dialog.server.menu.defaultRemove": "إزالة الافتراضي",
|
||||
"dialog.server.menu.delete": "حذف",
|
||||
"dialog.server.current": "الخادم الحالي",
|
||||
"dialog.server.status.default": "افتراضي",
|
||||
|
||||
"dialog.project.edit.title": "تحرير المشروع",
|
||||
"dialog.project.edit.name": "الاسم",
|
||||
"dialog.project.edit.icon": "أيقونة",
|
||||
|
||||
@@ -88,6 +88,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Outro",
|
||||
"dialog.provider.tag.recommended": "Recomendado",
|
||||
"dialog.provider.anthropic.note": "Conectar com Claude Pro/Max ou chave de API",
|
||||
"dialog.provider.openai.note": "Conectar com ChatGPT Pro/Plus ou chave de API",
|
||||
"dialog.provider.copilot.note": "Conectar com Copilot ou chave de API",
|
||||
|
||||
"dialog.model.select.title": "Selecionar modelo",
|
||||
"dialog.model.search.placeholder": "Buscar modelos",
|
||||
|
||||
@@ -86,6 +86,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Andre",
|
||||
"dialog.provider.tag.recommended": "Anbefalet",
|
||||
"dialog.provider.anthropic.note": "Forbind med Claude Pro/Max eller API-nøgle",
|
||||
"dialog.provider.openai.note": "Forbind med ChatGPT Pro/Plus eller API-nøgle",
|
||||
"dialog.provider.copilot.note": "Forbind med Copilot eller API-nøgle",
|
||||
|
||||
"dialog.model.select.title": "Vælg model",
|
||||
"dialog.model.search.placeholder": "Søg modeller",
|
||||
@@ -203,6 +205,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "{{enabled}} af {{total}} aktiveret",
|
||||
"dialog.mcp.empty": "Ingen MCP'er konfigureret",
|
||||
|
||||
"dialog.lsp.empty": "LSP'er registreret automatisk fra filtyper",
|
||||
"dialog.plugins.empty": "Plugins konfigureret i opencode.json",
|
||||
|
||||
"mcp.status.connected": "forbundet",
|
||||
"mcp.status.failed": "mislykkedes",
|
||||
"mcp.status.needs_auth": "kræver godkendelse",
|
||||
@@ -222,7 +227,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Kunne ikke forbinde til server",
|
||||
"dialog.server.add.checking": "Tjekker...",
|
||||
"dialog.server.add.button": "Tilføj",
|
||||
"dialog.server.add.button": "Tilføj server",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
"dialog.server.default.description":
|
||||
"Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.",
|
||||
@@ -231,6 +236,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "Ryd",
|
||||
"dialog.server.action.remove": "Fjern server",
|
||||
|
||||
"dialog.server.menu.edit": "Rediger",
|
||||
"dialog.server.menu.default": "Sæt som standard",
|
||||
"dialog.server.menu.defaultRemove": "Fjern som standard",
|
||||
"dialog.server.menu.delete": "Slet",
|
||||
"dialog.server.current": "Nuværende server",
|
||||
"dialog.server.status.default": "Standard",
|
||||
|
||||
"dialog.project.edit.title": "Rediger projekt",
|
||||
"dialog.project.edit.name": "Navn",
|
||||
"dialog.project.edit.icon": "Ikon",
|
||||
|
||||
@@ -90,6 +90,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Andere",
|
||||
"dialog.provider.tag.recommended": "Empfohlen",
|
||||
"dialog.provider.anthropic.note": "Mit Claude Pro/Max oder API-Schlüssel verbinden",
|
||||
"dialog.provider.openai.note": "Mit ChatGPT Pro/Plus oder API-Schlüssel verbinden",
|
||||
"dialog.provider.copilot.note": "Mit Copilot oder API-Schlüssel verbinden",
|
||||
|
||||
"dialog.model.select.title": "Modell auswählen",
|
||||
"dialog.model.search.placeholder": "Modelle durchsuchen",
|
||||
@@ -208,6 +210,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "{{enabled}} von {{total}} aktiviert",
|
||||
"dialog.mcp.empty": "Keine MCPs konfiguriert",
|
||||
|
||||
"dialog.lsp.empty": "LSPs automatisch nach Dateityp erkannt",
|
||||
"dialog.plugins.empty": "In opencode.json konfigurierte Plugins",
|
||||
|
||||
"mcp.status.connected": "verbunden",
|
||||
"mcp.status.failed": "fehlgeschlagen",
|
||||
"mcp.status.needs_auth": "benötigt Authentifizierung",
|
||||
@@ -227,7 +232,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Verbindung zum Server fehlgeschlagen",
|
||||
"dialog.server.add.checking": "Prüfen...",
|
||||
"dialog.server.add.button": "Hinzufügen",
|
||||
"dialog.server.add.button": "Server hinzufügen",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
"dialog.server.default.description":
|
||||
"Beim App-Start mit diesem Server verbinden, anstatt einen lokalen Server zu starten. Erfordert Neustart.",
|
||||
@@ -236,6 +241,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "Löschen",
|
||||
"dialog.server.action.remove": "Server entfernen",
|
||||
|
||||
"dialog.server.menu.edit": "Bearbeiten",
|
||||
"dialog.server.menu.default": "Als Standard festlegen",
|
||||
"dialog.server.menu.defaultRemove": "Standard entfernen",
|
||||
"dialog.server.menu.delete": "Löschen",
|
||||
"dialog.server.current": "Aktueller Server",
|
||||
"dialog.server.status.default": "Standard",
|
||||
|
||||
"dialog.project.edit.title": "Projekt bearbeiten",
|
||||
"dialog.project.edit.name": "Name",
|
||||
"dialog.project.edit.icon": "Icon",
|
||||
|
||||
@@ -88,6 +88,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Other",
|
||||
"dialog.provider.tag.recommended": "Recommended",
|
||||
"dialog.provider.anthropic.note": "Connect with Claude Pro/Max or API key",
|
||||
"dialog.provider.openai.note": "Connect with ChatGPT Pro/Plus or API key",
|
||||
"dialog.provider.copilot.note": "Connect with Copilot or API key",
|
||||
|
||||
"dialog.model.select.title": "Select model",
|
||||
"dialog.model.search.placeholder": "Search models",
|
||||
@@ -221,6 +223,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
|
||||
"dialog.mcp.empty": "No MCPs configured",
|
||||
|
||||
"dialog.lsp.empty": "LSPs auto-detected from file types",
|
||||
"dialog.plugins.empty": "Plugins configured in opencode.json",
|
||||
|
||||
"mcp.status.connected": "connected",
|
||||
"mcp.status.failed": "failed",
|
||||
"mcp.status.needs_auth": "needs auth",
|
||||
@@ -240,7 +245,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Could not connect to server",
|
||||
"dialog.server.add.checking": "Checking...",
|
||||
"dialog.server.add.button": "Add",
|
||||
"dialog.server.add.button": "Add server",
|
||||
"dialog.server.default.title": "Default server",
|
||||
"dialog.server.default.description":
|
||||
"Connect to this server on app launch instead of starting a local server. Requires restart.",
|
||||
@@ -249,6 +254,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "Clear",
|
||||
"dialog.server.action.remove": "Remove server",
|
||||
|
||||
"dialog.server.menu.edit": "Edit",
|
||||
"dialog.server.menu.default": "Set as default",
|
||||
"dialog.server.menu.defaultRemove": "Remove default",
|
||||
"dialog.server.menu.delete": "Delete",
|
||||
"dialog.server.current": "Current Server",
|
||||
"dialog.server.status.default": "Default",
|
||||
|
||||
"dialog.project.edit.title": "Edit project",
|
||||
"dialog.project.edit.name": "Name",
|
||||
"dialog.project.edit.icon": "Icon",
|
||||
@@ -492,6 +504,7 @@ export const dict = {
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
|
||||
@@ -86,6 +86,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Otro",
|
||||
"dialog.provider.tag.recommended": "Recomendado",
|
||||
"dialog.provider.anthropic.note": "Conectar con Claude Pro/Max o clave API",
|
||||
"dialog.provider.openai.note": "Conectar con ChatGPT Pro/Plus o clave API",
|
||||
"dialog.provider.copilot.note": "Conectar con Copilot o clave API",
|
||||
|
||||
"dialog.model.select.title": "Seleccionar modelo",
|
||||
"dialog.model.search.placeholder": "Buscar modelos",
|
||||
@@ -203,6 +205,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "{{enabled}} de {{total}} habilitados",
|
||||
"dialog.mcp.empty": "No hay MCPs configurados",
|
||||
|
||||
"dialog.lsp.empty": "LSPs detectados automáticamente por tipo de archivo",
|
||||
"dialog.plugins.empty": "Plugins configurados en opencode.json",
|
||||
|
||||
"mcp.status.connected": "conectado",
|
||||
"mcp.status.failed": "fallido",
|
||||
"mcp.status.needs_auth": "necesita auth",
|
||||
@@ -222,7 +227,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "No se pudo conectar al servidor",
|
||||
"dialog.server.add.checking": "Comprobando...",
|
||||
"dialog.server.add.button": "Añadir",
|
||||
"dialog.server.add.button": "Añadir servidor",
|
||||
"dialog.server.default.title": "Servidor predeterminado",
|
||||
"dialog.server.default.description":
|
||||
"Conectar a este servidor al iniciar la app en lugar de iniciar un servidor local. Requiere reinicio.",
|
||||
@@ -231,6 +236,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "Limpiar",
|
||||
"dialog.server.action.remove": "Eliminar servidor",
|
||||
|
||||
"dialog.server.menu.edit": "Editar",
|
||||
"dialog.server.menu.default": "Establecer como predeterminado",
|
||||
"dialog.server.menu.defaultRemove": "Quitar predeterminado",
|
||||
"dialog.server.menu.delete": "Eliminar",
|
||||
"dialog.server.current": "Servidor actual",
|
||||
"dialog.server.status.default": "Predeterminado",
|
||||
|
||||
"dialog.project.edit.title": "Editar proyecto",
|
||||
"dialog.project.edit.name": "Nombre",
|
||||
"dialog.project.edit.icon": "Icono",
|
||||
|
||||
@@ -86,6 +86,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Autre",
|
||||
"dialog.provider.tag.recommended": "Recommandé",
|
||||
"dialog.provider.anthropic.note": "Connectez-vous avec Claude Pro/Max ou une clé API",
|
||||
"dialog.provider.openai.note": "Connectez-vous avec ChatGPT Pro/Plus ou une clé API",
|
||||
"dialog.provider.copilot.note": "Connectez-vous avec Copilot ou une clé API",
|
||||
|
||||
"dialog.model.select.title": "Sélectionner un modèle",
|
||||
"dialog.model.search.placeholder": "Rechercher des modèles",
|
||||
@@ -203,6 +205,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "{{enabled}} sur {{total}} activés",
|
||||
"dialog.mcp.empty": "Aucun MCP configuré",
|
||||
|
||||
"dialog.lsp.empty": "LSPs détectés automatiquement par type de fichier",
|
||||
"dialog.plugins.empty": "Plugins configurés dans opencode.json",
|
||||
|
||||
"mcp.status.connected": "connecté",
|
||||
"mcp.status.failed": "échoué",
|
||||
"mcp.status.needs_auth": "nécessite auth",
|
||||
@@ -222,7 +227,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Impossible de se connecter au serveur",
|
||||
"dialog.server.add.checking": "Vérification...",
|
||||
"dialog.server.add.button": "Ajouter",
|
||||
"dialog.server.add.button": "Ajouter un serveur",
|
||||
"dialog.server.default.title": "Serveur par défaut",
|
||||
"dialog.server.default.description":
|
||||
"Se connecter à ce serveur au lancement de l'application au lieu de démarrer un serveur local. Nécessite un redémarrage.",
|
||||
@@ -231,6 +236,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "Effacer",
|
||||
"dialog.server.action.remove": "Supprimer le serveur",
|
||||
|
||||
"dialog.server.menu.edit": "Modifier",
|
||||
"dialog.server.menu.default": "Définir par défaut",
|
||||
"dialog.server.menu.defaultRemove": "Supprimer par défaut",
|
||||
"dialog.server.menu.delete": "Supprimer",
|
||||
"dialog.server.current": "Serveur actuel",
|
||||
"dialog.server.status.default": "Défaut",
|
||||
|
||||
"dialog.project.edit.title": "Modifier le projet",
|
||||
"dialog.project.edit.name": "Nom",
|
||||
"dialog.project.edit.icon": "Icône",
|
||||
|
||||
@@ -86,6 +86,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "その他",
|
||||
"dialog.provider.tag.recommended": "推奨",
|
||||
"dialog.provider.anthropic.note": "Claude Pro/MaxまたはAPIキーで接続",
|
||||
"dialog.provider.openai.note": "ChatGPT Pro/PlusまたはAPIキーで接続",
|
||||
"dialog.provider.copilot.note": "CopilotまたはAPIキーで接続",
|
||||
|
||||
"dialog.model.select.title": "モデルを選択",
|
||||
"dialog.model.search.placeholder": "モデルを検索",
|
||||
@@ -202,6 +204,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "{{total}}個中{{enabled}}個が有効",
|
||||
"dialog.mcp.empty": "MCPが設定されていません",
|
||||
|
||||
"dialog.lsp.empty": "ファイルタイプから自動検出されたLSP",
|
||||
"dialog.plugins.empty": "opencode.jsonで設定されたプラグイン",
|
||||
|
||||
"mcp.status.connected": "接続済み",
|
||||
"mcp.status.failed": "失敗",
|
||||
"mcp.status.needs_auth": "認証が必要",
|
||||
@@ -221,7 +226,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "サーバーに接続できませんでした",
|
||||
"dialog.server.add.checking": "確認中...",
|
||||
"dialog.server.add.button": "追加",
|
||||
"dialog.server.add.button": "サーバーを追加",
|
||||
"dialog.server.default.title": "デフォルトサーバー",
|
||||
"dialog.server.default.description":
|
||||
"ローカルサーバーを起動する代わりに、アプリ起動時にこのサーバーに接続します。再起動が必要です。",
|
||||
@@ -230,6 +235,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "クリア",
|
||||
"dialog.server.action.remove": "サーバーを削除",
|
||||
|
||||
"dialog.server.menu.edit": "編集",
|
||||
"dialog.server.menu.default": "デフォルトに設定",
|
||||
"dialog.server.menu.defaultRemove": "デフォルト設定を解除",
|
||||
"dialog.server.menu.delete": "削除",
|
||||
"dialog.server.current": "現在のサーバー",
|
||||
"dialog.server.status.default": "デフォルト",
|
||||
|
||||
"dialog.project.edit.title": "プロジェクトを編集",
|
||||
"dialog.project.edit.name": "名前",
|
||||
"dialog.project.edit.icon": "アイコン",
|
||||
|
||||
@@ -90,6 +90,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "기타",
|
||||
"dialog.provider.tag.recommended": "추천",
|
||||
"dialog.provider.anthropic.note": "Claude Pro/Max 또는 API 키로 연결",
|
||||
"dialog.provider.openai.note": "ChatGPT Pro/Plus 또는 API 키로 연결",
|
||||
"dialog.provider.copilot.note": "Copilot 또는 API 키로 연결",
|
||||
|
||||
"dialog.model.select.title": "모델 선택",
|
||||
"dialog.model.search.placeholder": "모델 검색",
|
||||
@@ -206,6 +208,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "{{total}}개 중 {{enabled}}개 활성화됨",
|
||||
"dialog.mcp.empty": "구성된 MCP 없음",
|
||||
|
||||
"dialog.lsp.empty": "파일 유형에서 자동 감지된 LSP",
|
||||
"dialog.plugins.empty": "opencode.json에 구성된 플러그인",
|
||||
|
||||
"mcp.status.connected": "연결됨",
|
||||
"mcp.status.failed": "실패",
|
||||
"mcp.status.needs_auth": "인증 필요",
|
||||
@@ -225,7 +230,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "서버에 연결할 수 없습니다",
|
||||
"dialog.server.add.checking": "확인 중...",
|
||||
"dialog.server.add.button": "추가",
|
||||
"dialog.server.add.button": "서버 추가",
|
||||
"dialog.server.default.title": "기본 서버",
|
||||
"dialog.server.default.description":
|
||||
"로컬 서버를 시작하는 대신 앱 실행 시 이 서버에 연결합니다. 다시 시작해야 합니다.",
|
||||
@@ -234,6 +239,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "지우기",
|
||||
"dialog.server.action.remove": "서버 제거",
|
||||
|
||||
"dialog.server.menu.edit": "편집",
|
||||
"dialog.server.menu.default": "기본값으로 설정",
|
||||
"dialog.server.menu.defaultRemove": "기본값 제거",
|
||||
"dialog.server.menu.delete": "삭제",
|
||||
"dialog.server.current": "현재 서버",
|
||||
"dialog.server.status.default": "기본값",
|
||||
|
||||
"dialog.project.edit.title": "프로젝트 편집",
|
||||
"dialog.project.edit.name": "이름",
|
||||
"dialog.project.edit.icon": "아이콘",
|
||||
|
||||
@@ -91,6 +91,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Andre",
|
||||
"dialog.provider.tag.recommended": "Anbefalt",
|
||||
"dialog.provider.anthropic.note": "Koble til med Claude Pro/Max eller API-nøkkel",
|
||||
"dialog.provider.openai.note": "Koble til med ChatGPT Pro/Plus eller API-nøkkel",
|
||||
"dialog.provider.copilot.note": "Koble til med Copilot eller API-nøkkel",
|
||||
|
||||
"dialog.model.select.title": "Velg modell",
|
||||
"dialog.model.search.placeholder": "Søk etter modeller",
|
||||
@@ -224,6 +226,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "{{enabled}} av {{total}} aktivert",
|
||||
"dialog.mcp.empty": "Ingen MCP-er konfigurert",
|
||||
|
||||
"dialog.lsp.empty": "LSP-er automatisk oppdaget fra filtyper",
|
||||
"dialog.plugins.empty": "Plugins konfigurert i opencode.json",
|
||||
|
||||
"mcp.status.connected": "tilkoblet",
|
||||
"mcp.status.failed": "mislyktes",
|
||||
"mcp.status.needs_auth": "trenger autentisering",
|
||||
@@ -243,7 +248,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Kunne ikke koble til server",
|
||||
"dialog.server.add.checking": "Sjekker...",
|
||||
"dialog.server.add.button": "Legg til",
|
||||
"dialog.server.add.button": "Legg til server",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
"dialog.server.default.description":
|
||||
"Koble til denne serveren ved oppstart i stedet for å starte en lokal server. Krever omstart.",
|
||||
@@ -252,6 +257,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "Tøm",
|
||||
"dialog.server.action.remove": "Fjern server",
|
||||
|
||||
"dialog.server.menu.edit": "Rediger",
|
||||
"dialog.server.menu.default": "Sett som standard",
|
||||
"dialog.server.menu.defaultRemove": "Fjern standard",
|
||||
"dialog.server.menu.delete": "Slett",
|
||||
"dialog.server.current": "Gjeldende server",
|
||||
"dialog.server.status.default": "Standard",
|
||||
|
||||
"dialog.project.edit.title": "Rediger prosjekt",
|
||||
"dialog.project.edit.name": "Navn",
|
||||
"dialog.project.edit.icon": "Ikon",
|
||||
|
||||
@@ -88,6 +88,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Inne",
|
||||
"dialog.provider.tag.recommended": "Zalecane",
|
||||
"dialog.provider.anthropic.note": "Połącz z Claude Pro/Max lub kluczem API",
|
||||
"dialog.provider.openai.note": "Połącz z ChatGPT Pro/Plus lub kluczem API",
|
||||
"dialog.provider.copilot.note": "Połącz z Copilot lub kluczem API",
|
||||
|
||||
"dialog.model.select.title": "Wybierz model",
|
||||
"dialog.model.search.placeholder": "Szukaj modeli",
|
||||
@@ -221,6 +223,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "{{enabled}} z {{total}} włączone",
|
||||
"dialog.mcp.empty": "Brak skonfigurowanych MCP",
|
||||
|
||||
"dialog.lsp.empty": "LSP wykryte automatycznie na podstawie typów plików",
|
||||
"dialog.plugins.empty": "Wtyczki skonfigurowane w opencode.json",
|
||||
|
||||
"mcp.status.connected": "połączono",
|
||||
"mcp.status.failed": "niepowodzenie",
|
||||
"mcp.status.needs_auth": "wymaga autoryzacji",
|
||||
@@ -240,7 +245,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Nie można połączyć się z serwerem",
|
||||
"dialog.server.add.checking": "Sprawdzanie...",
|
||||
"dialog.server.add.button": "Dodaj",
|
||||
"dialog.server.add.button": "Dodaj serwer",
|
||||
"dialog.server.default.title": "Domyślny serwer",
|
||||
"dialog.server.default.description":
|
||||
"Połącz z tym serwerem przy uruchomieniu aplikacji zamiast uruchamiać lokalny serwer. Wymaga restartu.",
|
||||
@@ -249,6 +254,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "Wyczyść",
|
||||
"dialog.server.action.remove": "Usuń serwer",
|
||||
|
||||
"dialog.server.menu.edit": "Edytuj",
|
||||
"dialog.server.menu.default": "Ustaw jako domyślny",
|
||||
"dialog.server.menu.defaultRemove": "Usuń domyślny",
|
||||
"dialog.server.menu.delete": "Usuń",
|
||||
"dialog.server.current": "Obecny serwer",
|
||||
"dialog.server.status.default": "Domyślny",
|
||||
|
||||
"dialog.project.edit.title": "Edytuj projekt",
|
||||
"dialog.project.edit.name": "Nazwa",
|
||||
"dialog.project.edit.icon": "Ikona",
|
||||
|
||||
@@ -88,6 +88,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Другие",
|
||||
"dialog.provider.tag.recommended": "Рекомендуемые",
|
||||
"dialog.provider.anthropic.note": "Подключитесь с помощью Claude Pro/Max или API ключа",
|
||||
"dialog.provider.openai.note": "Подключитесь с помощью ChatGPT Pro/Plus или API ключа",
|
||||
"dialog.provider.copilot.note": "Подключитесь с помощью Copilot или API ключа",
|
||||
|
||||
"dialog.model.select.title": "Выбрать модель",
|
||||
"dialog.model.search.placeholder": "Поиск моделей",
|
||||
@@ -221,6 +223,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "{{enabled}} из {{total}} включено",
|
||||
"dialog.mcp.empty": "MCP не настроены",
|
||||
|
||||
"dialog.lsp.empty": "LSP автоматически обнаружены по типам файлов",
|
||||
"dialog.plugins.empty": "Плагины настроены в opencode.json",
|
||||
|
||||
"mcp.status.connected": "подключено",
|
||||
"mcp.status.failed": "ошибка",
|
||||
"mcp.status.needs_auth": "требуется авторизация",
|
||||
@@ -240,7 +245,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Не удалось подключиться к серверу",
|
||||
"dialog.server.add.checking": "Проверка...",
|
||||
"dialog.server.add.button": "Добавить",
|
||||
"dialog.server.add.button": "Добавить сервер",
|
||||
"dialog.server.default.title": "Сервер по умолчанию",
|
||||
"dialog.server.default.description":
|
||||
"Подключаться к этому серверу при запуске приложения вместо запуска локального сервера. Требуется перезапуск.",
|
||||
@@ -249,6 +254,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "Очистить",
|
||||
"dialog.server.action.remove": "Удалить сервер",
|
||||
|
||||
"dialog.server.menu.edit": "Редактировать",
|
||||
"dialog.server.menu.default": "Сделать по умолчанию",
|
||||
"dialog.server.menu.defaultRemove": "Удалить по умолчанию",
|
||||
"dialog.server.menu.delete": "Удалить",
|
||||
"dialog.server.current": "Текущий сервер",
|
||||
"dialog.server.status.default": "По умолч.",
|
||||
|
||||
"dialog.project.edit.title": "Редактировать проект",
|
||||
"dialog.project.edit.name": "Название",
|
||||
"dialog.project.edit.icon": "Иконка",
|
||||
|
||||
@@ -90,6 +90,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "其他",
|
||||
"dialog.provider.tag.recommended": "推荐",
|
||||
"dialog.provider.anthropic.note": "使用 Claude Pro/Max 或 API 密钥连接",
|
||||
"dialog.provider.openai.note": "使用 ChatGPT Pro/Plus 或 API 密钥连接",
|
||||
"dialog.provider.copilot.note": "使用 Copilot 或 API 密钥连接",
|
||||
|
||||
"dialog.model.select.title": "选择模型",
|
||||
"dialog.model.search.placeholder": "搜索模型",
|
||||
@@ -203,6 +205,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "已启用 {{enabled}} / {{total}}",
|
||||
"dialog.mcp.empty": "未配置 MCPs",
|
||||
|
||||
"dialog.lsp.empty": "已从文件类型自动检测到 LSPs",
|
||||
"dialog.plugins.empty": "在 opencode.json 中配置的插件",
|
||||
|
||||
"mcp.status.connected": "已连接",
|
||||
"mcp.status.failed": "失败",
|
||||
"mcp.status.needs_auth": "需要授权",
|
||||
@@ -222,7 +227,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "无法连接到服务器",
|
||||
"dialog.server.add.checking": "检查中...",
|
||||
"dialog.server.add.button": "添加",
|
||||
"dialog.server.add.button": "添加服务器",
|
||||
"dialog.server.default.title": "默认服务器",
|
||||
"dialog.server.default.description": "应用启动时连接此服务器,而不是启动本地服务器。需要重启。",
|
||||
"dialog.server.default.none": "未选择服务器",
|
||||
@@ -230,6 +235,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "清除",
|
||||
"dialog.server.action.remove": "移除服务器",
|
||||
|
||||
"dialog.server.menu.edit": "编辑",
|
||||
"dialog.server.menu.default": "设为默认",
|
||||
"dialog.server.menu.defaultRemove": "取消默认",
|
||||
"dialog.server.menu.delete": "删除",
|
||||
"dialog.server.current": "当前服务器",
|
||||
"dialog.server.status.default": "默认",
|
||||
|
||||
"dialog.project.edit.title": "编辑项目",
|
||||
"dialog.project.edit.name": "名称",
|
||||
"dialog.project.edit.icon": "图标",
|
||||
|
||||
@@ -90,6 +90,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "其他",
|
||||
"dialog.provider.tag.recommended": "推薦",
|
||||
"dialog.provider.anthropic.note": "使用 Claude Pro/Max 或 API 金鑰連線",
|
||||
"dialog.provider.openai.note": "使用 ChatGPT Pro/Plus 或 API 金鑰連線",
|
||||
"dialog.provider.copilot.note": "使用 Copilot 或 API 金鑰連線",
|
||||
|
||||
"dialog.model.select.title": "選擇模型",
|
||||
"dialog.model.search.placeholder": "搜尋模型",
|
||||
@@ -205,6 +207,9 @@ export const dict = {
|
||||
"dialog.mcp.description": "已啟用 {{enabled}} / {{total}}",
|
||||
"dialog.mcp.empty": "未設定 MCP",
|
||||
|
||||
"dialog.lsp.empty": "已從檔案類型自動偵測到 LSPs",
|
||||
"dialog.plugins.empty": "在 opencode.json 中設定的外掛程式",
|
||||
|
||||
"mcp.status.connected": "已連線",
|
||||
"mcp.status.failed": "失敗",
|
||||
"mcp.status.needs_auth": "需要授權",
|
||||
@@ -224,7 +229,7 @@ export const dict = {
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "無法連線到伺服器",
|
||||
"dialog.server.add.checking": "檢查中...",
|
||||
"dialog.server.add.button": "新增",
|
||||
"dialog.server.add.button": "新增伺服器",
|
||||
"dialog.server.default.title": "預設伺服器",
|
||||
"dialog.server.default.description": "應用程式啟動時連線此伺服器,而不是啟動本地伺服器。需要重新啟動。",
|
||||
"dialog.server.default.none": "未選擇伺服器",
|
||||
@@ -232,6 +237,13 @@ export const dict = {
|
||||
"dialog.server.default.clear": "清除",
|
||||
"dialog.server.action.remove": "移除伺服器",
|
||||
|
||||
"dialog.server.menu.edit": "編輯",
|
||||
"dialog.server.menu.default": "設為預設",
|
||||
"dialog.server.menu.defaultRemove": "取消預設",
|
||||
"dialog.server.menu.delete": "刪除",
|
||||
"dialog.server.current": "目前伺服器",
|
||||
"dialog.server.status.default": "預設",
|
||||
|
||||
"dialog.project.edit.title": "編輯專案",
|
||||
"dialog.project.edit.name": "名稱",
|
||||
"dialog.project.edit.icon": "圖示",
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function Layout(props: ParentProps) {
|
||||
return base64Decode(params.dir!)
|
||||
})
|
||||
return (
|
||||
<Show when={params.dir} keyed>
|
||||
<Show when={params.dir}>
|
||||
<SDKProvider directory={directory()}>
|
||||
<SyncProvider>
|
||||
{iife(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { createMemo, For, Match, Switch } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { useLayout } from "@/context/layout"
|
||||
|
||||
@@ -56,6 +56,7 @@ import { usePermission } from "@/context/permission"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
@@ -88,11 +89,6 @@ export default function Layout(props: ParentProps) {
|
||||
const pageReady = createMemo(() => ready())
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
const xlQuery = window.matchMedia("(min-width: 1280px)")
|
||||
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
|
||||
const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
|
||||
xlQuery.addEventListener("change", handleViewportChange)
|
||||
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
|
||||
|
||||
const params = useParams()
|
||||
const [autoselect, setAutoselect] = createSignal(!params.dir)
|
||||
@@ -332,6 +328,18 @@ export default function Layout(props: ParentProps) {
|
||||
const cooldownMs = 5000
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
if (e.details?.type === "worktree.ready") {
|
||||
setBusy(e.name, false)
|
||||
WorktreeState.ready(e.name)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.details?.type === "worktree.failed") {
|
||||
setBusy(e.name, false)
|
||||
WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
|
||||
return
|
||||
}
|
||||
|
||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
||||
const title =
|
||||
e.details.type === "permission.asked"
|
||||
@@ -537,8 +545,6 @@ export default function Layout(props: ParentProps) {
|
||||
const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
|
||||
workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
||||
|
||||
const isWorkspaceEditing = () => editor.active.startsWith("workspace:")
|
||||
|
||||
const workspaceSetting = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return false
|
||||
@@ -551,6 +557,7 @@ export default function Layout(props: ParentProps) {
|
||||
const project = currentProject()
|
||||
if (!project) return
|
||||
|
||||
const local = project.worktree
|
||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const existing = store.workspaceOrder[project.worktree]
|
||||
if (!existing) {
|
||||
@@ -558,9 +565,9 @@ export default function Layout(props: ParentProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const keep = existing.filter((d) => dirs.includes(d))
|
||||
const missing = dirs.filter((d) => !existing.includes(d))
|
||||
const merged = [...keep, ...missing]
|
||||
const keep = existing.filter((d) => d !== local && dirs.includes(d))
|
||||
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
|
||||
const merged = [local, ...missing, ...keep]
|
||||
|
||||
if (merged.length !== existing.length) {
|
||||
setStore("workspaceOrder", project.worktree, merged)
|
||||
@@ -710,7 +717,8 @@ export default function Layout(props: ParentProps) {
|
||||
if (!directory) return
|
||||
|
||||
const [store] = globalSync.child(directory)
|
||||
if (store.message[session.id] !== undefined) return
|
||||
const cached = untrack(() => store.message[session.id] !== undefined)
|
||||
if (cached) return
|
||||
|
||||
const q = queueFor(directory)
|
||||
if (q.inflight.has(session.id)) return
|
||||
@@ -841,14 +849,34 @@ export default function Layout(props: ParentProps) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const removed = new Set<string>([session.id])
|
||||
const collect = (parentID: string) => {
|
||||
for (const item of draft.session) {
|
||||
if (item.parentID !== parentID) continue
|
||||
removed.add(item.id)
|
||||
collect(item.id)
|
||||
|
||||
const byParent = new Map<string, string[]>()
|
||||
for (const item of draft.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [session.id]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
collect(session.id)
|
||||
|
||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||
}),
|
||||
)
|
||||
@@ -1246,9 +1274,9 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteWorkspace(props.directory)
|
||||
const handleDelete = () => {
|
||||
dialog.close()
|
||||
void deleteWorkspace(props.directory)
|
||||
}
|
||||
|
||||
const description = () => {
|
||||
@@ -1434,17 +1462,22 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
function workspaceIds(project: LocalProject | undefined) {
|
||||
if (!project) return []
|
||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const local = project.worktree
|
||||
const dirs = [local, ...(project.sandboxes ?? [])]
|
||||
const active = currentProject()
|
||||
const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined
|
||||
const next = directory && directory !== project.worktree && !dirs.includes(directory) ? [...dirs, directory] : dirs
|
||||
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
|
||||
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
||||
|
||||
const existing = store.workspaceOrder[project.worktree]
|
||||
if (!existing) return next
|
||||
if (!existing) return extra ? [...dirs, extra] : dirs
|
||||
|
||||
const keep = existing.filter((d) => next.includes(d))
|
||||
const missing = next.filter((d) => !existing.includes(d))
|
||||
return [...keep, ...missing]
|
||||
const keep = existing.filter((d) => d !== local && dirs.includes(d))
|
||||
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
|
||||
const merged = [local, ...(pending && extra ? [extra] : []), ...missing, ...keep]
|
||||
if (!extra) return merged
|
||||
if (pending) return merged
|
||||
return [...merged, extra]
|
||||
}
|
||||
|
||||
function handleWorkspaceDragStart(event: unknown) {
|
||||
@@ -2136,11 +2169,11 @@ export default function Layout(props: ParentProps) {
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
layout.sidebar.open()
|
||||
setOpen(false)
|
||||
if (selected()) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
layout.sidebar.open()
|
||||
navigateToProject(props.project.worktree)
|
||||
}}
|
||||
>
|
||||
@@ -2237,8 +2270,29 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
if (!created?.directory) return
|
||||
|
||||
const local = current.worktree
|
||||
const key = workspaceKey(created.directory)
|
||||
const root = workspaceKey(local)
|
||||
|
||||
setBusy(created.directory, true)
|
||||
WorktreeState.pending(created.directory)
|
||||
setStore("workspaceExpanded", key, true)
|
||||
if (key !== created.directory) {
|
||||
setStore("workspaceExpanded", created.directory, true)
|
||||
}
|
||||
setStore("workspaceOrder", current.worktree, (prev) => {
|
||||
const existing = prev ?? []
|
||||
const next = existing.filter((item) => {
|
||||
const id = workspaceKey(item)
|
||||
if (id === root) return false
|
||||
return id !== key
|
||||
})
|
||||
return [local, created.directory, ...next]
|
||||
})
|
||||
|
||||
globalSync.child(created.directory)
|
||||
navigate(`/${base64Encode(created.directory)}/session`)
|
||||
layout.mobileSidebar.hide()
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
|
||||
+251
-214
@@ -1,16 +1,4 @@
|
||||
import {
|
||||
For,
|
||||
Index,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Match,
|
||||
Switch,
|
||||
createMemo,
|
||||
createEffect,
|
||||
on,
|
||||
createSignal,
|
||||
} from "solid-js"
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
@@ -27,6 +15,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
@@ -39,7 +28,7 @@ import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { Terminal } from "@/components/terminal"
|
||||
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
@@ -65,7 +54,6 @@ import {
|
||||
SortableTerminalTab,
|
||||
NewSessionView,
|
||||
} from "@/components/session"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { navMark, navParams } from "@/utils/perf"
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
@@ -101,7 +89,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
const readFile = (path: string) => {
|
||||
const readFile = async (path: string) => {
|
||||
return sdk.client.file
|
||||
.read({ path })
|
||||
.then((x) => x.data)
|
||||
@@ -190,7 +178,6 @@ export default function Page() {
|
||||
const codeComponent = useCodeComponent()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
@@ -199,8 +186,8 @@ export default function Page() {
|
||||
const permission = usePermission()
|
||||
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
createEffect(
|
||||
@@ -316,12 +303,22 @@ export default function Page() {
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
|
||||
const visibleUserMessages = createMemo(() => {
|
||||
const revert = revertMessageID()
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
}, emptyUserMessages)
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
const visibleUserMessages = createMemo(
|
||||
() => {
|
||||
const revert = revertMessageID()
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{
|
||||
equals: same,
|
||||
},
|
||||
)
|
||||
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
|
||||
|
||||
createEffect(
|
||||
@@ -347,13 +344,19 @@ export default function Page() {
|
||||
promptHeight: 0,
|
||||
})
|
||||
|
||||
const renderedUserMessages = createMemo(() => {
|
||||
const msgs = visibleUserMessages()
|
||||
const start = store.turnStart
|
||||
if (start <= 0) return msgs
|
||||
if (start >= msgs.length) return emptyUserMessages
|
||||
return msgs.slice(start)
|
||||
}, emptyUserMessages)
|
||||
const renderedUserMessages = createMemo(
|
||||
() => {
|
||||
const msgs = visibleUserMessages()
|
||||
const start = store.turnStart
|
||||
if (start <= 0) return msgs
|
||||
if (start >= msgs.length) return emptyUserMessages
|
||||
return msgs.slice(start)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{
|
||||
equals: same,
|
||||
},
|
||||
)
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
@@ -519,6 +522,7 @@ export default function Page() {
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
preview?: string
|
||||
origin?: "review" | "file"
|
||||
}) => {
|
||||
const selection = selectionFromLines(input.selection)
|
||||
const preview = input.preview ?? selectionPreview(input.file, selection)
|
||||
@@ -533,6 +537,7 @@ export default function Page() {
|
||||
selection,
|
||||
comment: input.comment,
|
||||
commentID: saved.id,
|
||||
commentOrigin: input.origin,
|
||||
preview,
|
||||
})
|
||||
}
|
||||
@@ -729,7 +734,7 @@ export default function Page() {
|
||||
}
|
||||
const revert = info()?.revert?.messageID
|
||||
// Find the last user message that's not already reverted
|
||||
const message = userMessages().findLast((x) => !revert || x.id < revert)
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
// Restore the prompt from the reverted message
|
||||
@@ -739,7 +744,7 @@ export default function Page() {
|
||||
prompt.set(restored)
|
||||
}
|
||||
// Navigate to the message before the reverted one (which will be the new last visible message)
|
||||
const priorMessage = userMessages().findLast((x) => x.id < message.id)
|
||||
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(priorMessage)
|
||||
},
|
||||
},
|
||||
@@ -761,14 +766,14 @@ export default function Page() {
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
// Navigate to the last message (the one that was at the revert point)
|
||||
const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
|
||||
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(lastMsg)
|
||||
return
|
||||
}
|
||||
// Partial redo - move forward to next message
|
||||
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
// Navigate to the message before the new revert point
|
||||
const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
|
||||
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
|
||||
setActiveMessage(priorMsg)
|
||||
},
|
||||
},
|
||||
@@ -1246,19 +1251,40 @@ export default function Page() {
|
||||
autoScroll.forceScrollToBottom()
|
||||
}
|
||||
|
||||
const closestMessage = (node: Element | null): HTMLElement | null => {
|
||||
if (!node) return null
|
||||
const match = node.closest?.("[data-message-id]") as HTMLElement | null
|
||||
if (match) return match
|
||||
const root = node.getRootNode?.()
|
||||
if (root instanceof ShadowRoot) return closestMessage(root.host)
|
||||
return null
|
||||
}
|
||||
|
||||
const getActiveMessageId = (container: HTMLDivElement) => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
|
||||
const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2))
|
||||
const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100))
|
||||
|
||||
const hit = document.elementFromPoint(x, y)
|
||||
const host = closestMessage(hit)
|
||||
const id = host?.dataset.messageId
|
||||
if (id) return id
|
||||
|
||||
// Fallback: DOM query (handles edge hit-testing cases)
|
||||
const cutoff = container.scrollTop + 100
|
||||
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
|
||||
let id: string | undefined
|
||||
let last: string | undefined
|
||||
|
||||
for (const node of nodes) {
|
||||
const next = node.dataset.messageId
|
||||
if (!next) continue
|
||||
if (node.offsetTop > cutoff) break
|
||||
id = next
|
||||
last = next
|
||||
}
|
||||
|
||||
return id
|
||||
return last
|
||||
}
|
||||
|
||||
const scheduleScrollSpy = (container: HTMLDivElement) => {
|
||||
@@ -1401,7 +1427,7 @@ export default function Page() {
|
||||
classes={{ button: "w-full" }}
|
||||
onClick={() => setStore("mobileTab", "session")}
|
||||
>
|
||||
Session
|
||||
{language.t("session.tab.session")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
@@ -1410,8 +1436,10 @@ export default function Page() {
|
||||
onClick={() => setStore("mobileTab", "review")}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={hasReview()}>{reviewCount()} Files Changed</Match>
|
||||
<Match when={true}>Review</Match>
|
||||
<Match when={hasReview()}>
|
||||
{language.t("session.review.filesChanged", { count: reviewCount() })}
|
||||
</Match>
|
||||
<Match when={true}>{language.t("session.tab.review")}</Match>
|
||||
</Switch>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
@@ -1422,7 +1450,7 @@ export default function Page() {
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
||||
"flex-1 md:flex-none py-6 md:py-3": true,
|
||||
"flex-1 md:flex-none pt-6 md:pt-3": true,
|
||||
}}
|
||||
style={{
|
||||
width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
|
||||
@@ -1441,13 +1469,17 @@ export default function Page() {
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
|
||||
fallback={
|
||||
<div class="px-4 py-4 text-text-weak">
|
||||
{language.t("session.review.loadingChanges")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle="unified"
|
||||
onLineComment={addCommentToContext}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
@@ -1467,7 +1499,9 @@ export default function Page() {
|
||||
<Match when={true}>
|
||||
<div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
|
||||
<div class="text-13-regular text-text-weak max-w-56">
|
||||
{language.t("session.review.empty")}
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1509,9 +1543,9 @@ export default function Page() {
|
||||
}}
|
||||
onClick={autoScroll.handleInteraction}
|
||||
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
|
||||
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
|
||||
style={{ "--session-title-height": info()?.title || info()?.parentID ? "40px" : "0px" }}
|
||||
>
|
||||
<Show when={info()?.title}>
|
||||
<Show when={info()?.title || info()?.parentID}>
|
||||
<div
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-background-stronger": true,
|
||||
@@ -1520,8 +1554,21 @@ export default function Page() {
|
||||
"md:max-w-200 md:mx-auto": !showTabs(),
|
||||
}}
|
||||
>
|
||||
<div class="h-10 flex items-center">
|
||||
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
|
||||
<div class="h-10 flex items-center gap-1">
|
||||
<Show when={info()?.parentID}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
navigate(`/${params.dir}/session/${info()?.parentID}`)
|
||||
}}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={info()?.title}>
|
||||
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -1635,11 +1682,11 @@ export default function Page() {
|
||||
{/* Prompt input */}
|
||||
<div
|
||||
ref={(el) => (promptDock = el)}
|
||||
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
|
||||
class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-full md:px-6 pointer-events-auto": true,
|
||||
"w-full px-4 pointer-events-auto": true,
|
||||
"md:max-w-200": !showTabs(),
|
||||
}}
|
||||
>
|
||||
@@ -1699,7 +1746,7 @@ export default function Page() {
|
||||
<DiffChanges changes={diffs()} variant="bars" />
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>Review</div>
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={info()?.summary?.files}>
|
||||
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
||||
{info()?.summary?.files ?? 0}
|
||||
@@ -1727,7 +1774,7 @@ export default function Page() {
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>Context</div>
|
||||
<div>{language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
@@ -1736,7 +1783,7 @@ export default function Page() {
|
||||
</SortableProvider>
|
||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||
<TooltipKeybind
|
||||
title="Open file"
|
||||
title={language.t("command.file.open")}
|
||||
keybind={command.keybind("file.open")}
|
||||
class="flex items-center"
|
||||
>
|
||||
@@ -1759,14 +1806,18 @@ export default function Page() {
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
|
||||
fallback={
|
||||
<div class="px-6 py-4 text-text-weak">
|
||||
{language.t("session.review.loadingChanges")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle={layout.review.diffStyle()}
|
||||
onDiffStyleChange={layout.review.setDiffStyle}
|
||||
onLineComment={addCommentToContext}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
@@ -1809,6 +1860,7 @@ export default function Page() {
|
||||
let scrollFrame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
let codeScroll: HTMLElement[] = []
|
||||
let focusToken = 0
|
||||
|
||||
const path = createMemo(() => file.pathFromTab(tab))
|
||||
const state = createMemo(() => {
|
||||
@@ -1855,7 +1907,6 @@ export default function Page() {
|
||||
})
|
||||
|
||||
let wrap: HTMLDivElement | undefined
|
||||
let textarea: HTMLTextAreaElement | undefined
|
||||
|
||||
const fileComments = createMemo(() => {
|
||||
const p = path()
|
||||
@@ -1871,6 +1922,8 @@ export default function Page() {
|
||||
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
||||
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
||||
|
||||
const empty = {} as Record<string, number>
|
||||
|
||||
const commentLabel = (range: SelectedLineRange) => {
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
@@ -1904,12 +1957,22 @@ export default function Page() {
|
||||
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
||||
}
|
||||
|
||||
const equal = (a: Record<string, number>, b: Record<string, number>) => {
|
||||
const aKeys = Object.keys(a)
|
||||
const bKeys = Object.keys(b)
|
||||
if (aKeys.length !== bKeys.length) return false
|
||||
for (const key of aKeys) {
|
||||
if (a[key] !== b[key]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const updateComments = () => {
|
||||
const el = wrap
|
||||
const root = getRoot()
|
||||
if (!el || !root) {
|
||||
setPositions({})
|
||||
setDraftTop(undefined)
|
||||
setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty))
|
||||
setDraftTop((prev) => (prev === undefined ? prev : undefined))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1920,7 +1983,7 @@ export default function Page() {
|
||||
next[comment.id] = markerTop(el, marker)
|
||||
}
|
||||
|
||||
setPositions(next)
|
||||
setPositions((prev) => (equal(prev, next) ? prev : next))
|
||||
|
||||
const range = commenting()
|
||||
if (!range) {
|
||||
@@ -1934,11 +1997,18 @@ export default function Page() {
|
||||
return
|
||||
}
|
||||
|
||||
setDraftTop(markerTop(el, marker))
|
||||
const nextTop = markerTop(el, marker)
|
||||
setDraftTop((prev) => (prev === nextTop ? prev : nextTop))
|
||||
}
|
||||
|
||||
let commentFrame: number | undefined
|
||||
|
||||
const scheduleComments = () => {
|
||||
requestAnimationFrame(updateComments)
|
||||
if (commentFrame !== undefined) return
|
||||
commentFrame = requestAnimationFrame(() => {
|
||||
commentFrame = undefined
|
||||
updateComments()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -1955,7 +2025,63 @@ export default function Page() {
|
||||
const range = commenting()
|
||||
if (!range) return
|
||||
setDraft("")
|
||||
requestAnimationFrame(() => textarea?.focus())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const focus = comments.focus()
|
||||
const p = path()
|
||||
if (!focus || !p) return
|
||||
if (focus.file !== p) return
|
||||
if (activeTab() !== tab) return
|
||||
|
||||
const target = fileComments().find((comment) => comment.id === focus.id)
|
||||
if (!target) return
|
||||
|
||||
focusToken++
|
||||
const token = focusToken
|
||||
|
||||
setOpenedComment(target.id)
|
||||
setCommenting(null)
|
||||
file.setSelectedLines(p, target.selection)
|
||||
|
||||
const scrollTo = (attempt: number) => {
|
||||
if (token !== focusToken) return
|
||||
|
||||
const root = scroll
|
||||
if (!root) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => scrollTo(attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
const anchor = root.querySelector(`[data-comment-id="${target.id}"]`)
|
||||
const ready =
|
||||
anchor instanceof HTMLElement &&
|
||||
anchor.style.pointerEvents !== "none" &&
|
||||
anchor.style.opacity !== "0"
|
||||
|
||||
const shadow = getRoot()
|
||||
const marker = shadow ? findMarker(shadow, target.selection) : undefined
|
||||
const node = (ready ? anchor : (marker ?? wrap)) as HTMLElement | undefined
|
||||
if (!node) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => scrollTo(attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const targetRect = node.getBoundingClientRect()
|
||||
const offset = targetRect.top - rootRect.top
|
||||
const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
|
||||
root.scrollTop = Math.max(0, next)
|
||||
|
||||
if (ready || marker) return
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => scrollTo(attempt + 1))
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => scrollTo(0))
|
||||
requestAnimationFrame(() => comments.clearFocus())
|
||||
})
|
||||
|
||||
const renderCode = (source: string, wrapperClass: string) => (
|
||||
@@ -2000,105 +2126,58 @@ export default function Page() {
|
||||
/>
|
||||
<For each={fileComments()}>
|
||||
{(comment) => (
|
||||
<div
|
||||
class="absolute right-6 z-30"
|
||||
style={{
|
||||
top: `${positions()[comment.id] ?? 0}px`,
|
||||
opacity: positions()[comment.id] === undefined ? 0 : 1,
|
||||
"pointer-events": positions()[comment.id] === undefined ? "none" : "auto",
|
||||
<LineCommentView
|
||||
id={comment.id}
|
||||
top={positions()[comment.id]}
|
||||
open={openedComment() === comment.id}
|
||||
onMouseEnter={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
|
||||
onMouseEnter={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setCommenting(null)
|
||||
setOpenedComment((current) => (current === comment.id ? null : comment.id))
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
>
|
||||
<Icon name="speech-bubble" size="small" />
|
||||
</button>
|
||||
<Show when={openedComment() === comment.id}>
|
||||
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="text-12-medium text-text-strong whitespace-nowrap">
|
||||
{getFilename(comment.file)}:{commentLabel(comment.selection)}
|
||||
</div>
|
||||
<div class="text-12-regular text-text-base whitespace-pre-wrap">
|
||||
{comment.comment}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setCommenting(null)
|
||||
setOpenedComment((current) => (current === comment.id ? null : comment.id))
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
comment={comment.comment}
|
||||
selection={commentLabel(comment.selection)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={commenting()}>
|
||||
{(range) => (
|
||||
<Show when={draftTop() !== undefined}>
|
||||
<div class="absolute right-6 z-30" style={{ top: `${draftTop() ?? 0}px` }}>
|
||||
<button
|
||||
type="button"
|
||||
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
|
||||
onClick={() => textarea?.focus()}
|
||||
>
|
||||
<Icon name="speech-bubble" size="small" />
|
||||
</button>
|
||||
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-12-medium text-text-strong">
|
||||
Commenting on {getFilename(path() ?? "")}:{commentLabel(range())}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textarea}
|
||||
class="w-[320px] max-w-[calc(100vw-48px)] resize-vertical p-2 rounded-sm bg-surface-base border border-border-base text-text-strong text-12-regular leading-5 focus:outline-none focus:shadow-xs-border-focus"
|
||||
rows={3}
|
||||
placeholder="Add a comment"
|
||||
value={draft()}
|
||||
onInput={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return
|
||||
if (e.shiftKey) return
|
||||
e.preventDefault()
|
||||
const value = draft().trim()
|
||||
if (!value) return
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({ file: p, selection: range(), comment: value })
|
||||
setCommenting(null)
|
||||
}}
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="small" variant="ghost" onClick={() => setCommenting(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
disabled={draft().trim().length === 0}
|
||||
onClick={() => {
|
||||
const value = draft().trim()
|
||||
if (!value) return
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({ file: p, selection: range(), comment: value })
|
||||
setCommenting(null)
|
||||
}}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LineCommentEditor
|
||||
top={draftTop()}
|
||||
value={draft()}
|
||||
selection={commentLabel(range())}
|
||||
onInput={setDraft}
|
||||
onCancel={() => setCommenting(null)}
|
||||
onSubmit={(comment) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({
|
||||
file: p,
|
||||
selection: range(),
|
||||
comment,
|
||||
origin: "file",
|
||||
})
|
||||
setCommenting(null)
|
||||
}}
|
||||
onPopoverFocusOut={(e) => {
|
||||
const target = e.relatedTarget as Node | null
|
||||
if (target && e.currentTarget.contains(target)) return
|
||||
// Delay to allow click handlers to fire first
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !e.currentTarget.contains(document.activeElement)) {
|
||||
setCommenting(null)
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
@@ -2228,6 +2307,7 @@ export default function Page() {
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (commentFrame !== undefined) cancelAnimationFrame(commentFrame)
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
@@ -2384,66 +2464,23 @@ export default function Page() {
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<For each={terminal.all()}>
|
||||
{(pty) => {
|
||||
const [dismissed, setDismissed] = createSignal(false)
|
||||
return (
|
||||
<div
|
||||
id={`terminal-wrapper-${pty.id}`}
|
||||
class="absolute inset-0"
|
||||
style={{
|
||||
display: terminal.active() === pty.id ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
{(pty) => (
|
||||
<div
|
||||
id={`terminal-wrapper-${pty.id}`}
|
||||
class="absolute inset-0"
|
||||
style={{
|
||||
display: terminal.active() === pty.id ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
<Show when={pty.id} keyed>
|
||||
<Terminal
|
||||
pty={pty}
|
||||
onCleanup={(data) => terminal.update({ ...data, id: pty.id })}
|
||||
onConnect={() => {
|
||||
terminal.update({ id: pty.id, error: false })
|
||||
setDismissed(false)
|
||||
}}
|
||||
onConnectError={() => {
|
||||
setDismissed(false)
|
||||
terminal.update({ id: pty.id, error: true })
|
||||
}}
|
||||
onCleanup={terminal.update}
|
||||
onConnectError={() => terminal.clone(pty.id)}
|
||||
/>
|
||||
<Show when={pty.error && !dismissed()}>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-3"
|
||||
style={{ "background-color": "rgba(0, 0, 0, 0.6)" }}
|
||||
>
|
||||
<Icon
|
||||
name="circle-ban-sign"
|
||||
class="w-8 h-8"
|
||||
style={{ color: "rgba(239, 68, 68, 0.8)" }}
|
||||
/>
|
||||
<div class="text-center" style={{ color: "rgba(255, 255, 255, 0.7)" }}>
|
||||
<div class="text-14-semibold mb-1">{language.t("terminal.connectionLost.title")}</div>
|
||||
<div class="text-12-regular" style={{ color: "rgba(255, 255, 255, 0.5)" }}>
|
||||
{language.t("terminal.connectionLost.description")}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="mt-2 px-3 py-1.5 text-12-medium rounded-lg transition-colors"
|
||||
style={{
|
||||
"background-color": "rgba(255, 255, 255, 0.1)",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.15)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)")
|
||||
}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
{language.t("common.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,5 +106,12 @@ export function soundSrc(id: string | undefined) {
|
||||
export function playSound(src: string | undefined) {
|
||||
if (typeof Audio === "undefined") return
|
||||
if (!src) return
|
||||
void new Audio(src).play().catch(() => undefined)
|
||||
const audio = new Audio(src)
|
||||
audio.play().catch(() => undefined)
|
||||
|
||||
// Return a cleanup function to pause the sound.
|
||||
return () => {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
const normalize = (directory: string) => directory.replace(/[\\/]+$/, "")
|
||||
|
||||
type State =
|
||||
| {
|
||||
status: "pending"
|
||||
}
|
||||
| {
|
||||
status: "ready"
|
||||
}
|
||||
| {
|
||||
status: "failed"
|
||||
message: string
|
||||
}
|
||||
|
||||
const state = new Map<string, State>()
|
||||
const waiters = new Map<string, Array<(state: State) => void>>()
|
||||
|
||||
export const Worktree = {
|
||||
get(directory: string) {
|
||||
return state.get(normalize(directory))
|
||||
},
|
||||
pending(directory: string) {
|
||||
const key = normalize(directory)
|
||||
const current = state.get(key)
|
||||
if (current && current.status !== "pending") return
|
||||
state.set(key, { status: "pending" })
|
||||
},
|
||||
ready(directory: string) {
|
||||
const key = normalize(directory)
|
||||
state.set(key, { status: "ready" })
|
||||
const list = waiters.get(key)
|
||||
if (!list) return
|
||||
waiters.delete(key)
|
||||
for (const fn of list) fn({ status: "ready" })
|
||||
},
|
||||
failed(directory: string, message: string) {
|
||||
const key = normalize(directory)
|
||||
state.set(key, { status: "failed", message })
|
||||
const list = waiters.get(key)
|
||||
if (!list) return
|
||||
waiters.delete(key)
|
||||
for (const fn of list) fn({ status: "failed", message })
|
||||
},
|
||||
wait(directory: string) {
|
||||
const key = normalize(directory)
|
||||
const current = state.get(key)
|
||||
if (current && current.status !== "pending") return Promise.resolve(current)
|
||||
|
||||
return new Promise<State>((resolve) => {
|
||||
const list = waiters.get(key)
|
||||
if (!list) {
|
||||
waiters.set(key, [resolve])
|
||||
return
|
||||
}
|
||||
list.push(resolve)
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -170,22 +170,18 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
|
||||
</Switch>
|
||||
</li>
|
||||
<Show when={!props.hideGetStarted}>
|
||||
{" "}
|
||||
<li>
|
||||
{" "}
|
||||
<A href="/download" data-slot="cta-button">
|
||||
{" "}
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{" "}
|
||||
<path
|
||||
d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>{" "}
|
||||
</svg>{" "}
|
||||
Free{" "}
|
||||
</A>{" "}
|
||||
/>
|
||||
</svg>
|
||||
Free
|
||||
</A>
|
||||
</li>
|
||||
</Show>
|
||||
</ul>
|
||||
|
||||
@@ -106,10 +106,13 @@
|
||||
[data-slot="cta-button"] {
|
||||
background: var(--color-background-strong);
|
||||
color: var(--color-text-inverted);
|
||||
padding: 8px 16px;
|
||||
padding: 8px 16px 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
display: none;
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function Changelog() {
|
||||
<Meta name="description" content="OpenCode release notes and changelog" />
|
||||
|
||||
<div data-component="container">
|
||||
<Header hideGetStarted />
|
||||
<Header />
|
||||
|
||||
<div data-component="content">
|
||||
<section data-component="changelog-hero">
|
||||
|
||||
@@ -141,8 +141,6 @@ export async function POST(input: APIEvent) {
|
||||
return couponID
|
||||
})()
|
||||
|
||||
// get user
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
// look up current billing
|
||||
const billing = await Billing.get()
|
||||
@@ -422,8 +420,8 @@ export async function POST(input: APIEvent) {
|
||||
}
|
||||
if (body.type === "invoice.payment_succeeded") {
|
||||
if (
|
||||
body.data.object.billing_reason === "subscription_cycle" ||
|
||||
body.data.object.billing_reason === "subscription_create"
|
||||
body.data.object.billing_reason === "subscription_create" ||
|
||||
body.data.object.billing_reason === "subscription_cycle"
|
||||
) {
|
||||
const invoiceID = body.data.object.id as string
|
||||
const amountInCents = body.data.object.amount_paid
|
||||
@@ -476,6 +474,70 @@ export async function POST(input: APIEvent) {
|
||||
},
|
||||
}),
|
||||
)
|
||||
} else if (body.data.object.billing_reason === "manual") {
|
||||
const workspaceID = body.data.object.metadata?.workspaceID
|
||||
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
||||
const invoiceID = body.data.object.id as string
|
||||
const customerID = body.data.object.customer as string
|
||||
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!amountInCents) throw new Error("Amount not found")
|
||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
// get payment id from invoice
|
||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||
expand: ["payments"],
|
||||
})
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
|
||||
reloadError: null,
|
||||
timeReloadError: null,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||
await tx.insert(PaymentTable).values({
|
||||
workspaceID: Actor.workspace(),
|
||||
id: Identifier.create("payment"),
|
||||
amount: centsToMicroCents(amountInCents),
|
||||
invoiceID,
|
||||
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
|
||||
customerID,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
if (body.type === "invoice.payment_failed" || body.type === "invoice.payment_action_required") {
|
||||
if (body.data.object.billing_reason === "manual") {
|
||||
const workspaceID = body.data.object.metadata?.workspaceID
|
||||
const invoiceID = body.data.object.id
|
||||
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||
|
||||
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(invoiceID)
|
||||
console.log(JSON.stringify(paymentIntent))
|
||||
const errorMessage =
|
||||
typeof paymentIntent === "object" && paymentIntent !== null
|
||||
? paymentIntent.last_payment_error?.message
|
||||
: undefined
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
reload: false,
|
||||
reloadError: errorMessage ?? "Payment failed.",
|
||||
timeReloadError: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace())),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (body.type === "charge.refunded") {
|
||||
|
||||
@@ -59,4 +59,84 @@
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="setting-row"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="toggle-label"] {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 2.5rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: #ccc;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.125rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + span {
|
||||
background-color: #21ad0e;
|
||||
border-color: #148605;
|
||||
|
||||
&::before {
|
||||
transform: translateX(1rem) translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover span {
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
input:checked:hover + span {
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input:disabled + span {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { action, useParams, useAction, useSubmission, json, query, createAsync }
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Show } from "solid-js"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { Database, eq, and, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { Black } from "@opencode-ai/console-core/black.js"
|
||||
@@ -32,6 +32,7 @@ const querySubscription = query(async (workspaceID: string) => {
|
||||
|
||||
return {
|
||||
plan: row.subscription.plan,
|
||||
useBalance: row.subscription.useBalance ?? false,
|
||||
rollingUsage: Black.analyzeRollingUsage({
|
||||
plan: row.subscription.plan,
|
||||
usage: row.rollingUsage ?? 0,
|
||||
@@ -107,6 +108,30 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
|
||||
)
|
||||
}, "sessionUrl")
|
||||
|
||||
const setUseBalance = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
const useBalance = form.get("useBalance")?.toString() === "true"
|
||||
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
subscription: useBalance
|
||||
? sql`JSON_SET(subscription, '$.useBalance', true)`
|
||||
: sql`JSON_REMOVE(subscription, '$.useBalance')`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
)
|
||||
return { error: undefined }
|
||||
}, workspaceID).catch((e) => ({ error: e.message as string })),
|
||||
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
|
||||
)
|
||||
}, "setUseBalance")
|
||||
|
||||
export function BlackSection() {
|
||||
const params = useParams()
|
||||
const billing = createAsync(() => queryBillingInfo(params.id!))
|
||||
@@ -117,6 +142,7 @@ export function BlackSection() {
|
||||
const cancelSubmission = useSubmission(cancelWaitlist)
|
||||
const enrollAction = useAction(enroll)
|
||||
const enrollSubmission = useSubmission(enroll)
|
||||
const useBalanceSubmission = useSubmission(setUseBalance)
|
||||
const [store, setStore] = createStore({
|
||||
sessionRedirecting: false,
|
||||
cancelled: false,
|
||||
@@ -185,6 +211,20 @@ export function BlackSection() {
|
||||
<span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<form action={setUseBalance} method="post" data-slot="setting-row">
|
||||
<p>Use your available balance after reaching the usage limits</p>
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<input type="hidden" name="useBalance" value={sub().useBalance ? "false" : "true"} />
|
||||
<label data-slot="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sub().useBalance}
|
||||
disabled={useBalanceSubmission.pending}
|
||||
onChange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
/>
|
||||
<span></span>
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -184,7 +184,7 @@ export function ReloadSection() {
|
||||
</div>
|
||||
</form>
|
||||
</Show>
|
||||
<Show when={billingInfo()?.reload && billingInfo()?.reloadError}>
|
||||
<Show when={billingInfo()?.reloadError}>
|
||||
<div data-slot="section-content">
|
||||
<div data-slot="reload-error">
|
||||
<p>
|
||||
|
||||
@@ -43,8 +43,9 @@ export default function () {
|
||||
</span>
|
||||
<Show when={userInfo()?.isAdmin}>
|
||||
<span data-slot="billing-info">
|
||||
<Switch>
|
||||
<Match when={!billingInfo()?.customerID}>
|
||||
<Show
|
||||
when={billingInfo()?.customerID}
|
||||
fallback={
|
||||
<button
|
||||
data-color="primary"
|
||||
data-size="sm"
|
||||
@@ -53,13 +54,12 @@ export default function () {
|
||||
>
|
||||
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
|
||||
</button>
|
||||
</Match>
|
||||
<Match when={!billingInfo()?.subscriptionID}>
|
||||
<span data-slot="balance">
|
||||
Current balance <b>${balance()}</b>
|
||||
</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<span data-slot="balance">
|
||||
Current balance <b>${balance()}</b>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</p>
|
||||
|
||||
@@ -110,6 +110,7 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
|
||||
timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated,
|
||||
reloadError: billing.reloadError,
|
||||
timeReloadError: billing.timeReloadError,
|
||||
subscription: billing.subscription,
|
||||
subscriptionID: billing.subscriptionID,
|
||||
subscriptionPlan: billing.subscriptionPlan,
|
||||
timeSubscriptionBooked: billing.timeSubscriptionBooked,
|
||||
|
||||
@@ -84,6 +84,7 @@ export async function handler(
|
||||
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
||||
const stickyProvider = await stickyTracker?.get()
|
||||
const authInfo = await authenticate(modelInfo)
|
||||
const billingSource = validateBilling(authInfo, modelInfo)
|
||||
|
||||
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
||||
const providerInfo = selectProvider(
|
||||
@@ -96,7 +97,6 @@ export async function handler(
|
||||
retry,
|
||||
stickyProvider,
|
||||
)
|
||||
validateBilling(authInfo, modelInfo)
|
||||
validateModelSettings(authInfo)
|
||||
updateProviderKey(authInfo, providerInfo)
|
||||
logger.metric({ provider: providerInfo.id })
|
||||
@@ -183,7 +183,7 @@ export async function handler(
|
||||
const tokensInfo = providerInfo.normalizeUsage(json.usage)
|
||||
await trialLimiter?.track(tokensInfo)
|
||||
await rateLimiter?.track()
|
||||
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, billingSource, tokensInfo)
|
||||
await reload(authInfo, costInfo)
|
||||
return new Response(body, {
|
||||
status: resStatus,
|
||||
@@ -219,7 +219,7 @@ export async function handler(
|
||||
if (usage) {
|
||||
const tokensInfo = providerInfo.normalizeUsage(usage)
|
||||
await trialLimiter?.track(tokensInfo)
|
||||
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, billingSource, tokensInfo)
|
||||
await reload(authInfo, costInfo)
|
||||
}
|
||||
c.close()
|
||||
@@ -484,54 +484,58 @@ export async function handler(
|
||||
}
|
||||
|
||||
function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo) {
|
||||
if (!authInfo) return
|
||||
if (authInfo.provider?.credentials) return
|
||||
if (authInfo.isFree) return
|
||||
if (modelInfo.allowAnonymous) return
|
||||
if (!authInfo) return "anonymous"
|
||||
if (authInfo.provider?.credentials) return "free"
|
||||
if (authInfo.isFree) return "free"
|
||||
if (modelInfo.allowAnonymous) return "free"
|
||||
|
||||
// Validate subscription billing
|
||||
if (authInfo.billing.subscription && authInfo.subscription) {
|
||||
const sub = authInfo.subscription
|
||||
const plan = authInfo.billing.subscription.plan
|
||||
try {
|
||||
const sub = authInfo.subscription
|
||||
const plan = authInfo.billing.subscription.plan
|
||||
|
||||
const formatRetryTime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.ceil((seconds % 3600) / 60)
|
||||
if (hours >= 1) return `${hours}hr ${minutes}min`
|
||||
return `${minutes}min`
|
||||
const formatRetryTime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.ceil((seconds % 3600) / 60)
|
||||
if (hours >= 1) return `${hours}hr ${minutes}min`
|
||||
return `${minutes}min`
|
||||
}
|
||||
|
||||
// Check weekly limit
|
||||
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
||||
const result = Black.analyzeWeeklyUsage({
|
||||
plan,
|
||||
usage: sub.fixedUsage,
|
||||
timeUpdated: sub.timeFixedUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
|
||||
// Check rolling limit
|
||||
if (sub.rollingUsage && sub.timeRollingUpdated) {
|
||||
const result = Black.analyzeRollingUsage({
|
||||
plan,
|
||||
usage: sub.rollingUsage,
|
||||
timeUpdated: sub.timeRollingUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
|
||||
return "subscription"
|
||||
} catch (e) {
|
||||
if (!authInfo.billing.subscription.useBalance) throw e
|
||||
}
|
||||
|
||||
// Check weekly limit
|
||||
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
||||
const result = Black.analyzeWeeklyUsage({
|
||||
plan,
|
||||
usage: sub.fixedUsage,
|
||||
timeUpdated: sub.timeFixedUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
|
||||
// Check rolling limit
|
||||
if (sub.rollingUsage && sub.timeRollingUpdated) {
|
||||
const result = Black.analyzeRollingUsage({
|
||||
plan,
|
||||
usage: sub.rollingUsage,
|
||||
timeUpdated: sub.timeRollingUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Validate pay as you go billing
|
||||
@@ -571,6 +575,8 @@ export async function handler(
|
||||
throw new UserLimitError(
|
||||
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
|
||||
)
|
||||
|
||||
return "balance"
|
||||
}
|
||||
|
||||
function validateModelSettings(authInfo: AuthInfo) {
|
||||
@@ -587,6 +593,7 @@ export async function handler(
|
||||
authInfo: AuthInfo,
|
||||
modelInfo: ModelInfo,
|
||||
providerInfo: ProviderInfo,
|
||||
billingSource: ReturnType<typeof validateBilling>,
|
||||
usageInfo: UsageInfo,
|
||||
) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
@@ -643,7 +650,8 @@ export async function handler(
|
||||
"cost.total": Math.round(totalCostInCent),
|
||||
})
|
||||
|
||||
if (!authInfo) return
|
||||
if (billingSource === "anonymous") return
|
||||
authInfo = authInfo!
|
||||
|
||||
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
||||
await Database.use((db) =>
|
||||
@@ -661,13 +669,13 @@ export async function handler(
|
||||
cacheWrite1hTokens,
|
||||
cost,
|
||||
keyID: authInfo.apiKeyId,
|
||||
enrichment: authInfo.subscription ? { plan: "sub" } : undefined,
|
||||
enrichment: billingSource === "subscription" ? { plan: "sub" } : undefined,
|
||||
}),
|
||||
db
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||
...(authInfo.subscription
|
||||
...(billingSource === "subscription"
|
||||
? (() => {
|
||||
const plan = authInfo.billing.subscription!.plan
|
||||
const black = BlackData.getLimits({ plan })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -78,8 +78,6 @@ export namespace Billing {
|
||||
const customerID = billing.customerID
|
||||
const paymentMethodID = billing.paymentMethodID
|
||||
const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
|
||||
const paymentID = Identifier.create("payment")
|
||||
let invoice
|
||||
try {
|
||||
const draft = await Billing.stripe().invoices.create({
|
||||
customer: customerID!,
|
||||
@@ -87,6 +85,10 @@ export namespace Billing {
|
||||
default_payment_method: paymentMethodID!,
|
||||
collection_method: "charge_automatically",
|
||||
currency: "usd",
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
amount: amountInCents.toString(),
|
||||
},
|
||||
})
|
||||
await Billing.stripe().invoiceItems.create({
|
||||
amount: amountInCents,
|
||||
@@ -103,19 +105,17 @@ export namespace Billing {
|
||||
description: ITEM_FEE_NAME,
|
||||
})
|
||||
await Billing.stripe().invoices.finalizeInvoice(draft.id!)
|
||||
invoice = await Billing.stripe().invoices.pay(draft.id!, {
|
||||
await Billing.stripe().invoices.pay(draft.id!, {
|
||||
off_session: true,
|
||||
payment_method: paymentMethodID!,
|
||||
expand: ["payments"],
|
||||
})
|
||||
if (invoice.status !== "paid" || invoice.payments?.data.length !== 1)
|
||||
throw new Error(invoice.last_finalization_error?.message)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
reload: false,
|
||||
reloadError: e.message ?? "Payment failed.",
|
||||
timeReloadError: sql`now()`,
|
||||
})
|
||||
@@ -123,25 +123,6 @@ export namespace Billing {
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
|
||||
reloadError: null,
|
||||
timeReloadError: null,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||
await tx.insert(PaymentTable).values({
|
||||
workspaceID: Actor.workspace(),
|
||||
id: paymentID,
|
||||
amount: centsToMicroCents(amountInCents),
|
||||
invoiceID: invoice.id!,
|
||||
paymentID: invoice.payments?.data[0].payment.payment_intent as string,
|
||||
customerID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const grantCredit = async (workspaceID: string, dollarAmount: number) => {
|
||||
|
||||
@@ -24,9 +24,10 @@ export const BillingTable = mysqlTable(
|
||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||
subscription: json("subscription").$type<{
|
||||
status: "subscribed"
|
||||
coupon?: string
|
||||
seats: number
|
||||
plan: "20" | "100" | "200"
|
||||
useBalance?: boolean
|
||||
coupon?: string
|
||||
}>(),
|
||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||
subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -43,7 +43,6 @@ uuid = { version = "1.19.0", features = ["v4"] }
|
||||
tauri-plugin-decorum = "1.1.1"
|
||||
comrak = { version = "0.50", default-features = false }
|
||||
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
webkit2gtk = "=2.0.1"
|
||||
|
||||
@@ -525,4 +525,4 @@ async fn spawn_local_server(
|
||||
break Ok(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,18 +370,51 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
|
||||
}),
|
||||
)
|
||||
|
||||
const errorMessage = () => {
|
||||
const error = serverData.error
|
||||
if (!error) return "Unknown error"
|
||||
if (typeof error === "string") return error
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
const restartApp = async () => {
|
||||
await invoke("kill_sidecar").catch(() => undefined)
|
||||
await relaunch().catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
// Not using suspense as not all components are compatible with it (undefined refs)
|
||||
<Show
|
||||
when={serverData.state !== "pending" && serverData()}
|
||||
when={serverData.state === "errored"}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
||||
</div>
|
||||
<Show
|
||||
when={serverData.state !== "pending" && serverData()}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(data) => props.children(data)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(data) => props.children(data)}
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4 px-6">
|
||||
<div class="text-16-semibold">OpenCode failed to start</div>
|
||||
<div class="text-12-regular opacity-70 text-center max-w-xl">
|
||||
The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy)
|
||||
and try again.
|
||||
</div>
|
||||
<div class="w-full max-w-3xl rounded border border-border bg-background-base overflow-auto max-h-64">
|
||||
<pre class="p-3 whitespace-pre-wrap break-words text-11-regular">{errorMessage()}</pre>
|
||||
</div>
|
||||
<button class="px-3 py-2 rounded bg-primary text-primary-foreground" onClick={() => void restartApp()}>
|
||||
Restart App
|
||||
</button>
|
||||
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.34"
|
||||
version = "1.1.36"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.34/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -2,3 +2,4 @@ research
|
||||
dist
|
||||
gen
|
||||
app.log
|
||||
src/provider/models-snapshot.ts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.34",
|
||||
"version": "1.1.36",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -70,7 +70,7 @@
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.2.0",
|
||||
"@gitlab/gitlab-ai-provider": "3.3.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
|
||||
@@ -15,6 +15,16 @@ process.chdir(dir)
|
||||
import pkg from "../package.json"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
|
||||
// Fetch and generate models.dev snapshot
|
||||
const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
: await fetch(`https://models.dev/api.json`).then((x) => x.text())
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
|
||||
)
|
||||
console.log("Generated models-snapshot.ts")
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
|
||||
@@ -14,6 +14,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Global } from "@/global"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
@@ -304,8 +305,11 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
||||
const textareaKeybindings = useTextareaKeybindings()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
const dialog = useDialog()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (dialog.stack.length > 0) return
|
||||
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
evt.preventDefault()
|
||||
props.onCancel()
|
||||
@@ -384,8 +388,11 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
})
|
||||
const diffKey = Keybind.parse("ctrl+f")[0]
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
const dialog = useDialog()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (dialog.stack.length > 0) return
|
||||
|
||||
if (evt.name === "left" || evt.name == "h") {
|
||||
evt.preventDefault()
|
||||
const idx = keys.indexOf(store.selected)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createMemo, For, Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { tint, useTheme } from "../../context/theme"
|
||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
@@ -272,7 +272,15 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
|
||||
onMouseUp={() => selectTab(index())}
|
||||
>
|
||||
<text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
|
||||
<text
|
||||
fg={
|
||||
isActive()
|
||||
? selectedForeground(theme, theme.accent)
|
||||
: isAnswered()
|
||||
? theme.text
|
||||
: theme.textMuted
|
||||
}
|
||||
>
|
||||
{q.header}
|
||||
</text>
|
||||
</box>
|
||||
@@ -285,7 +293,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
backgroundColor={confirm() ? theme.accent : theme.backgroundElement}
|
||||
onMouseUp={() => selectTab(questions().length)}
|
||||
>
|
||||
<text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
|
||||
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -304,7 +312,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const active = () => i() === store.selected
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<box onMouseOver={() => moveTo(i())} onMouseUp={() => selectOption()}>
|
||||
<box
|
||||
onMouseOver={() => moveTo(i())}
|
||||
onMouseDown={() => moveTo(i())}
|
||||
onMouseUp={() => selectOption()}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
|
||||
@@ -329,7 +341,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
}}
|
||||
</For>
|
||||
<Show when={custom()}>
|
||||
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
|
||||
<box
|
||||
onMouseOver={() => moveTo(options().length)}
|
||||
onMouseDown={() => moveTo(options().length)}
|
||||
onMouseUp={() => selectOption()}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||
<text fg={other() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
|
||||
@@ -358,6 +374,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
}}
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.primary}
|
||||
|
||||
@@ -144,12 +144,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = flat().length - 1
|
||||
if (next >= flat().length) next = 0
|
||||
moveTo(next)
|
||||
moveTo(next, true)
|
||||
}
|
||||
|
||||
function moveTo(next: number, center = false) {
|
||||
setStore("selected", next)
|
||||
props.onMove?.(selected()!)
|
||||
const option = selected()
|
||||
if (option) props.onMove?.(option)
|
||||
if (!scroll) return
|
||||
const target = scroll.getChildren().find((child) => {
|
||||
return child.id === JSON.stringify(selected()?.value)
|
||||
|
||||
@@ -46,6 +46,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
||||
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
|
||||
@@ -22,10 +22,6 @@ export namespace Global {
|
||||
cache,
|
||||
config,
|
||||
state,
|
||||
// Allow overriding models.dev URL for offline deployments
|
||||
get modelsDevUrl() {
|
||||
return process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -355,7 +355,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
if (auth.type !== "oauth") return {}
|
||||
|
||||
// Filter models to only allowed Codex models for OAuth
|
||||
const allowedModels = new Set(["gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2", "gpt-5.2-codex"])
|
||||
const allowedModels = new Set([
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1-codex",
|
||||
])
|
||||
for (const modelId of Object.keys(provider.models)) {
|
||||
if (!allowedModels.has(modelId)) {
|
||||
delete provider.models[modelId]
|
||||
|
||||
@@ -338,6 +338,22 @@ export namespace Project {
|
||||
return valid
|
||||
}
|
||||
|
||||
export async function addSandbox(projectID: string, directory: string) {
|
||||
const result = await Storage.update<Info>(["project", projectID], (draft) => {
|
||||
const sandboxes = draft.sandboxes ?? []
|
||||
if (!sandboxes.includes(directory)) sandboxes.push(directory)
|
||||
draft.sandboxes = sandboxes
|
||||
draft.time.updated = Date.now()
|
||||
})
|
||||
GlobalBus.emit("event", {
|
||||
payload: {
|
||||
type: Event.Updated.type,
|
||||
properties: result,
|
||||
},
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export async function removeSandbox(projectID: string, directory: string) {
|
||||
const result = await Storage.update<Info>(["project", projectID], (draft) => {
|
||||
const sandboxes = draft.sandboxes ?? []
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Global } from "../global"
|
||||
|
||||
export async function data() {
|
||||
const path = Bun.env.MODELS_DEV_API_JSON
|
||||
if (path) {
|
||||
const file = Bun.file(path)
|
||||
if (await file.exists()) {
|
||||
return await file.text()
|
||||
}
|
||||
}
|
||||
const url = Global.Path.modelsDevUrl
|
||||
const json = await fetch(`${url}/api.json`).then((x) => x.text())
|
||||
return json
|
||||
}
|
||||
@@ -2,9 +2,13 @@ import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { data } from "./models-macro" with { type: "macro" }
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
|
||||
// Try to import bundled snapshot (generated at build time)
|
||||
// Falls back to undefined in dev mode when snapshot doesn't exist
|
||||
/* @ts-ignore */
|
||||
|
||||
export namespace ModelsDev {
|
||||
const log = Log.create({ service: "models.dev" })
|
||||
@@ -76,28 +80,35 @@ export namespace ModelsDev {
|
||||
|
||||
export type Provider = z.infer<typeof Provider>
|
||||
|
||||
export async function get() {
|
||||
refresh()
|
||||
function url() {
|
||||
return Flag.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
}
|
||||
|
||||
export const Data = lazy(async () => {
|
||||
const file = Bun.file(filepath)
|
||||
const result = await file.json().catch(() => {})
|
||||
if (result) return result as Record<string, Provider>
|
||||
if (typeof data === "function") {
|
||||
const json = await data()
|
||||
return JSON.parse(json) as Record<string, Provider>
|
||||
}
|
||||
const url = Global.Path.modelsDevUrl
|
||||
const json = await fetch(`${url}/api.json`).then((x) => x.text())
|
||||
return JSON.parse(json) as Record<string, Provider>
|
||||
if (result) return result
|
||||
// @ts-ignore
|
||||
const snapshot = await import("./models-snapshot")
|
||||
.then((m) => m.snapshot as Record<string, unknown>)
|
||||
.catch(() => undefined)
|
||||
if (snapshot) return snapshot
|
||||
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
|
||||
const json = await fetch(`${url()}/api.json`).then((x) => x.text())
|
||||
return JSON.parse(json)
|
||||
})
|
||||
|
||||
export async function get() {
|
||||
const result = await Data()
|
||||
return result as Record<string, Provider>
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return
|
||||
const file = Bun.file(filepath)
|
||||
log.info("refreshing", {
|
||||
file,
|
||||
})
|
||||
const url = Global.Path.modelsDevUrl
|
||||
const result = await fetch(`${url}/api.json`, {
|
||||
const result = await fetch(`${url()}/api.json`, {
|
||||
headers: {
|
||||
"User-Agent": Installation.USER_AGENT,
|
||||
},
|
||||
@@ -107,8 +118,19 @@ export namespace ModelsDev {
|
||||
error: e,
|
||||
})
|
||||
})
|
||||
if (result && result.ok) await Bun.write(file, await result.text())
|
||||
if (result && result.ok) {
|
||||
await Bun.write(file, await result.text())
|
||||
ModelsDev.Data.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(() => ModelsDev.refresh(), 60 * 1000 * 60).unref()
|
||||
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH) {
|
||||
ModelsDev.refresh()
|
||||
setInterval(
|
||||
async () => {
|
||||
await ModelsDev.refresh()
|
||||
},
|
||||
60 * 1000 * 60,
|
||||
).unref()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { APICallError, ModelMessage } from "ai"
|
||||
import { unique } from "remeda"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import type { JSONSchema } from "zod/v4/core"
|
||||
import type { Provider } from "./provider"
|
||||
import type { ModelsDev } from "./models"
|
||||
@@ -26,6 +26,7 @@ export namespace ProviderTransform {
|
||||
case "@ai-sdk/amazon-bedrock":
|
||||
return "bedrock"
|
||||
case "@ai-sdk/anthropic":
|
||||
case "@ai-sdk/google-vertex/anthropic":
|
||||
return "anthropic"
|
||||
case "@ai-sdk/google-vertex":
|
||||
case "@ai-sdk/google":
|
||||
@@ -186,18 +187,12 @@ export namespace ProviderTransform {
|
||||
if (shouldUseContentOptions) {
|
||||
const lastContent = msg.content[msg.content.length - 1]
|
||||
if (lastContent && typeof lastContent === "object") {
|
||||
lastContent.providerOptions = {
|
||||
...lastContent.providerOptions,
|
||||
...providerOptions,
|
||||
}
|
||||
lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
msg.providerOptions = {
|
||||
...msg.providerOptions,
|
||||
...providerOptions,
|
||||
}
|
||||
msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions)
|
||||
}
|
||||
|
||||
return msgs
|
||||
@@ -349,8 +344,12 @@ export namespace ProviderTransform {
|
||||
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
|
||||
|
||||
case "@ai-sdk/github-copilot":
|
||||
const copilotEfforts = iife(() => {
|
||||
if (id.includes("5.1-codex-max") || id.includes("5.2")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
|
||||
return WIDELY_SUPPORTED_EFFORTS
|
||||
})
|
||||
return Object.fromEntries(
|
||||
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
|
||||
copilotEfforts.map((effort) => [
|
||||
effort,
|
||||
{
|
||||
reasoningEffort: effort,
|
||||
|
||||
@@ -10,7 +10,7 @@ export namespace Question {
|
||||
|
||||
export const Option = z
|
||||
.object({
|
||||
label: z.string().max(30).describe("Display text (1-5 words, concise)"),
|
||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||
description: z.string().describe("Explanation of choice"),
|
||||
})
|
||||
.meta({
|
||||
@@ -21,7 +21,7 @@ export namespace Question {
|
||||
export const Info = z
|
||||
.object({
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().max(30).describe("Very short label (max 30 chars)"),
|
||||
header: z.string().describe("Very short label (max 30 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||
|
||||
@@ -598,7 +598,7 @@ export namespace SessionPrompt {
|
||||
agent,
|
||||
abort,
|
||||
sessionID,
|
||||
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
|
||||
system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
|
||||
messages: [
|
||||
...MessageV2.toModelMessages(sessionMessages, model),
|
||||
...(isLastStep
|
||||
|
||||
@@ -40,7 +40,7 @@ export namespace SessionSummary {
|
||||
.flatMap((x) => x.parts)
|
||||
.filter((x) => x.type === "patch")
|
||||
.flatMap((x) => x.files)
|
||||
.map((x) => path.relative(Instance.worktree, x)),
|
||||
.map((x) => path.relative(Instance.worktree, x).replaceAll("\\", "/")),
|
||||
)
|
||||
const diffs = await computeDiff({ messages: input.messages }).then((x) =>
|
||||
x.filter((x) => {
|
||||
|
||||
@@ -52,10 +52,11 @@ export namespace SystemPrompt {
|
||||
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
|
||||
}
|
||||
|
||||
export async function environment() {
|
||||
export async function environment(model: Provider.Model) {
|
||||
const project = Instance.project
|
||||
return [
|
||||
[
|
||||
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
|
||||
`Here is some useful information about the environment you are running in:`,
|
||||
`<env>`,
|
||||
` Working directory: ${Instance.directory}`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user