Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8753cda02 | ||
|
|
2685de2a33 | ||
|
|
ddb1ec294e | ||
|
|
fbd9677932 | ||
|
|
814e513db7 | ||
|
|
c600114db9 | ||
|
|
038cff4a93 | ||
|
|
741cb9c0ef | ||
|
|
38e5adc491 | ||
|
|
389a5fc017 | ||
|
|
d60393835c | ||
|
|
e6ba241045 | ||
|
|
cd2c160cf6 | ||
|
|
0f34634c52 | ||
|
|
a5a569f892 | ||
|
|
afc1825cf5 | ||
|
|
6b4c433e14 | ||
|
|
797d8425e0 | ||
|
|
260eef2d66 | ||
|
|
93f1e1afb8 | ||
|
|
6acd16dde4 | ||
|
|
6647b1e22f | ||
|
|
b8872d9d20 | ||
|
|
78940d5b7e | ||
|
|
b84a1f714b | ||
|
|
07c008fe3d | ||
|
|
dad9c917d2 | ||
|
|
2aaea71eb3 | ||
|
|
db8d83b53d | ||
|
|
963f407062 | ||
|
|
4f1ef93910 | ||
|
|
05eee679a3 | ||
|
|
154c52c4d9 | ||
|
|
680db7b9e4 | ||
|
|
7aa1dbe873 | ||
|
|
76186d19f3 | ||
|
|
7760b33956 | ||
|
|
99794c25b0 | ||
|
|
351ddeed91 | ||
|
|
dccb8875ad | ||
|
|
5f2be55e54 | ||
|
|
8b35d56a48 | ||
|
|
9be944a2d2 | ||
|
|
5138f9250e | ||
|
|
e503654252 | ||
|
|
8ebc601ea2 | ||
|
|
7a3ff5b98f | ||
|
|
3b03324578 | ||
|
|
35fff0ca70 | ||
|
|
dc8586371c | ||
|
|
41f9a58c27 | ||
|
|
01237c5325 | ||
|
|
6e7fc30f94 | ||
|
|
03733b0505 | ||
|
|
d1a4295a32 | ||
|
|
6341ed506c | ||
|
|
ed745df375 | ||
|
|
80db008419 | ||
|
|
4039670a24 | ||
|
|
d59357c89b | ||
|
|
3331b0600a | ||
|
|
c131dd0829 | ||
|
|
1c25f1fae0 | ||
|
|
2da71e0a50 | ||
|
|
87978b1c17 | ||
|
|
63d2b21b8f | ||
|
|
9a1dc1ffe4 | ||
|
|
c93e7621be | ||
|
|
e842205550 | ||
|
|
b2aa387376 | ||
|
|
34aecda47c | ||
|
|
b419b0ec55 | ||
|
|
538ac208e1 | ||
|
|
16957fd107 | ||
|
|
7f3a0b8e5c | ||
|
|
d4a2652eda | ||
|
|
7a4bfbe56d | ||
|
|
31e2c8b5e9 | ||
|
|
eab23738a8 | ||
|
|
93845db462 | ||
|
|
65bc72098b | ||
|
|
b5546dce80 | ||
|
|
3807364e73 | ||
|
|
3a1cfa6c73 | ||
|
|
a2857bba83 | ||
|
|
97a0fd1d54 | ||
|
|
87f9ebd17c | ||
|
|
e7422ee782 | ||
|
|
dc3e731afe | ||
|
|
de50e7f9b9 | ||
|
|
f3db966317 | ||
|
|
decc616c80 | ||
|
|
e0450e74fb | ||
|
|
094af4dc7d | ||
|
|
2dc14718aa | ||
|
|
b97d20f252 | ||
|
|
fcfcdd95e9 | ||
|
|
c42bd492ea | ||
|
|
840fe030ab | ||
|
|
a7c4f83ca2 | ||
|
|
ed70a07201 | ||
|
|
18a5eb205f | ||
|
|
5249f04ea0 | ||
|
|
6e3ead198e | ||
|
|
4309d439f5 | ||
|
|
2ec6a21cc0 | ||
|
|
ebf5ad25c5 | ||
|
|
8aa34ab9f3 | ||
|
|
50ef866a02 | ||
|
|
3650fefe2d | ||
|
|
22091c29f1 | ||
|
|
e7e89dc5a6 | ||
|
|
34e9392bb4 | ||
|
|
05c3bc27ff | ||
|
|
b1a6333d17 | ||
|
|
5c9d619620 | ||
|
|
dfb9caa2a9 |
@@ -2,11 +2,9 @@ name: test
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches:
|
||||||
- production
|
- dev
|
||||||
pull_request:
|
pull_request:
|
||||||
branches-ignore:
|
|
||||||
- production
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
---
|
|
||||||
description: Use this agent when you are asked to commit and push code changes to a git repository.
|
|
||||||
mode: subagent
|
|
||||||
---
|
|
||||||
|
|
||||||
You commit and push to git
|
|
||||||
|
|
||||||
Commit messages should be brief since they are used to generate release notes.
|
|
||||||
|
|
||||||
Messages should say WHY the change was made and not WHAT was changed.
|
|
||||||
@@ -10,7 +10,17 @@
|
|||||||
"options": {},
|
"options": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"mcp": {},
|
"permission": {
|
||||||
|
"bash": {
|
||||||
|
"ls foo": "ask",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"context7": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://mcp.context7.com/mcp",
|
||||||
|
},
|
||||||
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"github-triage": false,
|
"github-triage": false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
## Debugging
|
|
||||||
|
|
||||||
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
||||||
|
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
|
||||||
## SDK
|
|
||||||
|
|
||||||
To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
|
|
||||||
|
|
||||||
## Tool Calling
|
|
||||||
|
|
||||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||||
|
- the default branch in this repo is `dev`
|
||||||
|
|||||||
@@ -186,3 +186,6 @@
|
|||||||
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
|
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
|
||||||
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
|
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
|
||||||
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
|
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
|
||||||
|
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
|
||||||
|
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
|
||||||
|
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "2.0.0",
|
"@ai-sdk/anthropic": "2.0.0",
|
||||||
"@ai-sdk/openai": "2.0.2",
|
"@ai-sdk/openai": "2.0.2",
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -294,7 +294,7 @@
|
|||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"ai": "catalog:",
|
"ai": "catalog:",
|
||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
"bun-pty": "0.4.2",
|
"bun-pty": "0.4.4",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
@@ -348,7 +348,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -368,7 +368,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.88.1",
|
"@hey-api/openapi-ts": "0.88.1",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -379,7 +379,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -392,7 +392,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -430,7 +430,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -441,7 +441,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -2044,7 +2044,7 @@
|
|||||||
|
|
||||||
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
|
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
|
||||||
|
|
||||||
"bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="],
|
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
|
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
[install]
|
[install]
|
||||||
exact = true
|
exact = true
|
||||||
|
|
||||||
|
[test]
|
||||||
|
root = "./do-not-run-tests-from-root"
|
||||||
|
|
||||||
|
|||||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767026758,
|
"lastModified": 1767242400,
|
||||||
"narHash": "sha256-7fsac/f7nh/VaKJ/qm3I338+wAJa/3J57cOGpXi0Sbg=",
|
"narHash": "sha256-knFaYjeg7swqG1dljj1hOxfg39zrIy8pfGuicjm9s+o=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "346dd96ad74dc4457a9db9de4f4f57dab2e5731d",
|
"rev": "c04833a1e584401bb63c1a63ddc51a71e6aa457a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
+1
-1
@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
|
|||||||
connected = true
|
connected = true
|
||||||
break
|
break
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
await Bun.sleep(300)
|
||||||
} while (retry++ < 30)
|
} while (retry++ < 30)
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": "sha256-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA="
|
"nodeModules": "sha256-uJDhOieOdMQLORyuOWtgtjLoMnNEQPrDcyij9TX0aTw="
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"hello": "echo 'Hello World!'"
|
"hello": "echo 'Hello World!'",
|
||||||
|
"test": "echo 'do not run tests from root' && exit 1"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
|
|||||||
+8
-23
@@ -1,28 +1,13 @@
|
|||||||
# Agent Guidelines for @opencode/app
|
## Debugging
|
||||||
|
|
||||||
## Build/Test Commands
|
- To test the opencode app, use the playwrite mcp server, the app is already
|
||||||
|
running at http://localhost:3000
|
||||||
|
- NEVER try to restart the app, or the server process, EVER.
|
||||||
|
|
||||||
- **Development**: `bun run dev` (starts Vite dev server on port 3000)
|
## SolidJS
|
||||||
- **Build**: `bun run build` (production build)
|
|
||||||
- **Preview**: `bun run serve` (preview production build)
|
|
||||||
- **Validation**: Use `bun run typecheck` only - do not build or run project for validation
|
|
||||||
- **Testing**: Do not create or run automated tests
|
|
||||||
|
|
||||||
## Code Style
|
- Always prefer `createStore` over multiple `createSignal` calls
|
||||||
|
|
||||||
- **Framework**: SolidJS with TypeScript
|
## Tool Calling
|
||||||
- **Imports**: Use `@/` alias for src/ directory (e.g., `import Button from "@/ui/button"`)
|
|
||||||
- **Formatting**: Prettier configured with semicolons disabled, 120 character line width
|
|
||||||
- **Components**: Use function declarations, splitProps for component props
|
|
||||||
- **Types**: Define interfaces for component props, avoid `any` type
|
|
||||||
- **CSS**: TailwindCSS with custom CSS variables theme system
|
|
||||||
- **Naming**: PascalCase for components, camelCase for variables/functions, snake_case for file names
|
|
||||||
- **File Structure**: UI primitives in `/ui/`, higher-level components in `/components/`, pages in `/pages/`, providers in `/providers/`
|
|
||||||
|
|
||||||
## Key Dependencies
|
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||||
|
|
||||||
- SolidJS, @solidjs/router, @kobalte/core (UI primitives)
|
|
||||||
- TailwindCSS 4.x with @tailwindcss/vite
|
|
||||||
- Custom theme system with CSS variables
|
|
||||||
|
|
||||||
No special rules files found.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
+33
-27
@@ -10,11 +10,13 @@ import { Diff } from "@opencode-ai/ui/diff"
|
|||||||
import { Code } from "@opencode-ai/ui/code"
|
import { Code } from "@opencode-ai/ui/code"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||||
|
import { PermissionProvider } from "@/context/permission"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||||
import { ServerProvider, useServer } from "@/context/server"
|
import { ServerProvider, useServer } from "@/context/server"
|
||||||
import { TerminalProvider } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
|
import { FileProvider } from "@/context/file"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider } from "@/context/notification"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
@@ -66,34 +68,38 @@ export function App() {
|
|||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<LayoutProvider>
|
<Router
|
||||||
<NotificationProvider>
|
root={(props) => (
|
||||||
<Router
|
<PermissionProvider>
|
||||||
root={(props) => (
|
<LayoutProvider>
|
||||||
<CommandProvider>
|
<NotificationProvider>
|
||||||
<Layout>{props.children}</Layout>
|
<CommandProvider>
|
||||||
</CommandProvider>
|
<Layout>{props.children}</Layout>
|
||||||
|
</CommandProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Route path="/" component={Home} />
|
||||||
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
|
<Route
|
||||||
|
path="/session/:id?"
|
||||||
|
component={(p) => (
|
||||||
|
<Show when={p.params.id ?? "new"} keyed>
|
||||||
|
<TerminalProvider>
|
||||||
|
<FileProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<Session />
|
||||||
|
</PromptProvider>
|
||||||
|
</FileProvider>
|
||||||
|
</TerminalProvider>
|
||||||
|
</Show>
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
<Route path="/" component={Home} />
|
</Route>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
</Router>
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
|
||||||
<Route
|
|
||||||
path="/session/:id?"
|
|
||||||
component={(p) => (
|
|
||||||
<Show when={p.params.id ?? "new"} keyed>
|
|
||||||
<TerminalProvider>
|
|
||||||
<PromptProvider>
|
|
||||||
<Session />
|
|
||||||
</PromptProvider>
|
|
||||||
</TerminalProvider>
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
</Router>
|
|
||||||
</NotificationProvider>
|
|
||||||
</LayoutProvider>
|
|
||||||
</GlobalSyncProvider>
|
</GlobalSyncProvider>
|
||||||
</GlobalSDKProvider>
|
</GlobalSDKProvider>
|
||||||
</ServerKey>
|
</ServerKey>
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useFile } from "@/context/file"
|
||||||
|
|
||||||
export function DialogSelectFile() {
|
export function DialogSelectFile() {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
@@ -20,11 +20,13 @@ export function DialogSelectFile() {
|
|||||||
<List
|
<List
|
||||||
search={{ placeholder: "Search files", autofocus: true }}
|
search={{ placeholder: "Search files", autofocus: true }}
|
||||||
emptyMessage="No files found"
|
emptyMessage="No files found"
|
||||||
items={local.file.searchFiles}
|
items={file.searchFiles}
|
||||||
key={(x) => x}
|
key={(x) => x}
|
||||||
onSelect={(path) => {
|
onSelect={(path) => {
|
||||||
if (path) {
|
if (path) {
|
||||||
tabs().open("file://" + path)
|
const value = file.tab(path)
|
||||||
|
tabs().open(value)
|
||||||
|
file.load(path)
|
||||||
}
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, createMemo, Show } from "solid-js"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
|
import { Component, createMemo, createSignal, JSX, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
@@ -9,9 +10,12 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
import { DialogManageModels } from "./dialog-manage-models"
|
import { DialogManageModels } from "./dialog-manage-models"
|
||||||
|
|
||||||
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
const ModelList: Component<{
|
||||||
|
provider?: string
|
||||||
|
class?: string
|
||||||
|
onSelect: () => void
|
||||||
|
}> = (props) => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
const models = createMemo(() =>
|
const models = createMemo(() =>
|
||||||
local.model
|
local.model
|
||||||
@@ -20,6 +24,70 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
|||||||
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
|
||||||
|
search={{ placeholder: "Search models", autofocus: true }}
|
||||||
|
emptyMessage="No model results"
|
||||||
|
key={(x) => `${x.provider.id}:${x.id}`}
|
||||||
|
items={models}
|
||||||
|
current={local.model.current()}
|
||||||
|
filterKeys={["provider.name", "name", "id"]}
|
||||||
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
|
groupBy={(x) => x.provider.name}
|
||||||
|
sortGroupsBy={(a, b) => {
|
||||||
|
if (a.category === "Recent" && b.category !== "Recent") return -1
|
||||||
|
if (b.category === "Recent" && a.category !== "Recent") return 1
|
||||||
|
const aProvider = a.items[0].provider.id
|
||||||
|
const bProvider = b.items[0].provider.id
|
||||||
|
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
||||||
|
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
||||||
|
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
||||||
|
}}
|
||||||
|
onSelect={(x) => {
|
||||||
|
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||||
|
recent: true,
|
||||||
|
})
|
||||||
|
props.onSelect()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(i) => (
|
||||||
|
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||||
|
<span class="truncate">{i.name}</span>
|
||||||
|
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
||||||
|
<Tag>Free</Tag>
|
||||||
|
</Show>
|
||||||
|
<Show when={i.latest}>
|
||||||
|
<Tag>Latest</Tag>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelSelectorPopover: Component<{
|
||||||
|
provider?: string
|
||||||
|
children: JSX.Element
|
||||||
|
}> = (props) => {
|
||||||
|
const [open, setOpen] = createSignal(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
||||||
|
<Kobalte.Trigger as="div">{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">
|
||||||
|
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
|
||||||
|
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
|
||||||
|
</Kobalte.Content>
|
||||||
|
</Kobalte.Portal>
|
||||||
|
</Kobalte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title="Select model"
|
title="Select model"
|
||||||
@@ -34,43 +102,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<List
|
<ModelList provider={props.provider} onSelect={() => dialog.close()} />
|
||||||
search={{ placeholder: "Search models", autofocus: true }}
|
|
||||||
emptyMessage="No model results"
|
|
||||||
key={(x) => `${x.provider.id}:${x.id}`}
|
|
||||||
items={models}
|
|
||||||
current={local.model.current()}
|
|
||||||
filterKeys={["provider.name", "name", "id"]}
|
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
|
||||||
groupBy={(x) => x.provider.name}
|
|
||||||
sortGroupsBy={(a, b) => {
|
|
||||||
if (a.category === "Recent" && b.category !== "Recent") return -1
|
|
||||||
if (b.category === "Recent" && a.category !== "Recent") return 1
|
|
||||||
const aProvider = a.items[0].provider.id
|
|
||||||
const bProvider = b.items[0].provider.id
|
|
||||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
|
||||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
|
||||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
|
||||||
}}
|
|
||||||
onSelect={(x) => {
|
|
||||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
|
||||||
recent: true,
|
|
||||||
})
|
|
||||||
dialog.close()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(i) => (
|
|
||||||
<div class="w-full flex items-center gap-x-3">
|
|
||||||
<span>{i.name}</span>
|
|
||||||
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
|
||||||
<Tag>Free</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.latest}>
|
|
||||||
<Tag>Latest</Tag>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
import { useGlobalSync } from "@/context/global-sync"
|
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
|
||||||
import { Popover } from "@opencode-ai/ui/popover"
|
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import { useCommand } from "@/context/command"
|
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
|
||||||
import { A, useParams } from "@solidjs/router"
|
|
||||||
import { createMemo, createResource, Show } from "solid-js"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
|
||||||
|
|
||||||
export function Header(props: {
|
|
||||||
navigateToProject: (directory: string) => void
|
|
||||||
navigateToSession: (session: Session | undefined) => void
|
|
||||||
onMobileMenuToggle?: () => void
|
|
||||||
}) {
|
|
||||||
const globalSync = useGlobalSync()
|
|
||||||
const globalSDK = useGlobalSDK()
|
|
||||||
const layout = useLayout()
|
|
||||||
const params = useParams()
|
|
||||||
const command = useCommand()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
|
|
||||||
onClick={props.onMobileMenuToggle}
|
|
||||||
>
|
|
||||||
<Icon name="menu" size="small" />
|
|
||||||
</button>
|
|
||||||
<A
|
|
||||||
href="/"
|
|
||||||
classList={{
|
|
||||||
"hidden xl:flex": true,
|
|
||||||
"w-12 shrink-0 px-4 py-3.5": true,
|
|
||||||
"items-center justify-start self-stretch": true,
|
|
||||||
"border-r border-border-weak-base": true,
|
|
||||||
}}
|
|
||||||
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
|
||||||
data-tauri-drag-region
|
|
||||||
>
|
|
||||||
<Mark class="shrink-0" />
|
|
||||||
</A>
|
|
||||||
<div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
|
|
||||||
<Show when={layout.projects.list().length > 0 && params.dir}>
|
|
||||||
{(directory) => {
|
|
||||||
const currentDirectory = createMemo(() => base64Decode(directory()))
|
|
||||||
const store = createMemo(() => globalSync.child(currentDirectory())[0])
|
|
||||||
const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
|
|
||||||
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
|
||||||
const shareEnabled = createMemo(() => store().config.share !== "disabled")
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<div class="hidden xl:flex items-center gap-2">
|
|
||||||
<Select
|
|
||||||
options={layout.projects.list().map((project) => project.worktree)}
|
|
||||||
current={currentDirectory()}
|
|
||||||
label={(x) => getFilename(x)}
|
|
||||||
onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
|
|
||||||
class="text-14-regular text-text-base"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
{/* @ts-ignore */}
|
|
||||||
{(i) => (
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon name="folder" size="small" />
|
|
||||||
<div class="text-text-strong">{getFilename(i)}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Select>
|
|
||||||
<div class="text-text-weaker">/</div>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
options={sessions()}
|
|
||||||
current={currentSession()}
|
|
||||||
placeholder="New session"
|
|
||||||
label={(x) => x.title}
|
|
||||||
value={(x) => x.id}
|
|
||||||
onSelect={props.navigateToSession}
|
|
||||||
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Show when={currentSession()}>
|
|
||||||
<Tooltip
|
|
||||||
class="hidden xl:block"
|
|
||||||
value={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>New session</span>
|
|
||||||
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
|
|
||||||
New session
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<Show when={currentSession()?.summary?.files}>
|
|
||||||
<Tooltip
|
|
||||||
class="hidden md:block shrink-0"
|
|
||||||
value={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>Toggle review</span>
|
|
||||||
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
|
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
|
||||||
<Icon
|
|
||||||
name={layout.review.opened() ? "layout-right" : "layout-left"}
|
|
||||||
size="small"
|
|
||||||
class="group-hover/review-toggle:hidden"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
|
|
||||||
size="small"
|
|
||||||
class="hidden group-hover/review-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
|
|
||||||
size="small"
|
|
||||||
class="hidden group-active/review-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Show>
|
|
||||||
<Tooltip
|
|
||||||
class="hidden md:block shrink-0"
|
|
||||||
value={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>Toggle terminal</span>
|
|
||||||
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
|
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
|
||||||
class="group-hover/terminal-toggle:hidden"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name="layout-bottom-partial"
|
|
||||||
class="hidden group-hover/terminal-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
|
||||||
class="hidden group-active/terminal-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Show when={shareEnabled() && currentSession()}>
|
|
||||||
<Popover
|
|
||||||
title="Share session"
|
|
||||||
trigger={
|
|
||||||
<Tooltip class="shrink-0" value="Share session">
|
|
||||||
<IconButton icon="share" variant="ghost" class="" />
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{iife(() => {
|
|
||||||
const [url] = createResource(
|
|
||||||
() => currentSession(),
|
|
||||||
async (session) => {
|
|
||||||
if (!session) return
|
|
||||||
let shareURL = session.share?.url
|
|
||||||
if (!shareURL) {
|
|
||||||
shareURL = await globalSDK.client.session
|
|
||||||
.share({ sessionID: session.id, directory: currentDirectory() })
|
|
||||||
.then((r) => r.data?.share?.url)
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to share session", e)
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return shareURL
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<Show when={url()}>
|
|
||||||
{(url) => <TextField value={url()} readOnly copyable class="w-72" />}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Popover>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,16 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
|
import { useFile, type FileSelection } from "@/context/file"
|
||||||
|
import {
|
||||||
|
ContentPart,
|
||||||
|
DEFAULT_PROMPT,
|
||||||
|
isPromptEqual,
|
||||||
|
Prompt,
|
||||||
|
usePrompt,
|
||||||
|
ImageAttachmentPart,
|
||||||
|
AgentPart,
|
||||||
|
} from "@/context/prompt"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
@@ -11,18 +20,19 @@ import { useSync } from "@/context/sync"
|
|||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
|
|
||||||
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||||
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||||
@@ -74,12 +84,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
const files = useFile()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
const permission = usePermission()
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
let fileInputRef!: HTMLInputElement
|
let fileInputRef!: HTMLInputElement
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
@@ -116,6 +128,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
|
const activeFile = createMemo(() => {
|
||||||
|
const tab = tabs().active()
|
||||||
|
if (!tab) return
|
||||||
|
return files.pathFromTab(tab)
|
||||||
|
})
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const status = createMemo(
|
const status = createMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -126,7 +143,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const working = createMemo(() => status()?.type !== "idle")
|
const working = createMemo(() => status()?.type !== "idle")
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
popover: "file" | "slash" | null
|
popover: "at" | "slash" | null
|
||||||
historyIndex: number
|
historyIndex: number
|
||||||
savedPrompt: Prompt | null
|
savedPrompt: Prompt | null
|
||||||
placeholder: number
|
placeholder: number
|
||||||
@@ -169,6 +186,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
prompt.map((part) => {
|
prompt.map((part) => {
|
||||||
if (part.type === "text") return { ...part }
|
if (part.type === "text") return { ...part }
|
||||||
if (part.type === "image") return { ...part }
|
if (part.type === "image") return { ...part }
|
||||||
|
if (part.type === "agent") return { ...part }
|
||||||
return {
|
return {
|
||||||
...part,
|
...part,
|
||||||
selection: part.selection ? { ...part.selection } : undefined,
|
selection: part.selection ? { ...part.selection } : undefined,
|
||||||
@@ -292,10 +310,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setStore("dragging", false)
|
setStore("dragging", false)
|
||||||
|
|
||||||
const files = event.dataTransfer?.files
|
const dropped = event.dataTransfer?.files
|
||||||
if (!files) return
|
if (!dropped) return
|
||||||
|
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(dropped)) {
|
||||||
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
|
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
|
||||||
await addImageAttachment(file)
|
await addImageAttachment(file)
|
||||||
}
|
}
|
||||||
@@ -319,15 +337,43 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (!isFocused()) setStore("popover", null)
|
if (!isFocused()) setStore("popover", null)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleFileSelect = (path: string | undefined) => {
|
type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string }
|
||||||
if (!path) return
|
|
||||||
addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
|
const agentList = createMemo(() =>
|
||||||
|
sync.data.agent
|
||||||
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
|
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAtSelect = (option: AtOption | undefined) => {
|
||||||
|
if (!option) return
|
||||||
|
if (option.type === "agent") {
|
||||||
|
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
|
||||||
|
} else {
|
||||||
|
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
|
const atKey = (x: AtOption | undefined) => {
|
||||||
items: local.file.searchFilesAndDirectories,
|
if (!x) return ""
|
||||||
key: (x) => x,
|
return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
|
||||||
onSelect: handleFileSelect,
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
flat: atFlat,
|
||||||
|
active: atActive,
|
||||||
|
onInput: atOnInput,
|
||||||
|
onKeyDown: atOnKeyDown,
|
||||||
|
} = useFilteredList<AtOption>({
|
||||||
|
items: async (query) => {
|
||||||
|
const agents = agentList()
|
||||||
|
const paths = await files.searchFilesAndDirectories(query)
|
||||||
|
const fileOptions: AtOption[] = paths.map((path) => ({ type: "file", path, display: path }))
|
||||||
|
return [...agents, ...fileOptions]
|
||||||
|
},
|
||||||
|
key: atKey,
|
||||||
|
filterKeys: ["display"],
|
||||||
|
onSelect: handleAtSelect,
|
||||||
})
|
})
|
||||||
|
|
||||||
const slashCommands = createMemo<SlashCommand[]>(() => {
|
const slashCommands = createMemo<SlashCommand[]>(() => {
|
||||||
@@ -413,6 +459,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (node.nodeType !== Node.ELEMENT_NODE) return false
|
if (node.nodeType !== Node.ELEMENT_NODE) return false
|
||||||
const el = node as HTMLElement
|
const el = node as HTMLElement
|
||||||
if (el.dataset.type === "file") return true
|
if (el.dataset.type === "file") return true
|
||||||
|
if (el.dataset.type === "agent") return true
|
||||||
return el.tagName === "BR"
|
return el.tagName === "BR"
|
||||||
})
|
})
|
||||||
if (normalized && isPromptEqual(currentParts, domParts)) return
|
if (normalized && isPromptEqual(currentParts, domParts)) return
|
||||||
@@ -436,6 +483,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
pill.style.userSelect = "text"
|
pill.style.userSelect = "text"
|
||||||
pill.style.cursor = "default"
|
pill.style.cursor = "default"
|
||||||
editorRef.appendChild(pill)
|
editorRef.appendChild(pill)
|
||||||
|
} else if (part.type === "agent") {
|
||||||
|
const pill = document.createElement("span")
|
||||||
|
pill.textContent = part.content
|
||||||
|
pill.setAttribute("data-type", "agent")
|
||||||
|
pill.setAttribute("data-name", part.name)
|
||||||
|
pill.setAttribute("contenteditable", "false")
|
||||||
|
pill.style.userSelect = "text"
|
||||||
|
pill.style.cursor = "default"
|
||||||
|
editorRef.appendChild(pill)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -471,6 +527,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
position += content.length
|
position += content.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pushAgent = (agent: HTMLElement) => {
|
||||||
|
const content = agent.textContent ?? ""
|
||||||
|
parts.push({
|
||||||
|
type: "agent",
|
||||||
|
name: agent.dataset.name!,
|
||||||
|
content,
|
||||||
|
start: position,
|
||||||
|
end: position + content.length,
|
||||||
|
})
|
||||||
|
position += content.length
|
||||||
|
}
|
||||||
|
|
||||||
const visit = (node: Node) => {
|
const visit = (node: Node) => {
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
buffer += node.textContent ?? ""
|
buffer += node.textContent ?? ""
|
||||||
@@ -484,6 +552,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
pushFile(el)
|
pushFile(el)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (el.dataset.type === "agent") {
|
||||||
|
flushText()
|
||||||
|
pushAgent(el)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (el.tagName === "BR") {
|
if (el.tagName === "BR") {
|
||||||
buffer += "\n"
|
buffer += "\n"
|
||||||
return
|
return
|
||||||
@@ -537,8 +610,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const slashMatch = rawText.match(/^\/(\S*)$/)
|
const slashMatch = rawText.match(/^\/(\S*)$/)
|
||||||
|
|
||||||
if (atMatch) {
|
if (atMatch) {
|
||||||
onInput(atMatch[1])
|
atOnInput(atMatch[1])
|
||||||
setStore("popover", "file")
|
setStore("popover", "at")
|
||||||
} else if (slashMatch) {
|
} else if (slashMatch) {
|
||||||
slashOnInput(slashMatch[1])
|
slashOnInput(slashMatch[1])
|
||||||
setStore("popover", "slash")
|
setStore("popover", "slash")
|
||||||
@@ -558,6 +631,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
queueScroll()
|
queueScroll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => {
|
||||||
|
let remaining = offset
|
||||||
|
const nodes = Array.from(editorRef.childNodes)
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const length = getNodeLength(node)
|
||||||
|
const isText = node.nodeType === Node.TEXT_NODE
|
||||||
|
const isPill =
|
||||||
|
node.nodeType === Node.ELEMENT_NODE &&
|
||||||
|
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
||||||
|
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||||
|
|
||||||
|
if (isText && remaining <= length) {
|
||||||
|
if (edge === "start") range.setStart(node, remaining)
|
||||||
|
if (edge === "end") range.setEnd(node, remaining)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isPill || isBreak) && remaining <= length) {
|
||||||
|
if (edge === "start" && remaining === 0) range.setStartBefore(node)
|
||||||
|
if (edge === "start" && remaining > 0) range.setStartAfter(node)
|
||||||
|
if (edge === "end" && remaining === 0) range.setEndBefore(node)
|
||||||
|
if (edge === "end" && remaining > 0) range.setEndAfter(node)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining -= length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addPart = (part: ContentPart) => {
|
const addPart = (part: ContentPart) => {
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (!selection || selection.rangeCount === 0) return
|
if (!selection || selection.rangeCount === 0) return
|
||||||
@@ -580,38 +683,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const gap = document.createTextNode(" ")
|
const gap = document.createTextNode(" ")
|
||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
|
|
||||||
const setEdge = (edge: "start" | "end", offset: number) => {
|
if (atMatch) {
|
||||||
let remaining = offset
|
const start = atMatch.index ?? cursorPosition - atMatch[0].length
|
||||||
const nodes = Array.from(editorRef.childNodes)
|
setRangeEdge(range, "start", start)
|
||||||
|
setRangeEdge(range, "end", cursorPosition)
|
||||||
for (const node of nodes) {
|
|
||||||
const length = getNodeLength(node)
|
|
||||||
const isText = node.nodeType === Node.TEXT_NODE
|
|
||||||
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
|
||||||
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
|
||||||
|
|
||||||
if (isText && remaining <= length) {
|
|
||||||
if (edge === "start") range.setStart(node, remaining)
|
|
||||||
if (edge === "end") range.setEnd(node, remaining)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((isFile || isBreak) && remaining <= length) {
|
|
||||||
if (edge === "start" && remaining === 0) range.setStartBefore(node)
|
|
||||||
if (edge === "start" && remaining > 0) range.setStartAfter(node)
|
|
||||||
if (edge === "end" && remaining === 0) range.setEndBefore(node)
|
|
||||||
if (edge === "end" && remaining > 0) range.setEndAfter(node)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining -= length
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
range.deleteContents()
|
||||||
|
range.insertNode(gap)
|
||||||
|
range.insertNode(pill)
|
||||||
|
range.setStartAfter(gap)
|
||||||
|
range.collapse(true)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
} else if (part.type === "agent") {
|
||||||
|
const pill = document.createElement("span")
|
||||||
|
pill.textContent = part.content
|
||||||
|
pill.setAttribute("data-type", "agent")
|
||||||
|
pill.setAttribute("data-name", part.name)
|
||||||
|
pill.setAttribute("contenteditable", "false")
|
||||||
|
pill.style.userSelect = "text"
|
||||||
|
pill.style.cursor = "default"
|
||||||
|
|
||||||
|
const gap = document.createTextNode(" ")
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
|
||||||
if (atMatch) {
|
if (atMatch) {
|
||||||
const start = atMatch.index ?? cursorPosition - atMatch[0].length
|
const start = atMatch.index ?? cursorPosition - atMatch[0].length
|
||||||
setEdge("start", start)
|
setRangeEdge(range, "start", start)
|
||||||
setEdge("end", cursorPosition)
|
setRangeEdge(range, "end", cursorPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
range.deleteContents()
|
range.deleteContents()
|
||||||
@@ -832,8 +932,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||||
if (store.popover === "file") {
|
if (store.popover === "at") {
|
||||||
onKeyDown(event)
|
atOnKeyDown(event)
|
||||||
} else {
|
} else {
|
||||||
slashOnKeyDown(event)
|
slashOnKeyDown(event)
|
||||||
}
|
}
|
||||||
@@ -1073,11 +1173,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (!existing) return
|
if (!existing) return
|
||||||
|
|
||||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||||
const attachments = currentPrompt.filter(
|
const fileAttachments = currentPrompt.filter(
|
||||||
(part) => part.type === "file",
|
(part) => part.type === "file",
|
||||||
) as import("@/context/prompt").FileAttachmentPart[]
|
) as import("@/context/prompt").FileAttachmentPart[]
|
||||||
|
const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
|
||||||
|
|
||||||
const fileAttachmentParts = attachments.map((attachment) => {
|
const fileAttachmentParts = fileAttachments.map((attachment) => {
|
||||||
const absolute = toAbsolutePath(attachment.path)
|
const absolute = toAbsolutePath(attachment.path)
|
||||||
const query = attachment.selection
|
const query = attachment.selection
|
||||||
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
||||||
@@ -1100,6 +1201,52 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const agentAttachmentParts = agentAttachments.map((attachment) => ({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "agent" as const,
|
||||||
|
name: attachment.name,
|
||||||
|
source: {
|
||||||
|
value: attachment.content,
|
||||||
|
start: attachment.start,
|
||||||
|
end: attachment.end,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
|
||||||
|
|
||||||
|
const contextFileParts: Array<{
|
||||||
|
id: string
|
||||||
|
type: "file"
|
||||||
|
mime: string
|
||||||
|
url: string
|
||||||
|
filename?: string
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
const addContextFile = (path: string, selection?: FileSelection) => {
|
||||||
|
const absolute = toAbsolutePath(path)
|
||||||
|
const query = selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||||
|
const url = `file://${absolute}${query}`
|
||||||
|
if (usedUrls.has(url)) return
|
||||||
|
usedUrls.add(url)
|
||||||
|
contextFileParts.push({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "file",
|
||||||
|
mime: "text/plain",
|
||||||
|
url,
|
||||||
|
filename: getFilename(path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePath = activeFile()
|
||||||
|
if (activePath && prompt.context.activeTab()) {
|
||||||
|
addContextFile(activePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of prompt.context.items()) {
|
||||||
|
if (item.type !== "file") continue
|
||||||
|
addContextFile(item.path, item.selection)
|
||||||
|
}
|
||||||
|
|
||||||
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
|
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
@@ -1109,7 +1256,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const isShellMode = store.mode === "shell"
|
const isShellMode = store.mode === "shell"
|
||||||
tabs().setActive(undefined)
|
|
||||||
editorRef.innerHTML = ""
|
editorRef.innerHTML = ""
|
||||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||||
setStore("imageAttachments", [])
|
setStore("imageAttachments", [])
|
||||||
@@ -1169,7 +1315,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text,
|
text,
|
||||||
}
|
}
|
||||||
const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
|
const requestParts = [
|
||||||
|
textPart,
|
||||||
|
...fileAttachmentParts,
|
||||||
|
...contextFileParts,
|
||||||
|
...agentAttachmentParts,
|
||||||
|
...imageAttachmentParts,
|
||||||
|
]
|
||||||
const optimisticParts = requestParts.map((part) => ({
|
const optimisticParts = requestParts.map((part) => ({
|
||||||
...part,
|
...part,
|
||||||
sessionID: existing.id,
|
sessionID: existing.id,
|
||||||
@@ -1207,24 +1359,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.popover === "file"}>
|
<Match when={store.popover === "at"}>
|
||||||
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}>
|
<Show
|
||||||
<For each={flat()}>
|
when={atFlat().length > 0}
|
||||||
{(i) => (
|
fallback={<div class="text-text-weak px-2 py-1">No matching results</div>}
|
||||||
|
>
|
||||||
|
<For each={atFlat().slice(0, 10)}>
|
||||||
|
{(item) => (
|
||||||
<button
|
<button
|
||||||
classList={{
|
classList={{
|
||||||
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
|
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
|
||||||
"bg-surface-raised-base-hover": active() === i,
|
"bg-surface-raised-base-hover": atActive() === atKey(item),
|
||||||
}}
|
}}
|
||||||
onClick={() => handleFileSelect(i)}
|
onClick={() => handleAtSelect(item)}
|
||||||
>
|
>
|
||||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
<Show
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
when={item.type === "agent"}
|
||||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span>
|
fallback={
|
||||||
<Show when={!i.endsWith("/")}>
|
<>
|
||||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
<FileIcon
|
||||||
</Show>
|
node={{ path: (item as { type: "file"; path: string }).path, type: "file" }}
|
||||||
</div>
|
class="shrink-0 size-4"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
|
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
|
||||||
|
{getDirectory((item as { type: "file"; path: string }).path)}
|
||||||
|
</span>
|
||||||
|
<Show when={!(item as { type: "file"; path: string }).path.endsWith("/")}>
|
||||||
|
<span class="text-text-strong whitespace-nowrap">
|
||||||
|
{getFilename((item as { type: "file"; path: string }).path)}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||||
|
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
||||||
|
@{(item as { type: "agent"; name: string }).name}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -1271,6 +1445,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
classList={{
|
classList={{
|
||||||
|
"group/prompt-input": true,
|
||||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
||||||
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
|
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
|
||||||
"border-icon-info-active border-dashed": store.dragging,
|
"border-icon-info-active border-dashed": store.dragging,
|
||||||
@@ -1285,6 +1460,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={false && (prompt.context.items().length > 0 || !!activeFile())}>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 px-3 pt-3">
|
||||||
|
<Show when={prompt.context.activeTab() ? activeFile() : undefined}>
|
||||||
|
{(path) => (
|
||||||
|
<div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full">
|
||||||
|
<FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-4" />
|
||||||
|
<div class="flex items-center text-12-regular min-w-0">
|
||||||
|
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span>
|
||||||
|
<span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span>
|
||||||
|
<span class="text-text-weak whitespace-nowrap ml-1">active</span>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
icon="close"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-6 w-6"
|
||||||
|
onClick={() => prompt.context.removeActive()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={!prompt.context.activeTab() && !!activeFile()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-weak hover:bg-surface-raised-base-hover"
|
||||||
|
onClick={() => prompt.context.addActive()}
|
||||||
|
>
|
||||||
|
<Icon name="plus-small" size="small" />
|
||||||
|
<span>Include active file</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<For each={prompt.context.items()}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full">
|
||||||
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||||
|
<div class="flex items-center text-12-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>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
icon="close"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-6 w-6"
|
||||||
|
onClick={() => prompt.context.remove(item.key)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show when={store.imageAttachments.length > 0}>
|
<Show when={store.imageAttachments.length > 0}>
|
||||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||||
<For each={store.imageAttachments}>
|
<For each={store.imageAttachments}>
|
||||||
@@ -1332,7 +1567,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
classList={{
|
classList={{
|
||||||
"select-text": true,
|
"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 px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||||
"[&_[data-type=file]]:text-icon-info-active": true,
|
"[&_[data-type=file]]:text-syntax-property": true,
|
||||||
|
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||||
"font-mono!": store.mode === "shell",
|
"font-mono!": store.mode === "shell",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1343,12 +1579,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
|
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="absolute top-4.5 right-4">
|
|
||||||
<SessionContextUsage />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="relative p-3 flex items-center justify-between">
|
<div class="relative p-3 flex items-center justify-between">
|
||||||
<div class="flex items-center justify-start gap-1">
|
<div class="flex items-center justify-start gap-0.5">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.mode === "shell"}>
|
<Match when={store.mode === "shell"}>
|
||||||
<div class="flex items-center gap-2 px-2 h-6">
|
<div class="flex items-center gap-2 px-2 h-6">
|
||||||
@@ -1358,15 +1591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.mode === "normal"}>
|
<Match when={store.mode === "normal"}>
|
||||||
<Tooltip
|
<TooltipKeybind placement="top" title="Cycle agent" keybind={command.keybind("agent.cycle")}>
|
||||||
placement="top"
|
|
||||||
value={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>Cycle agent</span>
|
|
||||||
<span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Select
|
<Select
|
||||||
options={local.agent.list().map((agent) => agent.name)}
|
options={local.agent.list().map((agent) => agent.name)}
|
||||||
current={local.agent.current()?.name ?? ""}
|
current={local.agent.current()?.name ?? ""}
|
||||||
@@ -1374,54 +1599,69 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
class="capitalize"
|
class="capitalize"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</TooltipKeybind>
|
||||||
<Tooltip
|
<Show
|
||||||
placement="top"
|
when={providers.paid().length > 0}
|
||||||
value={
|
fallback={
|
||||||
<div class="flex flex-col gap-1">
|
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
|
||||||
<div class="flex items-center gap-2">
|
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
|
||||||
<span>Choose model</span>
|
{local.model.current()?.name ?? "Select model"}
|
||||||
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
|
<Icon name="chevron-down" size="small" />
|
||||||
</div>
|
</Button>
|
||||||
<Show when={local.model.current()?.provider.name}>
|
</TooltipKeybind>
|
||||||
<span class="text-text-weak">{local.model.current()?.provider.name}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<ModelSelectorPopover>
|
||||||
as="div"
|
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
|
||||||
variant="ghost"
|
<Button as="div" variant="ghost">
|
||||||
onClick={() =>
|
{local.model.current()?.name ?? "Select model"}
|
||||||
dialog.show(() =>
|
<Icon name="chevron-down" size="small" />
|
||||||
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
|
</Button>
|
||||||
)
|
</TooltipKeybind>
|
||||||
}
|
</ModelSelectorPopover>
|
||||||
>
|
</Show>
|
||||||
{local.model.current()?.name ?? "Select model"}
|
|
||||||
<Icon name="chevron-down" size="small" />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Show when={local.model.variant.list().length > 0}>
|
<Show when={local.model.variant.list().length > 0}>
|
||||||
<Tooltip placement="top" value="Cycle effort level">
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
title="Thinking effort"
|
||||||
|
keybind={command.keybind("model.variant.cycle")}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
class="text-text-base _hidden group-hover/prompt-input:inline-block"
|
||||||
onClick={() => local.model.variant.cycle()}
|
onClick={() => local.model.variant.cycle()}
|
||||||
|
>
|
||||||
|
<span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
<Show when={permission.permissionsEnabled() && params.id}>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
title="Auto-accept edits"
|
||||||
|
keybind={command.keybind("permissions.autoaccept")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||||
classList={{
|
classList={{
|
||||||
"text-icon-warning": !!local.model.variant.current(),
|
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||||
|
"text-text-base": !permission.isAutoAccepting(params.id!),
|
||||||
|
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="brain" size="small" />
|
<Icon
|
||||||
<Show when={local.model.variant.current()}>
|
name="chevron-double-right"
|
||||||
<span class="text-12-regular">{local.model.variant.current()}</span>
|
size="small"
|
||||||
</Show>
|
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 absolute right-2 bottom-2">
|
<div class="flex items-center gap-3 absolute right-2 bottom-2">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -1433,17 +1673,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
e.currentTarget.value = ""
|
e.currentTarget.value = ""
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Show when={store.mode === "normal"}>
|
<div class="flex items-center gap-2">
|
||||||
<Tooltip placement="top" value="Attach image">
|
<SessionContextUsage />
|
||||||
<IconButton
|
<Show when={store.mode === "normal"}>
|
||||||
type="button"
|
<Tooltip placement="top" value="Attach image">
|
||||||
icon="photo"
|
<Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}>
|
||||||
variant="ghost"
|
<Icon name="photo" class="size-4.5" />
|
||||||
class="h-10 w-8"
|
</Button>
|
||||||
onClick={() => fileInputRef.click()}
|
</Tooltip>
|
||||||
/>
|
</Show>
|
||||||
</Tooltip>
|
</div>
|
||||||
</Show>
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement="top"
|
placement="top"
|
||||||
inactive={!prompt.dirty() && !working()}
|
inactive={!prompt.dirty() && !working()}
|
||||||
@@ -1469,7 +1708,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
|
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="h-10 w-8"
|
class="h-6 w-4.5"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -1527,7 +1766,9 @@ function setCursorPosition(parent: HTMLElement, position: number) {
|
|||||||
while (node) {
|
while (node) {
|
||||||
const length = getNodeLength(node)
|
const length = getNodeLength(node)
|
||||||
const isText = node.nodeType === Node.TEXT_NODE
|
const isText = node.nodeType === Node.TEXT_NODE
|
||||||
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
|
const isPill =
|
||||||
|
node.nodeType === Node.ELEMENT_NODE &&
|
||||||
|
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
||||||
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||||
|
|
||||||
if (isText && remaining <= length) {
|
if (isText && remaining <= length) {
|
||||||
@@ -1540,13 +1781,13 @@ function setCursorPosition(parent: HTMLElement, position: number) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((isFile || isBreak) && remaining <= length) {
|
if ((isPill || isBreak) && remaining <= length) {
|
||||||
const range = document.createRange()
|
const range = document.createRange()
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
range.setStartBefore(node)
|
range.setStartBefore(node)
|
||||||
}
|
}
|
||||||
if (remaining > 0 && isFile) {
|
if (remaining > 0 && isPill) {
|
||||||
range.setStartAfter(node)
|
range.setStartAfter(node)
|
||||||
}
|
}
|
||||||
if (remaining > 0 && isBreak) {
|
if (remaining > 0 && isBreak) {
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { createMemo, Show } from "solid-js"
|
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
import { useSync } from "@/context/sync"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
|
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
export function SessionContextUsage() {
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
|
||||||
|
interface SessionContextUsageProps {
|
||||||
|
variant?: "button" | "indicator"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const layout = useLayout()
|
||||||
|
|
||||||
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
@@ -19,7 +31,11 @@ export function SessionContextUsage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const context = createMemo(() => {
|
const context = createMemo(() => {
|
||||||
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
|
const last = messages().findLast((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
|
||||||
|
}) as AssistantMessage
|
||||||
if (!last) return
|
if (!last) return
|
||||||
const total =
|
const total =
|
||||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||||
@@ -30,28 +46,57 @@ export function SessionContextUsage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
const openContext = () => {
|
||||||
<Show when={context?.()}>
|
if (!params.id) return
|
||||||
{(ctx) => (
|
layout.review.open()
|
||||||
<Tooltip
|
tabs().open("context")
|
||||||
value={
|
tabs().setActive("context")
|
||||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
}
|
||||||
<span class="opacity-70 text-right">Tokens</span>
|
|
||||||
<span class="text-left">{ctx().tokens}</span>
|
const circle = () => (
|
||||||
<span class="opacity-70 text-right">Usage</span>
|
<div class="p-1">
|
||||||
<span class="text-left">{ctx().percentage ?? 0}%</span>
|
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
|
||||||
<span class="opacity-70 text-right">Cost</span>
|
</div>
|
||||||
<span class="text-left">{cost()}</span>
|
)
|
||||||
|
|
||||||
|
const tooltipValue = () => (
|
||||||
|
<div>
|
||||||
|
<Show when={context()}>
|
||||||
|
{(ctx) => (
|
||||||
|
<>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-text-invert-strong">{ctx().tokens}</span>
|
||||||
|
<span class="text-text-invert-base">Tokens</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
<div class="flex items-center gap-2">
|
||||||
placement="top"
|
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
|
||||||
>
|
<span class="text-text-invert-base">Usage</span>
|
||||||
<div class="flex items-center gap-1.5">
|
</div>
|
||||||
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
|
</>
|
||||||
{/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
|
)}
|
||||||
</div>
|
</Show>
|
||||||
</Tooltip>
|
<div class="flex items-center gap-2">
|
||||||
)}
|
<span class="text-text-invert-strong">{cost()}</span>
|
||||||
|
<span class="text-text-invert-base">Cost</span>
|
||||||
|
</div>
|
||||||
|
<Show when={variant() === "button"}>
|
||||||
|
<div class="text-11-regular text-text-invert-base mt-1">Click to view context</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={params.id}>
|
||||||
|
<Tooltip value={tooltipValue()} placement="top">
|
||||||
|
<Switch>
|
||||||
|
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<Button type="button" variant="ghost" class="size-6" onClick={openContext}>
|
||||||
|
{circle()}
|
||||||
|
</Button>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Tooltip>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { SessionHeader } from "./session-header"
|
||||||
|
export { SessionContextTab } from "./session-context-tab"
|
||||||
|
export { SortableTab, FileVisual } from "./session-sortable-tab"
|
||||||
|
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||||
|
export { NewSessionView } from "./session-new-view"
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||||
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
|
import { Code } from "@opencode-ai/ui/code"
|
||||||
|
import { Markdown } from "@opencode-ai/ui/markdown"
|
||||||
|
import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
interface SessionContextTabProps {
|
||||||
|
messages: () => Message[]
|
||||||
|
visibleUserMessages: () => UserMessage[]
|
||||||
|
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||||
|
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionContextTab(props: SessionContextTabProps) {
|
||||||
|
const params = useParams()
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
|
const ctx = createMemo(() => {
|
||||||
|
const last = props.messages().findLast((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
|
||||||
|
}) as AssistantMessage
|
||||||
|
if (!last) return
|
||||||
|
|
||||||
|
const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
|
||||||
|
const model = provider?.models[last.modelID]
|
||||||
|
const limit = model?.limit.context
|
||||||
|
|
||||||
|
const input = last.tokens.input
|
||||||
|
const output = last.tokens.output
|
||||||
|
const reasoning = last.tokens.reasoning
|
||||||
|
const cacheRead = last.tokens.cache.read
|
||||||
|
const cacheWrite = last.tokens.cache.write
|
||||||
|
const total = input + output + reasoning + cacheRead + cacheWrite
|
||||||
|
const usage = limit ? Math.round((total / limit) * 100) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: last,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
limit,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
reasoning,
|
||||||
|
cacheRead,
|
||||||
|
cacheWrite,
|
||||||
|
total,
|
||||||
|
usage,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cost = createMemo(() => {
|
||||||
|
const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
const counts = createMemo(() => {
|
||||||
|
const all = props.messages()
|
||||||
|
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
|
||||||
|
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
|
||||||
|
return {
|
||||||
|
all: all.length,
|
||||||
|
user,
|
||||||
|
assistant,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const systemPrompt = createMemo(() => {
|
||||||
|
const msg = props.visibleUserMessages().findLast((m) => !!m.system)
|
||||||
|
const system = msg?.system
|
||||||
|
if (!system) return
|
||||||
|
const trimmed = system.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
return trimmed
|
||||||
|
})
|
||||||
|
|
||||||
|
const number = (value: number | null | undefined) => {
|
||||||
|
if (value === undefined) return "—"
|
||||||
|
if (value === null) return "—"
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = (value: number | null | undefined) => {
|
||||||
|
if (value === undefined) return "—"
|
||||||
|
if (value === null) return "—"
|
||||||
|
return value.toString() + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = (value: number | undefined) => {
|
||||||
|
if (!value) return "—"
|
||||||
|
return DateTime.fromMillis(value).toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerLabel = createMemo(() => {
|
||||||
|
const c = ctx()
|
||||||
|
if (!c) return "—"
|
||||||
|
return c.provider?.name ?? c.message.providerID
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelLabel = createMemo(() => {
|
||||||
|
const c = ctx()
|
||||||
|
if (!c) return "—"
|
||||||
|
if (c.model?.name) return c.model.name
|
||||||
|
return c.message.modelID
|
||||||
|
})
|
||||||
|
|
||||||
|
const breakdown = createMemo(
|
||||||
|
on(
|
||||||
|
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
|
||||||
|
() => {
|
||||||
|
const c = ctx()
|
||||||
|
if (!c) return []
|
||||||
|
const input = c.input
|
||||||
|
if (!input) return []
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
system: systemPrompt()?.length ?? 0,
|
||||||
|
user: 0,
|
||||||
|
assistant: 0,
|
||||||
|
tool: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of props.messages()) {
|
||||||
|
const parts = (sync.data.part[msg.id] ?? []) as Part[]
|
||||||
|
|
||||||
|
if (msg.role === "user") {
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === "text") out.user += part.text.length
|
||||||
|
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
|
||||||
|
if (part.type === "agent") out.user += part.source?.value.length ?? 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role === "assistant") {
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === "text") out.assistant += part.text.length
|
||||||
|
if (part.type === "reasoning") out.assistant += part.text.length
|
||||||
|
if (part.type === "tool") {
|
||||||
|
out.tool += Object.keys(part.state.input).length * 16
|
||||||
|
if (part.state.status === "pending") out.tool += part.state.raw.length
|
||||||
|
if (part.state.status === "completed") out.tool += part.state.output.length
|
||||||
|
if (part.state.status === "error") out.tool += part.state.error.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
||||||
|
const system = estimateTokens(out.system)
|
||||||
|
const user = estimateTokens(out.user)
|
||||||
|
const assistant = estimateTokens(out.assistant)
|
||||||
|
const tool = estimateTokens(out.tool)
|
||||||
|
const estimated = system + user + assistant + tool
|
||||||
|
|
||||||
|
const pct = (tokens: number) => (tokens / input) * 100
|
||||||
|
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
|
||||||
|
|
||||||
|
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "system",
|
||||||
|
label: "System",
|
||||||
|
tokens: tokens.system,
|
||||||
|
width: pct(tokens.system),
|
||||||
|
percent: pctLabel(tokens.system),
|
||||||
|
color: "var(--syntax-info)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "User",
|
||||||
|
tokens: tokens.user,
|
||||||
|
width: pct(tokens.user),
|
||||||
|
percent: pctLabel(tokens.user),
|
||||||
|
color: "var(--syntax-success)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "assistant",
|
||||||
|
label: "Assistant",
|
||||||
|
tokens: tokens.assistant,
|
||||||
|
width: pct(tokens.assistant),
|
||||||
|
percent: pctLabel(tokens.assistant),
|
||||||
|
color: "var(--syntax-property)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tool",
|
||||||
|
label: "Tool Calls",
|
||||||
|
tokens: tokens.tool,
|
||||||
|
width: pct(tokens.tool),
|
||||||
|
percent: pctLabel(tokens.tool),
|
||||||
|
color: "var(--syntax-warning)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "other",
|
||||||
|
label: "Other",
|
||||||
|
tokens: tokens.other,
|
||||||
|
width: pct(tokens.other),
|
||||||
|
percent: pctLabel(tokens.other),
|
||||||
|
color: "var(--syntax-comment)",
|
||||||
|
},
|
||||||
|
].filter((x) => x.tokens > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (estimated <= input) {
|
||||||
|
return build({ system, user, assistant, tool, other: input - estimated })
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = input / estimated
|
||||||
|
const scaled = {
|
||||||
|
system: Math.floor(system * scale),
|
||||||
|
user: Math.floor(user * scale),
|
||||||
|
assistant: Math.floor(assistant * scale),
|
||||||
|
tool: Math.floor(tool * scale),
|
||||||
|
}
|
||||||
|
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
|
||||||
|
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function Stat(statProps: { label: string; value: JSX.Element }) {
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-12-regular text-text-weak">{statProps.label}</div>
|
||||||
|
<div class="text-12-medium text-text-strong">{statProps.value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = createMemo(() => {
|
||||||
|
const c = ctx()
|
||||||
|
const count = counts()
|
||||||
|
return [
|
||||||
|
{ label: "Session", value: props.info()?.title ?? params.id ?? "—" },
|
||||||
|
{ label: "Messages", value: count.all.toLocaleString() },
|
||||||
|
{ label: "Provider", value: providerLabel() },
|
||||||
|
{ label: "Model", value: modelLabel() },
|
||||||
|
{ label: "Context Limit", value: number(c?.limit) },
|
||||||
|
{ label: "Total Tokens", value: number(c?.total) },
|
||||||
|
{ label: "Usage", value: percent(c?.usage) },
|
||||||
|
{ label: "Input Tokens", value: number(c?.input) },
|
||||||
|
{ label: "Output Tokens", value: number(c?.output) },
|
||||||
|
{ label: "Reasoning Tokens", value: number(c?.reasoning) },
|
||||||
|
{ label: "Cache Tokens (read/write)", value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}` },
|
||||||
|
{ label: "User Messages", value: count.user.toLocaleString() },
|
||||||
|
{ label: "Assistant Messages", value: count.assistant.toLocaleString() },
|
||||||
|
{ label: "Total Cost", value: cost() },
|
||||||
|
{ label: "Session Created", value: time(props.info()?.time.created) },
|
||||||
|
{ label: "Last Activity", value: time(c?.message.time.created) },
|
||||||
|
] satisfies { label: string; value: JSX.Element }[]
|
||||||
|
})
|
||||||
|
|
||||||
|
function RawMessageContent(msgProps: { message: Message }) {
|
||||||
|
const file = createMemo(() => {
|
||||||
|
const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
|
||||||
|
const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
|
||||||
|
return {
|
||||||
|
name: `${msgProps.message.role}-${msgProps.message.id}.json`,
|
||||||
|
contents,
|
||||||
|
cacheKey: checksum(contents),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return <Code file={file()} overflow="wrap" class="select-text" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function RawMessage(msgProps: { message: Message }) {
|
||||||
|
return (
|
||||||
|
<Accordion.Item value={msgProps.message.id}>
|
||||||
|
<StickyAccordionHeader>
|
||||||
|
<Accordion.Trigger>
|
||||||
|
<div class="flex items-center justify-between gap-2 w-full">
|
||||||
|
<div class="min-w-0 truncate">
|
||||||
|
{msgProps.message.role} <span class="text-text-base">• {msgProps.message.id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
|
||||||
|
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</StickyAccordionHeader>
|
||||||
|
<Accordion.Content class="bg-background-base">
|
||||||
|
<div class="p-3">
|
||||||
|
<RawMessageContent message={msgProps.message} />
|
||||||
|
</div>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let scroll: HTMLDivElement | undefined
|
||||||
|
let frame: number | undefined
|
||||||
|
let pending: { x: number; y: number } | undefined
|
||||||
|
|
||||||
|
const restoreScroll = () => {
|
||||||
|
const el = scroll
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const s = props.view()?.scroll("context")
|
||||||
|
if (!s) return
|
||||||
|
|
||||||
|
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
||||||
|
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||||
|
pending = {
|
||||||
|
x: event.currentTarget.scrollLeft,
|
||||||
|
y: event.currentTarget.scrollTop,
|
||||||
|
}
|
||||||
|
if (frame !== undefined) return
|
||||||
|
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = undefined
|
||||||
|
|
||||||
|
const next = pending
|
||||||
|
pending = undefined
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
props.view().setScroll("context", next)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.messages().length,
|
||||||
|
() => {
|
||||||
|
requestAnimationFrame(restoreScroll)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (frame === undefined) return
|
||||||
|
cancelAnimationFrame(frame)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="@container h-full overflow-y-auto no-scrollbar pb-10"
|
||||||
|
ref={(el) => {
|
||||||
|
scroll = el
|
||||||
|
restoreScroll()
|
||||||
|
}}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
<div class="px-6 pt-4 flex flex-col gap-10">
|
||||||
|
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||||
|
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={breakdown().length > 0}>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-12-regular text-text-weak">Context Breakdown</div>
|
||||||
|
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
|
||||||
|
<For each={breakdown()}>
|
||||||
|
{(segment) => (
|
||||||
|
<div
|
||||||
|
class="h-full"
|
||||||
|
style={{
|
||||||
|
width: `${segment.width}%`,
|
||||||
|
"background-color": segment.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||||
|
<For each={breakdown()}>
|
||||||
|
{(segment) => (
|
||||||
|
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
||||||
|
<div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
|
||||||
|
<div>{segment.label}</div>
|
||||||
|
<div class="text-text-weaker">{segment.percent}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class="hidden text-11-regular text-text-weaker">
|
||||||
|
Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={systemPrompt()}>
|
||||||
|
{(prompt) => (
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-12-regular text-text-weak">System Prompt</div>
|
||||||
|
<div class="border border-border-base rounded-md bg-surface-base px-3 py-2">
|
||||||
|
<Markdown text={prompt()} class="text-12-regular" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-12-regular text-text-weak">Raw messages</div>
|
||||||
|
<Accordion multiple>
|
||||||
|
<For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { createMemo, createResource, Show } from "solid-js"
|
||||||
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||||
|
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
||||||
|
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
||||||
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
export function SessionHeader() {
|
||||||
|
const globalSDK = useGlobalSDK()
|
||||||
|
const layout = useLayout()
|
||||||
|
const params = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const command = useCommand()
|
||||||
|
const server = useServer()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
|
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
|
||||||
|
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
||||||
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
|
const branch = createMemo(() => sync.data.vcs?.branch)
|
||||||
|
|
||||||
|
function navigateToProject(directory: string) {
|
||||||
|
navigate(`/${base64Encode(directory)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToSession(session: Session | undefined) {
|
||||||
|
if (!session) return
|
||||||
|
navigate(`/${params.dir}/session/${session.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
|
||||||
|
onClick={layout.mobileSidebar.toggle}
|
||||||
|
>
|
||||||
|
<Icon name="menu" size="small" />
|
||||||
|
</button>
|
||||||
|
<div class="px-4 flex items-center justify-between gap-4 w-full">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<div class="hidden xl:flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
options={layout.projects.list().map((project) => project.worktree)}
|
||||||
|
current={sync.directory}
|
||||||
|
label={(x) => {
|
||||||
|
const name = getFilename(x)
|
||||||
|
const b = x === sync.directory ? branch() : undefined
|
||||||
|
return b ? `${name}:${b}` : name
|
||||||
|
}}
|
||||||
|
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
|
||||||
|
class="text-14-regular text-text-base"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
{(i) => (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon name="folder" size="small" />
|
||||||
|
<div class="text-text-strong">{getFilename(i)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
<div class="text-text-weaker">/</div>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
options={sessions()}
|
||||||
|
current={currentSession()}
|
||||||
|
placeholder="New session"
|
||||||
|
label={(x) => x.title}
|
||||||
|
value={(x) => x.id}
|
||||||
|
onSelect={navigateToSession}
|
||||||
|
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={currentSession()}>
|
||||||
|
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
|
||||||
|
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="hidden md:flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
dialog.show(() => <DialogSelectServer />)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"size-1.5 rounded-full": true,
|
||||||
|
"bg-icon-success-base": server.healthy() === true,
|
||||||
|
"bg-icon-critical-base": server.healthy() === false,
|
||||||
|
"bg-border-weak-base": server.healthy() === undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Icon name="server" size="small" class="text-icon-weak" />
|
||||||
|
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
|
||||||
|
</Button>
|
||||||
|
<SessionLspIndicator />
|
||||||
|
<SessionMcpIndicator />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Show when={currentSession()?.summary?.files}>
|
||||||
|
<TooltipKeybind
|
||||||
|
class="hidden md:block shrink-0"
|
||||||
|
title="Toggle review"
|
||||||
|
keybind={command.keybind("review.toggle")}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
|
||||||
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
|
<Icon
|
||||||
|
name={layout.review.opened() ? "layout-right" : "layout-left"}
|
||||||
|
size="small"
|
||||||
|
class="group-hover/review-toggle:hidden"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
|
||||||
|
size="small"
|
||||||
|
class="hidden group-hover/review-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
|
||||||
|
size="small"
|
||||||
|
class="hidden group-active/review-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
<TooltipKeybind
|
||||||
|
class="hidden md:block shrink-0"
|
||||||
|
title="Toggle terminal"
|
||||||
|
keybind={command.keybind("terminal.toggle")}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
|
||||||
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
|
<Icon
|
||||||
|
size="small"
|
||||||
|
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
||||||
|
class="group-hover/terminal-toggle:hidden"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
size="small"
|
||||||
|
name="layout-bottom-partial"
|
||||||
|
class="hidden group-hover/terminal-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
size="small"
|
||||||
|
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
||||||
|
class="hidden group-active/terminal-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
<Show when={shareEnabled() && currentSession()}>
|
||||||
|
<Popover
|
||||||
|
title="Share session"
|
||||||
|
trigger={
|
||||||
|
<Tooltip class="shrink-0" value="Share session">
|
||||||
|
<IconButton icon="share" variant="ghost" class="" />
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{iife(() => {
|
||||||
|
const [url] = createResource(
|
||||||
|
() => currentSession(),
|
||||||
|
async (session) => {
|
||||||
|
if (!session) return
|
||||||
|
let shareURL = session.share?.url
|
||||||
|
if (!shareURL) {
|
||||||
|
shareURL = await globalSDK.client.session
|
||||||
|
.share({ sessionID: session.id, directory: sync.directory })
|
||||||
|
.then((r) => r.data?.share?.url)
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to share session", e)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return shareURL
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
|
||||||
|
})}
|
||||||
|
</Popover>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Show } from "solid-js"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
|
export function NewSessionView() {
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
|
||||||
|
<div class="text-20-medium text-text-weaker">New session</div>
|
||||||
|
<div class="flex justify-center items-center gap-3">
|
||||||
|
<Icon name="folder" size="small" />
|
||||||
|
<div class="text-12-medium text-text-weak">
|
||||||
|
{getDirectory(sync.data.path.directory)}
|
||||||
|
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={sync.project}>
|
||||||
|
{(project) => (
|
||||||
|
<div class="flex justify-center items-center gap-3">
|
||||||
|
<Icon name="pencil-line" size="small" />
|
||||||
|
<div class="text-12-medium text-text-weak">
|
||||||
|
Last modified
|
||||||
|
<span class="text-text-strong">
|
||||||
|
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
import { useFile } from "@/context/file"
|
||||||
|
|
||||||
|
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-x-1.5">
|
||||||
|
<FileIcon
|
||||||
|
node={{ path: props.path, type: "file" }}
|
||||||
|
classList={{
|
||||||
|
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
|
||||||
|
"grayscale-0": props.active,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="text-14-medium">{getFilename(props.path)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
|
||||||
|
const file = useFile()
|
||||||
|
const sortable = createSortable(props.tab)
|
||||||
|
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||||
|
<div class="relative h-full">
|
||||||
|
<Tabs.Trigger
|
||||||
|
value={props.tab}
|
||||||
|
closeButton={
|
||||||
|
<Tooltip value="Close tab" placement="bottom">
|
||||||
|
<IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
hideCloseButton
|
||||||
|
>
|
||||||
|
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
|
|
||||||
|
export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element {
|
||||||
|
const terminal = useTerminal()
|
||||||
|
const sortable = createSortable(props.terminal.id)
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||||
|
<div class="relative h-full">
|
||||||
|
<Tabs.Trigger
|
||||||
|
value={props.terminal.id}
|
||||||
|
closeButton={
|
||||||
|
terminal.all().length > 1 && (
|
||||||
|
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.terminal.title}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
|
||||||
|
|
||||||
export function StatusBar(props: ParentProps) {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const server = useServer()
|
|
||||||
const sync = useSync()
|
|
||||||
const globalSync = useGlobalSync()
|
|
||||||
|
|
||||||
const directoryDisplay = createMemo(() => {
|
|
||||||
const directory = sync.data.path.directory || ""
|
|
||||||
const home = globalSync.data.path.home || ""
|
|
||||||
const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
|
|
||||||
const branch = sync.data.vcs?.branch
|
|
||||||
return branch ? `${short}:${branch}` : short
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span class="text-12-regular text-text-weak">{server.name}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Show when={directoryDisplay()}>
|
|
||||||
<span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">{props.children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
import { useSDK } from "./sdk"
|
||||||
|
import { useSync } from "./sync"
|
||||||
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
|
export type FileSelection = {
|
||||||
|
startLine: number
|
||||||
|
startChar: number
|
||||||
|
endLine: number
|
||||||
|
endChar: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectedLineRange = {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
side?: "additions" | "deletions"
|
||||||
|
endSide?: "additions" | "deletions"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileViewState = {
|
||||||
|
scrollTop?: number
|
||||||
|
scrollLeft?: number
|
||||||
|
selectedLines?: SelectedLineRange | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileState = {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
loaded?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
error?: string
|
||||||
|
content?: FileContent
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripFileProtocol(input: string) {
|
||||||
|
if (!input.startsWith("file://")) return input
|
||||||
|
return input.slice("file://".length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripQueryAndHash(input: string) {
|
||||||
|
const hashIndex = input.indexOf("#")
|
||||||
|
const queryIndex = input.indexOf("?")
|
||||||
|
|
||||||
|
if (hashIndex !== -1 && queryIndex !== -1) {
|
||||||
|
return input.slice(0, Math.min(hashIndex, queryIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashIndex !== -1) return input.slice(0, hashIndex)
|
||||||
|
if (queryIndex !== -1) return input.slice(0, queryIndex)
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionFromLines(range: SelectedLineRange): FileSelection {
|
||||||
|
const startLine = Math.min(range.start, range.end)
|
||||||
|
const endLine = Math.max(range.start, range.end)
|
||||||
|
return {
|
||||||
|
startLine,
|
||||||
|
endLine,
|
||||||
|
startChar: 0,
|
||||||
|
endChar: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
||||||
|
if (range.start <= range.end) return range
|
||||||
|
|
||||||
|
const startSide = range.side
|
||||||
|
const endSide = range.endSide ?? startSide
|
||||||
|
|
||||||
|
return {
|
||||||
|
...range,
|
||||||
|
start: range.end,
|
||||||
|
end: range.start,
|
||||||
|
side: endSide,
|
||||||
|
endSide: startSide !== endSide ? startSide : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
|
name: "File",
|
||||||
|
init: () => {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const params = useParams()
|
||||||
|
|
||||||
|
const directory = createMemo(() => sync.data.path.directory)
|
||||||
|
|
||||||
|
function normalize(input: string) {
|
||||||
|
const root = directory()
|
||||||
|
const prefix = root.endsWith("/") ? root : root + "/"
|
||||||
|
|
||||||
|
let path = stripQueryAndHash(stripFileProtocol(input))
|
||||||
|
|
||||||
|
if (path.startsWith(prefix)) {
|
||||||
|
path = path.slice(prefix.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith(root)) {
|
||||||
|
path = path.slice(root.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("./")) {
|
||||||
|
path = path.slice(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/")) {
|
||||||
|
path = path.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
function tab(input: string) {
|
||||||
|
const path = normalize(input)
|
||||||
|
return `file://${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathFromTab(tabValue: string) {
|
||||||
|
if (!tabValue.startsWith("file://")) return
|
||||||
|
return normalize(tabValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
|
const [store, setStore] = createStore<{
|
||||||
|
file: Record<string, FileState>
|
||||||
|
}>({
|
||||||
|
file: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
|
||||||
|
|
||||||
|
const [view, setView, _, ready] = persisted(
|
||||||
|
viewKey(),
|
||||||
|
createStore<{
|
||||||
|
file: Record<string, FileViewState>
|
||||||
|
}>({
|
||||||
|
file: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function ensure(path: string) {
|
||||||
|
if (!path) return
|
||||||
|
if (store.file[path]) return
|
||||||
|
setStore("file", path, { path, name: getFilename(path) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(input: string, options?: { force?: boolean }) {
|
||||||
|
const path = normalize(input)
|
||||||
|
if (!path) return Promise.resolve()
|
||||||
|
|
||||||
|
ensure(path)
|
||||||
|
|
||||||
|
const current = store.file[path]
|
||||||
|
if (!options?.force && current?.loaded) return Promise.resolve()
|
||||||
|
|
||||||
|
const pending = inflight.get(path)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
setStore(
|
||||||
|
"file",
|
||||||
|
path,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loading = true
|
||||||
|
draft.error = undefined
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const promise = sdk.client.file
|
||||||
|
.read({ path })
|
||||||
|
.then((x) => {
|
||||||
|
setStore(
|
||||||
|
"file",
|
||||||
|
path,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loaded = true
|
||||||
|
draft.loading = false
|
||||||
|
draft.content = x.data
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setStore(
|
||||||
|
"file",
|
||||||
|
path,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loading = false
|
||||||
|
draft.error = e.message
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Failed to load file",
|
||||||
|
description: e.message,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(path)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflight.set(path, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = sdk.event.listen((e) => {
|
||||||
|
const event = e.details
|
||||||
|
if (event.type !== "file.watcher.updated") return
|
||||||
|
const path = normalize(event.properties.file)
|
||||||
|
if (!path) return
|
||||||
|
if (path.startsWith(".git/")) return
|
||||||
|
if (!store.file[path]) return
|
||||||
|
load(path, { force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = (input: string) => store.file[normalize(input)]
|
||||||
|
|
||||||
|
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
|
||||||
|
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
|
||||||
|
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
|
||||||
|
|
||||||
|
const setScrollTop = (input: string, top: number) => {
|
||||||
|
const path = normalize(input)
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollTop === top) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollTop: top,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setScrollLeft = (input: string, left: number) => {
|
||||||
|
const path = normalize(input)
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollLeft === left) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollLeft: left,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
||||||
|
const path = normalize(input)
|
||||||
|
const next = range ? normalizeSelectedLines(range) : null
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.selectedLines === next) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
selectedLines: next,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => stop())
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
normalize,
|
||||||
|
tab,
|
||||||
|
pathFromTab,
|
||||||
|
get,
|
||||||
|
load,
|
||||||
|
scrollTop,
|
||||||
|
scrollLeft,
|
||||||
|
setScrollTop,
|
||||||
|
setScrollLeft,
|
||||||
|
selectedLines,
|
||||||
|
setSelectedLines,
|
||||||
|
searchFiles: (query: string) =>
|
||||||
|
sdk.client.find.files({ query, dirs: "false" }).then((x) => (x.data ?? []).map(normalize)),
|
||||||
|
searchFilesAndDirectories: (query: string) =>
|
||||||
|
sdk.client.find.files({ query, dirs: "true" }).then((x) => (x.data ?? []).map(normalize)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type McpStatus,
|
type McpStatus,
|
||||||
type LspStatus,
|
type LspStatus,
|
||||||
type VcsInfo,
|
type VcsInfo,
|
||||||
type Permission,
|
type PermissionRequest,
|
||||||
createOpencodeClient,
|
createOpencodeClient,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
@@ -28,7 +28,7 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
ready: boolean
|
status: "loading" | "partial" | "complete"
|
||||||
agent: Agent[]
|
agent: Agent[]
|
||||||
command: Command[]
|
command: Command[]
|
||||||
project: string
|
project: string
|
||||||
@@ -46,7 +46,7 @@ type State = {
|
|||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
}
|
}
|
||||||
permission: {
|
permission: {
|
||||||
[sessionID: string]: Permission[]
|
[sessionID: string]: PermissionRequest[]
|
||||||
}
|
}
|
||||||
mcp: {
|
mcp: {
|
||||||
[name: string]: McpStatus
|
[name: string]: McpStatus
|
||||||
@@ -88,7 +88,7 @@ function createGlobalSync() {
|
|||||||
provider: { all: [], connected: [], default: {} },
|
provider: { all: [], connected: [], default: {} },
|
||||||
config: {},
|
config: {},
|
||||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
ready: false,
|
status: "loading" as const,
|
||||||
agent: [],
|
agent: [],
|
||||||
command: [],
|
command: [],
|
||||||
session: [],
|
session: [],
|
||||||
@@ -115,13 +115,14 @@ function createGlobalSync() {
|
|||||||
.then((x) => {
|
.then((x) => {
|
||||||
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
|
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
|
||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
|
.filter((s) => !!s?.id)
|
||||||
|
.filter((s) => !s.time?.archived)
|
||||||
.slice()
|
.slice()
|
||||||
.filter((s) => !s.time.archived)
|
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
// Include up to the limit, plus any updated in the last 4 hours
|
// Include up to the limit, plus any updated in the last 4 hours
|
||||||
const sessions = nonArchived.filter((s, i) => {
|
const sessions = nonArchived.filter((s, i) => {
|
||||||
if (i < store.limit) return true
|
if (i < store.limit) return true
|
||||||
const updated = new Date(s.time.updated).getTime()
|
const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
|
||||||
return updated > fourHoursAgo
|
return updated > fourHoursAgo
|
||||||
})
|
})
|
||||||
setStore("session", reconcile(sessions, { key: "id" }))
|
setStore("session", reconcile(sessions, { key: "id" }))
|
||||||
@@ -141,7 +142,8 @@ function createGlobalSync() {
|
|||||||
directory,
|
directory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
const load = {
|
|
||||||
|
const blockingRequests = {
|
||||||
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
||||||
provider: () =>
|
provider: () =>
|
||||||
sdk.provider.list().then((x) => {
|
sdk.provider.list().then((x) => {
|
||||||
@@ -156,47 +158,57 @@ function createGlobalSync() {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
|
|
||||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||||
command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
|
||||||
session: () => loadSessions(directory),
|
|
||||||
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
|
||||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||||
mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
|
|
||||||
lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
|
|
||||||
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
|
|
||||||
permission: () =>
|
|
||||||
sdk.permission.list().then((x) => {
|
|
||||||
const grouped: Record<string, Permission[]> = {}
|
|
||||||
for (const perm of x.data ?? []) {
|
|
||||||
const existing = grouped[perm.sessionID]
|
|
||||||
if (existing) {
|
|
||||||
existing.push(perm)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
grouped[perm.sessionID] = [perm]
|
|
||||||
}
|
|
||||||
|
|
||||||
batch(() => {
|
|
||||||
for (const sessionID of Object.keys(store.permission)) {
|
|
||||||
if (grouped[sessionID]) continue
|
|
||||||
setStore("permission", sessionID, [])
|
|
||||||
}
|
|
||||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
|
||||||
setStore(
|
|
||||||
"permission",
|
|
||||||
sessionID,
|
|
||||||
reconcile(
|
|
||||||
permissions.slice().sort((a, b) => a.id.localeCompare(b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
await Promise.all(Object.values(blockingRequests).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
||||||
.then(() => setStore("ready", true))
|
.then(() => {
|
||||||
|
if (store.status !== "complete") setStore("status", "partial")
|
||||||
|
// non-blocking
|
||||||
|
Promise.all([
|
||||||
|
sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||||
|
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||||
|
sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
||||||
|
loadSessions(directory),
|
||||||
|
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
|
||||||
|
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
|
||||||
|
sdk.vcs.get().then((x) => setStore("vcs", x.data)),
|
||||||
|
sdk.permission.list().then((x) => {
|
||||||
|
const grouped: Record<string, PermissionRequest[]> = {}
|
||||||
|
for (const perm of x.data ?? []) {
|
||||||
|
if (!perm?.id || !perm.sessionID) continue
|
||||||
|
const existing = grouped[perm.sessionID]
|
||||||
|
if (existing) {
|
||||||
|
existing.push(perm)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grouped[perm.sessionID] = [perm]
|
||||||
|
}
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(store.permission)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
setStore("permission", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||||
|
setStore(
|
||||||
|
"permission",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
permissions
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
]).then(() => {
|
||||||
|
setStore("status", "complete")
|
||||||
|
})
|
||||||
|
})
|
||||||
.catch((e) => setGlobalStore("error", e))
|
.catch((e) => setGlobalStore("error", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +356,7 @@ function createGlobalSync() {
|
|||||||
setStore("vcs", { branch: event.properties.branch })
|
setStore("vcs", { branch: event.properties.branch })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "permission.updated": {
|
case "permission.asked": {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.properties.sessionID
|
||||||
const permissions = store.permission[sessionID]
|
const permissions = store.permission[sessionID]
|
||||||
if (!permissions) {
|
if (!permissions) {
|
||||||
@@ -370,7 +382,7 @@ function createGlobalSync() {
|
|||||||
case "permission.replied": {
|
case "permission.replied": {
|
||||||
const permissions = store.permission[event.properties.sessionID]
|
const permissions = store.permission[event.properties.sessionID]
|
||||||
if (!permissions) break
|
if (!permissions) break
|
||||||
const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
|
const result = Binary.search(permissions, event.properties.requestID, (p) => p.id)
|
||||||
if (!result.found) break
|
if (!result.found) break
|
||||||
setStore(
|
setStore(
|
||||||
"permission",
|
"permission",
|
||||||
@@ -414,10 +426,12 @@ function createGlobalSync() {
|
|||||||
),
|
),
|
||||||
retry(() =>
|
retry(() =>
|
||||||
globalSDK.client.project.list().then(async (x) => {
|
globalSDK.client.project.list().then(async (x) => {
|
||||||
setGlobalStore(
|
const projects = (x.data ?? [])
|
||||||
"project",
|
.filter((p) => !!p?.id)
|
||||||
x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
)
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
setGlobalStore("project", projects)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
retry(() =>
|
retry(() =>
|
||||||
|
|||||||
@@ -23,13 +23,32 @@ export function getAvatarColors(key?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
|
||||||
|
if (a === b) return true
|
||||||
|
if (!a || !b) return false
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
return a.every((x, i) => x === b[i])
|
||||||
|
}
|
||||||
|
|
||||||
type SessionTabs = {
|
type SessionTabs = {
|
||||||
active?: string
|
active?: string
|
||||||
all: string[]
|
all: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionScroll = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionView = {
|
||||||
|
scroll: Record<string, SessionScroll>
|
||||||
|
reviewOpen?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||||
|
|
||||||
|
export type ReviewDiffStyle = "unified" | "split"
|
||||||
|
|
||||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -37,7 +56,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
"layout.v4",
|
"layout.v6",
|
||||||
createStore({
|
createStore({
|
||||||
sidebar: {
|
sidebar: {
|
||||||
opened: false,
|
opened: false,
|
||||||
@@ -49,11 +68,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
opened: true,
|
opened: true,
|
||||||
|
diffStyle: "split" as ReviewDiffStyle,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
width: 600,
|
width: 600,
|
||||||
},
|
},
|
||||||
|
mobileSidebar: {
|
||||||
|
opened: false,
|
||||||
|
},
|
||||||
sessionTabs: {} as Record<string, SessionTabs>,
|
sessionTabs: {} as Record<string, SessionTabs>,
|
||||||
|
sessionView: {} as Record<string, SessionView>,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -156,6 +180,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
opened: createMemo(() => store.review?.opened ?? true),
|
opened: createMemo(() => store.review?.opened ?? true),
|
||||||
|
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
||||||
|
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
||||||
|
if (!store.review) {
|
||||||
|
setStore("review", { opened: true, diffStyle })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("review", "diffStyle", diffStyle)
|
||||||
|
},
|
||||||
open() {
|
open() {
|
||||||
setStore("review", "opened", true)
|
setStore("review", "opened", true)
|
||||||
},
|
},
|
||||||
@@ -171,11 +203,55 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
resize(width: number) {
|
resize(width: number) {
|
||||||
if (!store.session) {
|
if (!store.session) {
|
||||||
setStore("session", { width })
|
setStore("session", { width })
|
||||||
} else {
|
return
|
||||||
setStore("session", "width", width)
|
|
||||||
}
|
}
|
||||||
|
setStore("session", "width", width)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mobileSidebar: {
|
||||||
|
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
||||||
|
show() {
|
||||||
|
setStore("mobileSidebar", "opened", true)
|
||||||
|
},
|
||||||
|
hide() {
|
||||||
|
setStore("mobileSidebar", "opened", false)
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
setStore("mobileSidebar", "opened", (x) => !x)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
view(sessionKey: string) {
|
||||||
|
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
||||||
|
return {
|
||||||
|
scroll(tab: string) {
|
||||||
|
return s().scroll?.[tab]
|
||||||
|
},
|
||||||
|
setScroll(tab: string, pos: SessionScroll) {
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", sessionKey, { scroll: { [tab]: pos } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = current.scroll?.[tab]
|
||||||
|
if (prev?.x === pos.x && prev?.y === pos.y) return
|
||||||
|
setStore("sessionView", sessionKey, "scroll", tab, pos)
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
open: createMemo(() => s().reviewOpen),
|
||||||
|
setOpen(open: string[]) {
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (same(current.reviewOpen, open)) return
|
||||||
|
setStore("sessionView", sessionKey, "reviewOpen", open)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
tabs(sessionKey: string) {
|
tabs(sessionKey: string) {
|
||||||
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
|
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
|
||||||
return {
|
return {
|
||||||
@@ -198,38 +274,55 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
async open(tab: string) {
|
async open(tab: string) {
|
||||||
const current = store.sessionTabs[sessionKey] ?? { all: [] }
|
const current = store.sessionTabs[sessionKey] ?? { all: [] }
|
||||||
if (tab !== "review") {
|
|
||||||
if (!current.all.includes(tab)) {
|
if (tab === "review") {
|
||||||
if (!store.sessionTabs[sessionKey]) {
|
if (!store.sessionTabs[sessionKey]) {
|
||||||
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
|
setStore("sessionTabs", sessionKey, { all: [], active: tab })
|
||||||
} else {
|
|
||||||
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
|
|
||||||
setStore("sessionTabs", sessionKey, "active", tab)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!store.sessionTabs[sessionKey]) {
|
|
||||||
setStore("sessionTabs", sessionKey, { all: [], active: tab })
|
|
||||||
} else {
|
|
||||||
setStore("sessionTabs", sessionKey, "active", tab)
|
setStore("sessionTabs", sessionKey, "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 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("sessionTabs", sessionKey, "all", all)
|
||||||
|
setStore("sessionTabs", sessionKey, "active", tab)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current.all.includes(tab)) {
|
||||||
|
if (!store.sessionTabs[sessionKey]) {
|
||||||
|
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
|
||||||
|
setStore("sessionTabs", sessionKey, "active", tab)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.sessionTabs[sessionKey]) {
|
||||||
|
setStore("sessionTabs", sessionKey, { all: current.all, active: tab })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("sessionTabs", sessionKey, "active", tab)
|
||||||
},
|
},
|
||||||
close(tab: string) {
|
close(tab: string) {
|
||||||
const current = store.sessionTabs[sessionKey]
|
const current = store.sessionTabs[sessionKey]
|
||||||
if (!current) return
|
if (!current) return
|
||||||
|
|
||||||
|
const all = current.all.filter((x) => x !== tab)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore(
|
setStore("sessionTabs", sessionKey, "all", all)
|
||||||
"sessionTabs",
|
if (current.active !== tab) return
|
||||||
sessionKey,
|
|
||||||
"all",
|
const index = current.all.findIndex((f) => f === tab)
|
||||||
current.all.filter((x) => x !== tab),
|
const next = all[index - 1] ?? all[0]
|
||||||
)
|
setStore("sessionTabs", sessionKey, "active", next)
|
||||||
if (current.active === tab) {
|
|
||||||
const index = current.all.findIndex((f) => f === tab)
|
|
||||||
const previous = current.all[Math.max(0, index - 1)]
|
|
||||||
setStore("sessionTabs", sessionKey, "active", previous)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
move(tab: string, to: number) {
|
move(tab: string, to: number) {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
// ]
|
// ]
|
||||||
// })
|
// })
|
||||||
// setStore("active", relativePath)
|
// setStore("active", relativePath)
|
||||||
context.addActive()
|
// context.addActive()
|
||||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||||
if (store.node[relativePath]?.loaded) return
|
if (store.node[relativePath]?.loaded) return
|
||||||
@@ -538,66 +538,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const context = (() => {
|
|
||||||
const [store, setStore] = createStore<{
|
|
||||||
activeTab: boolean
|
|
||||||
files: string[]
|
|
||||||
activeFile?: string
|
|
||||||
items: (ContextItem & { key: string })[]
|
|
||||||
}>({
|
|
||||||
activeTab: true,
|
|
||||||
files: [],
|
|
||||||
items: [],
|
|
||||||
})
|
|
||||||
const files = createMemo(() => store.files.map((x) => file.node(x)))
|
|
||||||
const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
|
|
||||||
|
|
||||||
return {
|
|
||||||
all() {
|
|
||||||
return store.items
|
|
||||||
},
|
|
||||||
// active() {
|
|
||||||
// return store.activeTab ? file.active() : undefined
|
|
||||||
// },
|
|
||||||
addActive() {
|
|
||||||
setStore("activeTab", true)
|
|
||||||
},
|
|
||||||
removeActive() {
|
|
||||||
setStore("activeTab", false)
|
|
||||||
},
|
|
||||||
add(item: ContextItem) {
|
|
||||||
let key = item.type
|
|
||||||
switch (item.type) {
|
|
||||||
case "file":
|
|
||||||
key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (store.items.find((x) => x.key === key)) return
|
|
||||||
setStore("items", (x) => [...x, { key, ...item }])
|
|
||||||
},
|
|
||||||
remove(key: string) {
|
|
||||||
setStore("items", (x) => x.filter((x) => x.key !== key))
|
|
||||||
},
|
|
||||||
files,
|
|
||||||
openFile(path: string) {
|
|
||||||
file.init(path).then(() => {
|
|
||||||
setStore("files", (x) => [...x, path])
|
|
||||||
setStore("activeFile", path)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
activeFile,
|
|
||||||
setActiveFile(path: string | undefined) {
|
|
||||||
setStore("activeFile", path)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
slug: createMemo(() => base64Encode(sdk.directory)),
|
slug: createMemo(() => base64Encode(sdk.directory)),
|
||||||
model,
|
model,
|
||||||
agent,
|
agent,
|
||||||
file,
|
file,
|
||||||
context,
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,130 +1,122 @@
|
|||||||
import { createEffect, createRoot, onCleanup } from "solid-js"
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { Permission } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
type PermissionsBySession = {
|
import { useGlobalSync } from "./global-sync"
|
||||||
[sessionID: string]: Permission[]
|
import { useParams } from "@solidjs/router"
|
||||||
}
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
|
|
||||||
type PermissionRespondFn = (input: {
|
type PermissionRespondFn = (input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
permissionID: string
|
permissionID: string
|
||||||
response: "once" | "always" | "reject"
|
response: "once" | "always" | "reject"
|
||||||
|
directory?: string
|
||||||
}) => void
|
}) => void
|
||||||
|
|
||||||
const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
|
function shouldAutoAccept(perm: PermissionRequest) {
|
||||||
|
return perm.permission === "edit"
|
||||||
function shouldAutoAccept(perm: Permission) {
|
|
||||||
return AUTO_ACCEPT_TYPES.has(perm.type)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||||
name: "Permission",
|
name: "Permission",
|
||||||
init: (props: { permissions: PermissionsBySession; onRespond: PermissionRespondFn }) => {
|
init: () => {
|
||||||
|
const params = useParams()
|
||||||
|
const globalSDK = useGlobalSDK()
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
|
|
||||||
|
const permissionsEnabled = createMemo(() => {
|
||||||
|
if (!params.dir || !base64Decode(params.dir)) return false
|
||||||
|
const [store] = globalSync.child(base64Decode(params.dir))
|
||||||
|
return store.config.permission !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
"permission.v1",
|
"permission.v3",
|
||||||
createStore({
|
createStore({
|
||||||
autoAcceptEdits: {} as Record<string, boolean>,
|
autoAcceptEdits: {} as Record<string, boolean>,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const responded = new Set<string>()
|
const responded = new Set<string>()
|
||||||
const watches = new Map<string, () => void>()
|
|
||||||
|
|
||||||
function respond(perm: Permission) {
|
const respond: PermissionRespondFn = (input) => {
|
||||||
if (responded.has(perm.id)) return
|
globalSDK.client.permission.respond(input).catch(() => {
|
||||||
responded.add(perm.id)
|
responded.delete(input.permissionID)
|
||||||
props.onRespond({
|
|
||||||
sessionID: perm.sessionID,
|
|
||||||
permissionID: perm.id,
|
|
||||||
response: "once",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function watch(sessionID: string) {
|
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||||
if (watches.has(sessionID)) return
|
if (responded.has(permission.id)) return
|
||||||
|
responded.add(permission.id)
|
||||||
|
respond({
|
||||||
|
sessionID: permission.sessionID,
|
||||||
|
permissionID: permission.id,
|
||||||
|
response: "once",
|
||||||
|
directory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const dispose = createRoot((dispose) => {
|
function isAutoAccepting(sessionID: string) {
|
||||||
createEffect(() => {
|
return store.autoAcceptEdits[sessionID] ?? false
|
||||||
if (!store.autoAcceptEdits[sessionID]) return
|
}
|
||||||
|
|
||||||
const permissions = props.permissions[sessionID] ?? []
|
const unsubscribe = globalSDK.event.listen((e) => {
|
||||||
permissions.length
|
const event = e.details
|
||||||
|
if (event?.type !== "permission.asked") return
|
||||||
|
|
||||||
for (const perm of permissions) {
|
const perm = event.properties
|
||||||
|
if (!isAutoAccepting(perm.sessionID)) return
|
||||||
|
if (!shouldAutoAccept(perm)) return
|
||||||
|
|
||||||
|
respondOnce(perm, e.name)
|
||||||
|
})
|
||||||
|
onCleanup(unsubscribe)
|
||||||
|
|
||||||
|
function enable(sessionID: string, directory: string) {
|
||||||
|
setStore("autoAcceptEdits", sessionID, true)
|
||||||
|
|
||||||
|
globalSDK.client.permission
|
||||||
|
.list({ directory })
|
||||||
|
.then((x) => {
|
||||||
|
for (const perm of x.data ?? []) {
|
||||||
|
if (!perm?.id) continue
|
||||||
|
if (perm.sessionID !== sessionID) continue
|
||||||
if (!shouldAutoAccept(perm)) continue
|
if (!shouldAutoAccept(perm)) continue
|
||||||
respond(perm)
|
respondOnce(perm, directory)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
return dispose
|
|
||||||
})
|
|
||||||
|
|
||||||
watches.set(sessionID, dispose)
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwatch(sessionID: string) {
|
|
||||||
const dispose = watches.get(sessionID)
|
|
||||||
if (!dispose) return
|
|
||||||
dispose()
|
|
||||||
watches.delete(sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready()) return
|
|
||||||
|
|
||||||
for (const sessionID in store.autoAcceptEdits) {
|
|
||||||
if (!store.autoAcceptEdits[sessionID]) continue
|
|
||||||
watch(sessionID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
for (const dispose of watches.values()) dispose()
|
|
||||||
watches.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
function enable(sessionID: string) {
|
|
||||||
setStore("autoAcceptEdits", sessionID, true)
|
|
||||||
watch(sessionID)
|
|
||||||
|
|
||||||
const permissions = props.permissions[sessionID] ?? []
|
|
||||||
for (const perm of permissions) {
|
|
||||||
if (!shouldAutoAccept(perm)) continue
|
|
||||||
respond(perm)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function disable(sessionID: string) {
|
function disable(sessionID: string) {
|
||||||
setStore("autoAcceptEdits", sessionID, false)
|
setStore("autoAcceptEdits", sessionID, false)
|
||||||
unwatch(sessionID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get permissions() {
|
ready,
|
||||||
return props.permissions
|
respond,
|
||||||
|
autoResponds(permission: PermissionRequest) {
|
||||||
|
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
|
||||||
},
|
},
|
||||||
respond: props.onRespond,
|
isAutoAccepting,
|
||||||
isAutoAccepting(sessionID: string) {
|
toggleAutoAccept(sessionID: string, directory: string) {
|
||||||
return store.autoAcceptEdits[sessionID] ?? false
|
if (isAutoAccepting(sessionID)) {
|
||||||
},
|
|
||||||
toggleAutoAccept(sessionID: string) {
|
|
||||||
if (store.autoAcceptEdits[sessionID]) {
|
|
||||||
disable(sessionID)
|
disable(sessionID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
enable(sessionID)
|
enable(sessionID, directory)
|
||||||
},
|
},
|
||||||
enableAutoAccept(sessionID: string) {
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
if (store.autoAcceptEdits[sessionID]) return
|
if (isAutoAccepting(sessionID)) return
|
||||||
enable(sessionID)
|
enable(sessionID, directory)
|
||||||
},
|
},
|
||||||
disableAutoAccept(sessionID: string) {
|
disableAutoAccept(sessionID: string) {
|
||||||
disable(sessionID)
|
disable(sessionID)
|
||||||
},
|
},
|
||||||
|
permissionsEnabled,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { TextSelection } from "./local"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
interface PartBase {
|
interface PartBase {
|
||||||
@@ -18,7 +18,12 @@ export interface TextPart extends PartBase {
|
|||||||
export interface FileAttachmentPart extends PartBase {
|
export interface FileAttachmentPart extends PartBase {
|
||||||
type: "file"
|
type: "file"
|
||||||
path: string
|
path: string
|
||||||
selection?: TextSelection
|
selection?: FileSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentPart extends PartBase {
|
||||||
|
type: "agent"
|
||||||
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageAttachmentPart {
|
export interface ImageAttachmentPart {
|
||||||
@@ -29,11 +34,27 @@ export interface ImageAttachmentPart {
|
|||||||
dataUrl: string
|
dataUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
|
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||||
export type Prompt = ContentPart[]
|
export type Prompt = ContentPart[]
|
||||||
|
|
||||||
|
export type FileContextItem = {
|
||||||
|
type: "file"
|
||||||
|
path: string
|
||||||
|
selection?: FileSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextItem = FileContextItem
|
||||||
|
|
||||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
|
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
||||||
|
if (!a && !b) return true
|
||||||
|
if (!a || !b) return false
|
||||||
|
return (
|
||||||
|
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||||
if (promptA.length !== promptB.length) return false
|
if (promptA.length !== promptB.length) return false
|
||||||
for (let i = 0; i < promptA.length; i++) {
|
for (let i = 0; i < promptA.length; i++) {
|
||||||
@@ -43,7 +64,13 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
|||||||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
if (partA.type === "file") {
|
||||||
|
const fileA = partA as FileAttachmentPart
|
||||||
|
const fileB = partB as FileAttachmentPart
|
||||||
|
if (fileA.path !== fileB.path) return false
|
||||||
|
if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
|
||||||
|
}
|
||||||
|
if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
|
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
|
||||||
@@ -53,7 +80,7 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneSelection(selection?: TextSelection) {
|
function cloneSelection(selection?: FileSelection) {
|
||||||
if (!selection) return undefined
|
if (!selection) return undefined
|
||||||
return { ...selection }
|
return { ...selection }
|
||||||
}
|
}
|
||||||
@@ -61,6 +88,7 @@ function cloneSelection(selection?: TextSelection) {
|
|||||||
function clonePart(part: ContentPart): ContentPart {
|
function clonePart(part: ContentPart): ContentPart {
|
||||||
if (part.type === "text") return { ...part }
|
if (part.type === "text") return { ...part }
|
||||||
if (part.type === "image") return { ...part }
|
if (part.type === "image") return { ...part }
|
||||||
|
if (part.type === "agent") return { ...part }
|
||||||
return {
|
return {
|
||||||
...part,
|
...part,
|
||||||
selection: cloneSelection(part.selection),
|
selection: cloneSelection(part.selection),
|
||||||
@@ -75,24 +103,57 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
name: "Prompt",
|
name: "Prompt",
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
|
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
name(),
|
name(),
|
||||||
createStore<{
|
createStore<{
|
||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
cursor?: number
|
cursor?: number
|
||||||
|
context: {
|
||||||
|
activeTab: boolean
|
||||||
|
items: (ContextItem & { key: string })[]
|
||||||
|
}
|
||||||
}>({
|
}>({
|
||||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||||
cursor: undefined,
|
cursor: undefined,
|
||||||
|
context: {
|
||||||
|
activeTab: true,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function keyForItem(item: ContextItem) {
|
||||||
|
if (item.type !== "file") return item.type
|
||||||
|
const start = item.selection?.startLine
|
||||||
|
const end = item.selection?.endLine
|
||||||
|
return `${item.type}:${item.path}:${start}:${end}`
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
current: createMemo(() => store.prompt),
|
current: createMemo(() => store.prompt),
|
||||||
cursor: createMemo(() => store.cursor),
|
cursor: createMemo(() => store.cursor),
|
||||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||||
|
context: {
|
||||||
|
activeTab: createMemo(() => store.context.activeTab),
|
||||||
|
items: createMemo(() => store.context.items),
|
||||||
|
addActive() {
|
||||||
|
setStore("context", "activeTab", true)
|
||||||
|
},
|
||||||
|
removeActive() {
|
||||||
|
setStore("context", "activeTab", false)
|
||||||
|
},
|
||||||
|
add(item: ContextItem) {
|
||||||
|
const key = keyForItem(item)
|
||||||
|
if (store.context.items.find((x) => x.key === key)) return
|
||||||
|
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||||
|
},
|
||||||
|
remove(key: string) {
|
||||||
|
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||||
|
},
|
||||||
|
},
|
||||||
set(prompt: Prompt, cursorPosition?: number) {
|
set(prompt: Prompt, cursorPosition?: number) {
|
||||||
const next = clonePrompt(prompt)
|
const next = clonePrompt(prompt)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return {
|
return {
|
||||||
data: store,
|
data: store,
|
||||||
set: setStore,
|
set: setStore,
|
||||||
|
get status() {
|
||||||
|
return store.status
|
||||||
|
},
|
||||||
get ready() {
|
get ready() {
|
||||||
return store.ready
|
return store.status !== "loading"
|
||||||
},
|
},
|
||||||
get project() {
|
get project() {
|
||||||
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
|
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
|
||||||
@@ -56,14 +59,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||||
messages.splice(result.index, 0, message)
|
messages.splice(result.index, 0, message)
|
||||||
}
|
}
|
||||||
draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
draft.part[input.messageID] = input.parts
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
async sync(sessionID: string, _isRetry = false) {
|
async sync(sessionID: string, _isRetry = false) {
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
retry(() => sdk.client.session.get({ sessionID })),
|
retry(() => sdk.client.session.get({ sessionID })),
|
||||||
retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
|
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
|
||||||
retry(() => sdk.client.session.todo({ sessionID })),
|
retry(() => sdk.client.session.todo({ sessionID })),
|
||||||
retry(() => sdk.client.session.diff({ sessionID })),
|
retry(() => sdk.client.session.diff({ sessionID })),
|
||||||
])
|
])
|
||||||
@@ -88,6 +94,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
reconcile(
|
reconcile(
|
||||||
(messages.data ?? [])
|
(messages.data ?? [])
|
||||||
.map((x) => x.info)
|
.map((x) => x.info)
|
||||||
|
.filter((m) => !!m?.id)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
{ key: "id" },
|
{ key: "id" },
|
||||||
@@ -95,11 +102,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (const message of messages.data ?? []) {
|
for (const message of messages.data ?? []) {
|
||||||
|
if (!message?.info?.id) continue
|
||||||
setStore(
|
setStore(
|
||||||
"part",
|
"part",
|
||||||
message.info.id,
|
message.info.id,
|
||||||
reconcile(
|
reconcile(
|
||||||
message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)),
|
message.parts
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
{ key: "id" },
|
{ key: "id" },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -112,6 +123,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
setStore("limit", (x) => x + count)
|
setStore("limit", (x) => x + count)
|
||||||
await sdk.client.session.list().then((x) => {
|
await sdk.client.session.list().then((x) => {
|
||||||
const sessions = (x.data ?? [])
|
const sessions = (x.data ?? [])
|
||||||
|
.filter((s) => !!s?.id)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
.slice(0, store.limit)
|
.slice(0, store.limit)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams } from "@solidjs/router"
|
|||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { SyncProvider, useSync } from "@/context/sync"
|
import { SyncProvider, useSync } from "@/context/sync"
|
||||||
import { LocalProvider } from "@/context/local"
|
import { LocalProvider } from "@/context/local"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import { DataProvider } from "@opencode-ai/ui/context"
|
import { DataProvider } from "@opencode-ai/ui/context"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
@@ -27,11 +27,9 @@ export default function Layout(props: ParentProps) {
|
|||||||
}) => sdk.client.permission.respond(input)
|
}) => sdk.client.permission.respond(input)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PermissionProvider permissions={sync.data.permission} onRespond={respond}>
|
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
|
||||||
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
<LocalProvider>{props.children}</LocalProvider>
|
</DataProvider>
|
||||||
</DataProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</SyncProvider>
|
</SyncProvider>
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
@@ -44,8 +45,9 @@ import { useProviders } from "@/hooks/use-providers"
|
|||||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { Header } from "@/components/header"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||||
@@ -60,17 +62,9 @@ export default function Layout(props: ParentProps) {
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
lastSession: {} as { [directory: string]: string },
|
lastSession: {} as { [directory: string]: string },
|
||||||
activeDraggable: undefined as string | undefined,
|
activeDraggable: undefined as string | undefined,
|
||||||
mobileSidebarOpen: false,
|
|
||||||
mobileProjectsExpanded: {} as Record<string, boolean>,
|
mobileProjectsExpanded: {} as Record<string, boolean>,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mobileSidebar = {
|
|
||||||
open: () => store.mobileSidebarOpen,
|
|
||||||
show: () => setStore("mobileSidebarOpen", true),
|
|
||||||
hide: () => setStore("mobileSidebarOpen", false),
|
|
||||||
toggle: () => setStore("mobileSidebarOpen", (x) => !x),
|
|
||||||
}
|
|
||||||
|
|
||||||
const mobileProjects = {
|
const mobileProjects = {
|
||||||
expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
|
expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
|
||||||
expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
|
expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
|
||||||
@@ -91,6 +85,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
|
const permission = usePermission()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -131,11 +126,15 @@ export default function Layout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
if (platform.checkUpdate && platform.update && platform.restart) {
|
if (!platform.checkUpdate || !platform.update || !platform.restart) return
|
||||||
const { updateAvailable, version } = await platform.checkUpdate()
|
|
||||||
if (updateAvailable) {
|
let toastId: number | undefined
|
||||||
showToast({
|
|
||||||
|
async function pollUpdate() {
|
||||||
|
const { updateAvailable, version } = await platform.checkUpdate!()
|
||||||
|
if (updateAvailable && toastId === undefined) {
|
||||||
|
toastId = showToast({
|
||||||
persistent: true,
|
persistent: true,
|
||||||
icon: "download",
|
icon: "download",
|
||||||
title: "Update available",
|
title: "Update available",
|
||||||
@@ -156,31 +155,48 @@ export default function Layout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pollUpdate()
|
||||||
|
const interval = setInterval(pollUpdate, 10 * 60 * 1000)
|
||||||
|
onCleanup(() => clearInterval(interval))
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const seenSessions = new Set<string>()
|
|
||||||
const toastBySession = new Map<string, number>()
|
const toastBySession = new Map<string, number>()
|
||||||
|
const alertedAtBySession = new Map<string, number>()
|
||||||
|
const permissionAlertCooldownMs = 5000
|
||||||
|
|
||||||
const unsub = globalSDK.event.listen((e) => {
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
if (e.details?.type !== "permission.updated") return
|
if (e.details?.type !== "permission.asked") return
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const permission = e.details.properties
|
const perm = e.details.properties
|
||||||
const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
if (permission.autoResponds(perm)) return
|
||||||
const currentSession = params.id
|
|
||||||
|
const sessionKey = `${directory}:${perm.sessionID}`
|
||||||
const [store] = globalSync.child(directory)
|
const [store] = globalSync.child(directory)
|
||||||
const session = store.session.find((s) => s.id === permission.sessionID)
|
const session = store.session.find((s) => s.id === perm.sessionID)
|
||||||
|
|
||||||
const sessionTitle = session?.title ?? "New session"
|
const sessionTitle = session?.title ?? "New session"
|
||||||
const projectName = getFilename(directory)
|
const projectName = getFilename(directory)
|
||||||
const description = `${sessionTitle} in ${projectName} needs permission`
|
const description = `${sessionTitle} in ${projectName} needs permission`
|
||||||
const href = `/${base64Encode(directory)}/session/${permission.sessionID}`
|
const href = `/${base64Encode(directory)}/session/${perm.sessionID}`
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
|
||||||
|
if (now - lastAlerted < permissionAlertCooldownMs) return
|
||||||
|
alertedAtBySession.set(sessionKey, now)
|
||||||
|
|
||||||
void platform.notify("Permission required", description, href)
|
void platform.notify("Permission required", description, href)
|
||||||
|
|
||||||
if (directory === currentDir && permission.sessionID === currentSession) return
|
const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
||||||
|
const currentSession = params.id
|
||||||
|
if (directory === currentDir && perm.sessionID === currentSession) return
|
||||||
if (directory === currentDir && session?.parentID === currentSession) return
|
if (directory === currentDir && session?.parentID === currentSession) return
|
||||||
|
|
||||||
const sessionKey = `${directory}:${permission.sessionID}`
|
const existingToastId = toastBySession.get(sessionKey)
|
||||||
if (seenSessions.has(sessionKey)) return
|
if (existingToastId !== undefined) {
|
||||||
seenSessions.add(sessionKey)
|
toaster.dismiss(existingToastId)
|
||||||
|
}
|
||||||
|
|
||||||
const toastId = showToast({
|
const toastId = showToast({
|
||||||
persistent: true,
|
persistent: true,
|
||||||
@@ -213,7 +229,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (toastId !== undefined) {
|
if (toastId !== undefined) {
|
||||||
toaster.dismiss(toastId)
|
toaster.dismiss(toastId)
|
||||||
toastBySession.delete(sessionKey)
|
toastBySession.delete(sessionKey)
|
||||||
seenSessions.delete(sessionKey)
|
alertedAtBySession.delete(sessionKey)
|
||||||
}
|
}
|
||||||
const [store] = globalSync.child(currentDir)
|
const [store] = globalSync.child(currentDir)
|
||||||
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
||||||
@@ -223,7 +239,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (childToastId !== undefined) {
|
if (childToastId !== undefined) {
|
||||||
toaster.dismiss(childToastId)
|
toaster.dismiss(childToastId)
|
||||||
toastBySession.delete(childKey)
|
toastBySession.delete(childKey)
|
||||||
seenSessions.delete(childKey)
|
alertedAtBySession.delete(childKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -444,13 +460,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (!directory) return
|
if (!directory) return
|
||||||
const lastSession = store.lastSession[directory]
|
const lastSession = store.lastSession[directory]
|
||||||
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
|
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
|
||||||
mobileSidebar.hide()
|
layout.mobileSidebar.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToSession(session: Session | undefined) {
|
function navigateToSession(session: Session | undefined) {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
navigate(`/${params.dir}/session/${session?.id}`)
|
navigate(`/${params.dir}/session/${session?.id}`)
|
||||||
mobileSidebar.hide()
|
layout.mobileSidebar.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openProject(directory: string, navigate = true) {
|
function openProject(directory: string, navigate = true) {
|
||||||
@@ -708,17 +724,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
</A>
|
</A>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
|
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
|
||||||
<Tooltip
|
<TooltipKeybind
|
||||||
placement={props.mobile ? "bottom" : "right"}
|
placement={props.mobile ? "bottom" : "right"}
|
||||||
value={
|
title="Archive session"
|
||||||
<div class="flex items-center gap-2">
|
keybind={command.keybind("session.archive")}
|
||||||
<span>Archive session</span>
|
|
||||||
<span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
|
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
|
||||||
</Tooltip>
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -786,17 +798,9 @@ export default function Layout(props: ParentProps) {
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Tooltip
|
<TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
|
||||||
placement="top"
|
|
||||||
value={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>New session</span>
|
|
||||||
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
|
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
|
||||||
</Tooltip>
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
@@ -874,15 +878,16 @@ export default function Layout(props: ParentProps) {
|
|||||||
<>
|
<>
|
||||||
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
|
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
|
||||||
<Show when={!sidebarProps.mobile}>
|
<Show when={!sidebarProps.mobile}>
|
||||||
<Tooltip
|
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2" data-tauri-drag-region>
|
||||||
|
<Mark class="shrink-0" />
|
||||||
|
</A>
|
||||||
|
</Show>
|
||||||
|
<Show when={!sidebarProps.mobile}>
|
||||||
|
<TooltipKeybind
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
placement="right"
|
placement="right"
|
||||||
value={
|
title="Toggle sidebar"
|
||||||
<div class="flex items-center gap-2">
|
keybind={command.keybind("sidebar.toggle")}
|
||||||
<span>Toggle sidebar</span>
|
|
||||||
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
inactive={expanded()}
|
inactive={expanded()}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -914,7 +919,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
@@ -1018,21 +1023,23 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||||
<Header
|
|
||||||
navigateToProject={navigateToProject}
|
|
||||||
navigateToSession={navigateToSession}
|
|
||||||
onMobileMenuToggle={mobileSidebar.toggle}
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-h-0 flex">
|
<div class="flex-1 min-h-0 flex">
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"hidden xl:flex": true,
|
"hidden xl:block": true,
|
||||||
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
|
"relative shrink-0": true,
|
||||||
"flex-col gap-5.5 items-start self-stretch justify-between": true,
|
|
||||||
"border-r border-border-weak-base contain-strict": true,
|
|
||||||
}}
|
}}
|
||||||
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"@container w-full h-full pb-5 bg-background-base": true,
|
||||||
|
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
|
||||||
|
"border-r border-border-weak-base contain-strict": true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SidebarContent />
|
||||||
|
</div>
|
||||||
<Show when={layout.sidebar.opened()}>
|
<Show when={layout.sidebar.opened()}>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
@@ -1044,24 +1051,23 @@ export default function Layout(props: ParentProps) {
|
|||||||
onCollapse={layout.sidebar.close}
|
onCollapse={layout.sidebar.close}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<SidebarContent />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="xl:hidden">
|
<div class="xl:hidden">
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
|
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
|
||||||
"opacity-100 pointer-events-auto": mobileSidebar.open(),
|
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||||
"opacity-0 pointer-events-none": !mobileSidebar.open(),
|
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) mobileSidebar.hide()
|
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
|
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
|
||||||
"translate-x-0": mobileSidebar.open(),
|
"translate-x-0": layout.mobileSidebar.opened(),
|
||||||
"-translate-x-full": !mobileSidebar.open(),
|
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|||||||
+743
-404
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const config = {
|
|||||||
github: {
|
github: {
|
||||||
repoUrl: "https://github.com/sst/opencode",
|
repoUrl: "https://github.com/sst/opencode",
|
||||||
starsFormatted: {
|
starsFormatted: {
|
||||||
compact: "41K",
|
compact: "45K",
|
||||||
full: "41,000",
|
full: "45,000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ export const config = {
|
|||||||
|
|
||||||
// Static stats (used on landing page)
|
// Static stats (used on landing page)
|
||||||
stats: {
|
stats: {
|
||||||
contributors: "450",
|
contributors: "500",
|
||||||
commits: "6,000",
|
commits: "6,500",
|
||||||
monthlyUsers: "400,000",
|
monthlyUsers: "650,000",
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||||
import { createEffect, Show } from "solid-js"
|
import { createEffect, Show, createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
@@ -68,6 +68,12 @@ export function ReloadSection() {
|
|||||||
reloadTrigger: "",
|
reloadTrigger: "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const processingFee = createMemo(() => {
|
||||||
|
const reloadAmount = billingInfo()?.reloadAmount
|
||||||
|
if (!reloadAmount) return "0.00"
|
||||||
|
return (((reloadAmount + 0.3) / 0.956) * 0.044 + 0.3).toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
|
if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
|
||||||
setStore("show", false)
|
setStore("show", false)
|
||||||
@@ -104,8 +110,8 @@ export function ReloadSection() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+$1.23 processing fee)
|
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
|
||||||
when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
|
processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
|
||||||
</p>
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
<button data-color="primary" type="button" onClick={() => show()}>
|
<button data-color="primary" type="button" onClick={() => show()}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsgo -b",
|
"typecheck": "tsgo -b",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE
|
|||||||
|
|
||||||
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
|
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
|
||||||
|
|
||||||
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`
|
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`
|
||||||
|
|
||||||
await $`cd ../opencode && bun run build --single`
|
await $`cd ../opencode && bun run build --single`
|
||||||
|
|
||||||
|
|||||||
@@ -57,19 +57,71 @@ const platform: Platform = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
openLink(url: string) {
|
openLink(url: string) {
|
||||||
shellOpen(url)
|
void shellOpen(url).catch(() => undefined)
|
||||||
},
|
},
|
||||||
|
|
||||||
storage: (name = "default.dat") => {
|
storage: (name = "default.dat") => {
|
||||||
const api: AsyncStorage = {
|
type StoreLike = {
|
||||||
|
get(key: string): Promise<string | null | undefined>
|
||||||
|
set(key: string, value: string): Promise<unknown>
|
||||||
|
delete(key: string): Promise<unknown>
|
||||||
|
clear(): Promise<unknown>
|
||||||
|
keys(): Promise<string[]>
|
||||||
|
length(): Promise<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
const memory = () => {
|
||||||
|
const data = new Map<string, string>()
|
||||||
|
const store: StoreLike = {
|
||||||
|
get: async (key) => data.get(key),
|
||||||
|
set: async (key, value) => {
|
||||||
|
data.set(key, value)
|
||||||
|
},
|
||||||
|
delete: async (key) => {
|
||||||
|
data.delete(key)
|
||||||
|
},
|
||||||
|
clear: async () => {
|
||||||
|
data.clear()
|
||||||
|
},
|
||||||
|
keys: async () => Array.from(data.keys()),
|
||||||
|
length: async () => data.size,
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
const api: AsyncStorage & { _store: Promise<StoreLike> | null; _getStore: () => Promise<StoreLike> } = {
|
||||||
_store: null,
|
_store: null,
|
||||||
_getStore: async () => api._store || (api._store = Store.load(name)),
|
_getStore: async () => {
|
||||||
getItem: async (key: string) => (await (await api._getStore()).get(key)) ?? null,
|
if (api._store) return api._store
|
||||||
setItem: async (key: string, value: string) => await (await api._getStore()).set(key, value),
|
api._store = Store.load(name).catch(() => memory())
|
||||||
removeItem: async (key: string) => await (await api._getStore()).delete(key),
|
return api._store
|
||||||
clear: async () => await (await api._getStore()).clear(),
|
},
|
||||||
key: async (index: number) => (await (await api._getStore()).keys())[index],
|
getItem: async (key: string) => {
|
||||||
getLength: async () => (await api._getStore()).length(),
|
const store = await api._getStore()
|
||||||
|
const value = await store.get(key).catch(() => null)
|
||||||
|
if (value === undefined) return null
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
setItem: async (key: string, value: string) => {
|
||||||
|
const store = await api._getStore()
|
||||||
|
await store.set(key, value).catch(() => undefined)
|
||||||
|
},
|
||||||
|
removeItem: async (key: string) => {
|
||||||
|
const store = await api._getStore()
|
||||||
|
await store.delete(key).catch(() => undefined)
|
||||||
|
},
|
||||||
|
clear: async () => {
|
||||||
|
const store = await api._getStore()
|
||||||
|
await store.clear().catch(() => undefined)
|
||||||
|
},
|
||||||
|
key: async (index: number) => {
|
||||||
|
const store = await api._getStore()
|
||||||
|
return (await store.keys().catch(() => []))[index]
|
||||||
|
},
|
||||||
|
getLength: async () => {
|
||||||
|
const store = await api._getStore()
|
||||||
|
return await store.length().catch(() => 0)
|
||||||
|
},
|
||||||
get length() {
|
get length() {
|
||||||
return api.getLength()
|
return api.getLength()
|
||||||
},
|
},
|
||||||
@@ -79,20 +131,25 @@ const platform: Platform = {
|
|||||||
|
|
||||||
checkUpdate: async () => {
|
checkUpdate: async () => {
|
||||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
||||||
update = await check()
|
const next = await check().catch(() => null)
|
||||||
if (!update) return { updateAvailable: false }
|
if (!next) return { updateAvailable: false }
|
||||||
await update.download()
|
const ok = await next
|
||||||
return { updateAvailable: true, version: update.version }
|
.download()
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!ok) return { updateAvailable: false }
|
||||||
|
update = next
|
||||||
|
return { updateAvailable: true, version: next.version }
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async () => {
|
update: async () => {
|
||||||
if (!UPDATER_ENABLED || !update) return
|
if (!UPDATER_ENABLED || !update) return
|
||||||
if (ostype() === "windows") await invoke("kill_sidecar")
|
if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined)
|
||||||
await update.install()
|
await update.install().catch(() => undefined)
|
||||||
},
|
},
|
||||||
|
|
||||||
restart: async () => {
|
restart: async () => {
|
||||||
await invoke("kill_sidecar")
|
await invoke("kill_sidecar").catch(() => undefined)
|
||||||
await relaunch()
|
await relaunch()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m)
|
|||||||
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
|
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
|
||||||
import("@opencode-ai/ui/pierre/worker").then((m) => ({
|
import("@opencode-ai/ui/pierre/worker").then((m) => ({
|
||||||
default: (props: { children: any }) => (
|
default: (props: { children: any }) => (
|
||||||
<WorkerPoolProvider pool={m.workerPool}>{props.children}</WorkerPoolProvider>
|
<WorkerPoolProvider pools={m.getWorkerPools()}>{props.children}</WorkerPoolProvider>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.0.218"
|
version = "1.0.224"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/sst/opencode"
|
repository = "https://github.com/sst/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-darwin-arm64.zip"
|
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-darwin-x64.zip"
|
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-windows-x64.zip"
|
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.0.218",
|
"version": "1.0.224",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"ai": "catalog:",
|
"ai": "catalog:",
|
||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
"bun-pty": "0.4.2",
|
"bun-pty": "0.4.4",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ if (!Script.preview) {
|
|||||||
"provides=('opencode')",
|
"provides=('opencode')",
|
||||||
"conflicts=('opencode-bin')",
|
"conflicts=('opencode-bin')",
|
||||||
"depends=('ripgrep')",
|
"depends=('ripgrep')",
|
||||||
"makedepends=('git' 'bun-bin' 'go')",
|
"makedepends=('git' 'bun' 'go')",
|
||||||
"",
|
"",
|
||||||
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
|
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
|
||||||
`sha256sums=('SKIP')`,
|
`sha256sums=('SKIP')`,
|
||||||
|
|||||||
@@ -71,19 +71,19 @@ export namespace ACP {
|
|||||||
this.config.sdk.event.subscribe({ directory }).then(async (events) => {
|
this.config.sdk.event.subscribe({ directory }).then(async (events) => {
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "permission.updated":
|
case "permission.asked":
|
||||||
try {
|
try {
|
||||||
const permission = event.properties
|
const permission = event.properties
|
||||||
const res = await this.connection
|
const res = await this.connection
|
||||||
.requestPermission({
|
.requestPermission({
|
||||||
sessionId,
|
sessionId,
|
||||||
toolCall: {
|
toolCall: {
|
||||||
toolCallId: permission.callID ?? permission.id,
|
toolCallId: permission.tool?.callID ?? permission.id,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
title: permission.title,
|
title: permission.permission,
|
||||||
rawInput: permission.metadata,
|
rawInput: permission.metadata,
|
||||||
kind: toToolKind(permission.type),
|
kind: toToolKind(permission.permission),
|
||||||
locations: toLocations(permission.type, permission.metadata),
|
locations: toLocations(permission.permission, permission.metadata),
|
||||||
},
|
},
|
||||||
options,
|
options,
|
||||||
})
|
})
|
||||||
@@ -93,28 +93,25 @@ export namespace ACP {
|
|||||||
permissionID: permission.id,
|
permissionID: permission.id,
|
||||||
sessionID: permission.sessionID,
|
sessionID: permission.sessionID,
|
||||||
})
|
})
|
||||||
await this.config.sdk.permission.respond({
|
await this.config.sdk.permission.reply({
|
||||||
sessionID: permission.sessionID,
|
requestID: permission.id,
|
||||||
permissionID: permission.id,
|
reply: "reject",
|
||||||
response: "reject",
|
|
||||||
directory,
|
directory,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
})
|
})
|
||||||
if (!res) return
|
if (!res) return
|
||||||
if (res.outcome.outcome !== "selected") {
|
if (res.outcome.outcome !== "selected") {
|
||||||
await this.config.sdk.permission.respond({
|
await this.config.sdk.permission.reply({
|
||||||
sessionID: permission.sessionID,
|
requestID: permission.id,
|
||||||
permissionID: permission.id,
|
reply: "reject",
|
||||||
response: "reject",
|
|
||||||
directory,
|
directory,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await this.config.sdk.permission.respond({
|
await this.config.sdk.permission.reply({
|
||||||
sessionID: permission.sessionID,
|
requestID: permission.id,
|
||||||
permissionID: permission.id,
|
reply: res.outcome.optionId as "once" | "always" | "reject",
|
||||||
response: res.outcome.optionId as "once" | "always" | "reject",
|
|
||||||
directory,
|
directory,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ import { Provider } from "../provider/provider"
|
|||||||
import { generateObject, type ModelMessage } from "ai"
|
import { generateObject, type ModelMessage } from "ai"
|
||||||
import { SystemPrompt } from "../session/system"
|
import { SystemPrompt } from "../session/system"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { mergeDeep } from "remeda"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "agent" })
|
|
||||||
|
|
||||||
import PROMPT_GENERATE from "./generate.txt"
|
import PROMPT_GENERATE from "./generate.txt"
|
||||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||||
import PROMPT_TITLE from "./prompt/title.txt"
|
import PROMPT_TITLE from "./prompt/title.txt"
|
||||||
|
import { PermissionNext } from "@/permission/next"
|
||||||
|
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||||
|
|
||||||
export namespace Agent {
|
export namespace Agent {
|
||||||
export const Info = z
|
export const Info = z
|
||||||
@@ -23,18 +21,10 @@ export namespace Agent {
|
|||||||
mode: z.enum(["subagent", "primary", "all"]),
|
mode: z.enum(["subagent", "primary", "all"]),
|
||||||
native: z.boolean().optional(),
|
native: z.boolean().optional(),
|
||||||
hidden: z.boolean().optional(),
|
hidden: z.boolean().optional(),
|
||||||
default: z.boolean().optional(),
|
|
||||||
topP: z.number().optional(),
|
topP: z.number().optional(),
|
||||||
temperature: z.number().optional(),
|
temperature: z.number().optional(),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
permission: z.object({
|
permission: PermissionNext.Ruleset,
|
||||||
edit: Config.Permission,
|
|
||||||
bash: z.record(z.string(), Config.Permission),
|
|
||||||
skill: z.record(z.string(), Config.Permission),
|
|
||||||
webfetch: Config.Permission.optional(),
|
|
||||||
doom_loop: Config.Permission.optional(),
|
|
||||||
external_directory: Config.Permission.optional(),
|
|
||||||
}),
|
|
||||||
model: z
|
model: z
|
||||||
.object({
|
.object({
|
||||||
modelID: z.string(),
|
modelID: z.string(),
|
||||||
@@ -42,9 +32,8 @@ export namespace Agent {
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
prompt: z.string().optional(),
|
prompt: z.string().optional(),
|
||||||
tools: z.record(z.string(), z.boolean()),
|
|
||||||
options: z.record(z.string(), z.any()),
|
options: z.record(z.string(), z.any()),
|
||||||
maxSteps: z.number().int().positive().optional(),
|
steps: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
.meta({
|
.meta({
|
||||||
ref: "Agent",
|
ref: "Agent",
|
||||||
@@ -53,113 +42,74 @@ export namespace Agent {
|
|||||||
|
|
||||||
const state = Instance.state(async () => {
|
const state = Instance.state(async () => {
|
||||||
const cfg = await Config.get()
|
const cfg = await Config.get()
|
||||||
const defaultTools = cfg.tools ?? {}
|
|
||||||
const defaultPermission: Info["permission"] = {
|
const defaults = PermissionNext.fromConfig({
|
||||||
edit: "allow",
|
"*": "allow",
|
||||||
bash: {
|
|
||||||
"*": "allow",
|
|
||||||
},
|
|
||||||
skill: {
|
|
||||||
"*": "allow",
|
|
||||||
},
|
|
||||||
webfetch: "allow",
|
|
||||||
doom_loop: "ask",
|
doom_loop: "ask",
|
||||||
external_directory: "ask",
|
external_directory: "ask",
|
||||||
}
|
})
|
||||||
const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})
|
const user = PermissionNext.fromConfig(cfg.permission ?? {})
|
||||||
|
|
||||||
const planPermission = mergeAgentPermissions(
|
|
||||||
{
|
|
||||||
edit: "deny",
|
|
||||||
bash: {
|
|
||||||
"cut*": "allow",
|
|
||||||
"diff*": "allow",
|
|
||||||
"du*": "allow",
|
|
||||||
"file *": "allow",
|
|
||||||
"find * -delete*": "ask",
|
|
||||||
"find * -exec*": "ask",
|
|
||||||
"find * -fprint*": "ask",
|
|
||||||
"find * -fls*": "ask",
|
|
||||||
"find * -fprintf*": "ask",
|
|
||||||
"find * -ok*": "ask",
|
|
||||||
"find *": "allow",
|
|
||||||
"git diff*": "allow",
|
|
||||||
"git log*": "allow",
|
|
||||||
"git show*": "allow",
|
|
||||||
"git status*": "allow",
|
|
||||||
"git branch": "allow",
|
|
||||||
"git branch -v": "allow",
|
|
||||||
"grep*": "allow",
|
|
||||||
"head*": "allow",
|
|
||||||
"less*": "allow",
|
|
||||||
"ls*": "allow",
|
|
||||||
"more*": "allow",
|
|
||||||
"pwd*": "allow",
|
|
||||||
"rg*": "allow",
|
|
||||||
"sort --output=*": "ask",
|
|
||||||
"sort -o *": "ask",
|
|
||||||
"sort*": "allow",
|
|
||||||
"stat*": "allow",
|
|
||||||
"tail*": "allow",
|
|
||||||
"tree -o *": "ask",
|
|
||||||
"tree*": "allow",
|
|
||||||
"uniq*": "allow",
|
|
||||||
"wc*": "allow",
|
|
||||||
"whereis*": "allow",
|
|
||||||
"which*": "allow",
|
|
||||||
"*": "ask",
|
|
||||||
},
|
|
||||||
webfetch: "allow",
|
|
||||||
},
|
|
||||||
cfg.permission ?? {},
|
|
||||||
)
|
|
||||||
|
|
||||||
const result: Record<string, Info> = {
|
const result: Record<string, Info> = {
|
||||||
build: {
|
build: {
|
||||||
name: "build",
|
name: "build",
|
||||||
tools: { ...defaultTools },
|
|
||||||
options: {},
|
options: {},
|
||||||
permission: agentPermission,
|
permission: PermissionNext.merge(defaults, user),
|
||||||
mode: "primary",
|
mode: "primary",
|
||||||
native: true,
|
native: true,
|
||||||
},
|
},
|
||||||
plan: {
|
plan: {
|
||||||
name: "plan",
|
name: "plan",
|
||||||
options: {},
|
options: {},
|
||||||
permission: planPermission,
|
permission: PermissionNext.merge(
|
||||||
tools: {
|
defaults,
|
||||||
...defaultTools,
|
PermissionNext.fromConfig({
|
||||||
},
|
edit: {
|
||||||
|
"*": "deny",
|
||||||
|
".opencode/plan/*.md": "allow",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
user,
|
||||||
|
),
|
||||||
mode: "primary",
|
mode: "primary",
|
||||||
native: true,
|
native: true,
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
name: "general",
|
name: "general",
|
||||||
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
||||||
tools: {
|
permission: PermissionNext.merge(
|
||||||
todoread: false,
|
defaults,
|
||||||
todowrite: false,
|
PermissionNext.fromConfig({
|
||||||
...defaultTools,
|
todoread: "deny",
|
||||||
},
|
todowrite: "deny",
|
||||||
|
}),
|
||||||
|
user,
|
||||||
|
),
|
||||||
options: {},
|
options: {},
|
||||||
permission: agentPermission,
|
|
||||||
mode: "subagent",
|
mode: "subagent",
|
||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
explore: {
|
explore: {
|
||||||
name: "explore",
|
name: "explore",
|
||||||
tools: {
|
permission: PermissionNext.merge(
|
||||||
todoread: false,
|
defaults,
|
||||||
todowrite: false,
|
PermissionNext.fromConfig({
|
||||||
edit: false,
|
"*": "deny",
|
||||||
write: false,
|
grep: "allow",
|
||||||
...defaultTools,
|
glob: "allow",
|
||||||
},
|
list: "allow",
|
||||||
|
bash: "allow",
|
||||||
|
webfetch: "allow",
|
||||||
|
websearch: "allow",
|
||||||
|
codesearch: "allow",
|
||||||
|
read: "allow",
|
||||||
|
}),
|
||||||
|
user,
|
||||||
|
),
|
||||||
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
|
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
|
||||||
prompt: PROMPT_EXPLORE,
|
prompt: PROMPT_EXPLORE,
|
||||||
options: {},
|
options: {},
|
||||||
permission: agentPermission,
|
|
||||||
mode: "subagent",
|
mode: "subagent",
|
||||||
native: true,
|
native: true,
|
||||||
},
|
},
|
||||||
@@ -169,11 +119,14 @@ export namespace Agent {
|
|||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
prompt: PROMPT_COMPACTION,
|
prompt: PROMPT_COMPACTION,
|
||||||
tools: {
|
permission: PermissionNext.merge(
|
||||||
"*": false,
|
defaults,
|
||||||
},
|
PermissionNext.fromConfig({
|
||||||
|
"*": "deny",
|
||||||
|
}),
|
||||||
|
user,
|
||||||
|
),
|
||||||
options: {},
|
options: {},
|
||||||
permission: agentPermission,
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
name: "title",
|
name: "title",
|
||||||
@@ -181,9 +134,14 @@ export namespace Agent {
|
|||||||
options: {},
|
options: {},
|
||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
permission: agentPermission,
|
permission: PermissionNext.merge(
|
||||||
|
defaults,
|
||||||
|
PermissionNext.fromConfig({
|
||||||
|
"*": "deny",
|
||||||
|
}),
|
||||||
|
user,
|
||||||
|
),
|
||||||
prompt: PROMPT_TITLE,
|
prompt: PROMPT_TITLE,
|
||||||
tools: {},
|
|
||||||
},
|
},
|
||||||
summary: {
|
summary: {
|
||||||
name: "summary",
|
name: "summary",
|
||||||
@@ -191,11 +149,17 @@ export namespace Agent {
|
|||||||
options: {},
|
options: {},
|
||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
permission: agentPermission,
|
permission: PermissionNext.merge(
|
||||||
|
defaults,
|
||||||
|
PermissionNext.fromConfig({
|
||||||
|
"*": "deny",
|
||||||
|
}),
|
||||||
|
user,
|
||||||
|
),
|
||||||
prompt: PROMPT_SUMMARY,
|
prompt: PROMPT_SUMMARY,
|
||||||
tools: {},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
||||||
if (value.disable) {
|
if (value.disable) {
|
||||||
delete result[key]
|
delete result[key]
|
||||||
@@ -206,74 +170,22 @@ export namespace Agent {
|
|||||||
item = result[key] = {
|
item = result[key] = {
|
||||||
name: key,
|
name: key,
|
||||||
mode: "all",
|
mode: "all",
|
||||||
permission: agentPermission,
|
permission: PermissionNext.merge(defaults, user),
|
||||||
options: {},
|
options: {},
|
||||||
tools: {},
|
|
||||||
native: false,
|
native: false,
|
||||||
}
|
}
|
||||||
const {
|
if (value.model) item.model = Provider.parseModel(value.model)
|
||||||
name,
|
item.prompt = value.prompt ?? item.prompt
|
||||||
model,
|
item.description = value.description ?? item.description
|
||||||
prompt,
|
item.temperature = value.temperature ?? item.temperature
|
||||||
tools,
|
item.topP = value.top_p ?? item.topP
|
||||||
description,
|
item.mode = value.mode ?? item.mode
|
||||||
temperature,
|
item.color = value.color ?? item.color
|
||||||
top_p,
|
item.name = value.options?.name ?? item.name
|
||||||
mode,
|
item.steps = value.steps ?? item.steps
|
||||||
permission,
|
item.options = mergeDeep(item.options, value.options ?? {})
|
||||||
color,
|
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
|
||||||
maxSteps,
|
|
||||||
...extra
|
|
||||||
} = value
|
|
||||||
item.options = {
|
|
||||||
...item.options,
|
|
||||||
...extra,
|
|
||||||
}
|
|
||||||
if (model) item.model = Provider.parseModel(model)
|
|
||||||
if (prompt) item.prompt = prompt
|
|
||||||
if (tools)
|
|
||||||
item.tools = {
|
|
||||||
...item.tools,
|
|
||||||
...tools,
|
|
||||||
}
|
|
||||||
item.tools = {
|
|
||||||
...defaultTools,
|
|
||||||
...item.tools,
|
|
||||||
}
|
|
||||||
if (description) item.description = description
|
|
||||||
if (temperature != undefined) item.temperature = temperature
|
|
||||||
if (top_p != undefined) item.topP = top_p
|
|
||||||
if (mode) item.mode = mode
|
|
||||||
if (color) item.color = color
|
|
||||||
// just here for consistency & to prevent it from being added as an option
|
|
||||||
if (name) item.name = name
|
|
||||||
if (maxSteps != undefined) item.maxSteps = maxSteps
|
|
||||||
|
|
||||||
if (permission ?? cfg.permission) {
|
|
||||||
item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the default agent
|
|
||||||
const defaultName = cfg.default_agent ?? "build"
|
|
||||||
const defaultCandidate = result[defaultName]
|
|
||||||
if (defaultCandidate && defaultCandidate.mode !== "subagent") {
|
|
||||||
defaultCandidate.default = true
|
|
||||||
} else {
|
|
||||||
// Fall back to "build" if configured default is invalid
|
|
||||||
if (result["build"]) {
|
|
||||||
result["build"].default = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasPrimaryAgents = Object.values(result).filter((a) => a.mode !== "subagent" && !a.hidden).length > 0
|
|
||||||
if (!hasPrimaryAgents) {
|
|
||||||
throw new Config.InvalidError({
|
|
||||||
path: "config",
|
|
||||||
message: "No primary agents are available. Please configure at least one agent with mode 'primary' or 'all'.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -282,13 +194,16 @@ export namespace Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return state().then((x) => Object.values(x))
|
const cfg = await Config.get()
|
||||||
|
return pipe(
|
||||||
|
await state(),
|
||||||
|
values(),
|
||||||
|
sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function defaultAgent(): Promise<string> {
|
export async function defaultAgent() {
|
||||||
const agents = await state()
|
return state().then((x) => Object.keys(x)[0])
|
||||||
const defaultCandidate = Object.values(agents).find((a) => a.default)
|
|
||||||
return defaultCandidate?.name ?? "build"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
|
export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
|
||||||
@@ -329,70 +244,3 @@ export namespace Agent {
|
|||||||
return result.object
|
return result.object
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
|
|
||||||
if (typeof basePermission.bash === "string") {
|
|
||||||
basePermission.bash = {
|
|
||||||
"*": basePermission.bash,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof overridePermission.bash === "string") {
|
|
||||||
overridePermission.bash = {
|
|
||||||
"*": overridePermission.bash,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof basePermission.skill === "string") {
|
|
||||||
basePermission.skill = {
|
|
||||||
"*": basePermission.skill,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof overridePermission.skill === "string") {
|
|
||||||
overridePermission.skill = {
|
|
||||||
"*": overridePermission.skill,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
|
|
||||||
let mergedBash
|
|
||||||
if (merged.bash) {
|
|
||||||
if (typeof merged.bash === "string") {
|
|
||||||
mergedBash = {
|
|
||||||
"*": merged.bash,
|
|
||||||
}
|
|
||||||
} else if (typeof merged.bash === "object") {
|
|
||||||
mergedBash = mergeDeep(
|
|
||||||
{
|
|
||||||
"*": "allow",
|
|
||||||
},
|
|
||||||
merged.bash,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mergedSkill
|
|
||||||
if (merged.skill) {
|
|
||||||
if (typeof merged.skill === "string") {
|
|
||||||
mergedSkill = {
|
|
||||||
"*": merged.skill,
|
|
||||||
}
|
|
||||||
} else if (typeof merged.skill === "object") {
|
|
||||||
mergedSkill = mergeDeep(
|
|
||||||
{
|
|
||||||
"*": "allow",
|
|
||||||
},
|
|
||||||
merged.skill,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: Agent.Info["permission"] = {
|
|
||||||
edit: merged.edit ?? "allow",
|
|
||||||
webfetch: merged.webfetch ?? "allow",
|
|
||||||
bash: mergedBash ?? { "*": "allow" },
|
|
||||||
skill: mergedSkill ?? { "*": "allow" },
|
|
||||||
doom_loop: merged.doom_loop,
|
|
||||||
external_directory: merged.external_directory,
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -73,8 +73,24 @@ export namespace BunProc {
|
|||||||
})
|
})
|
||||||
if (parsed.dependencies[pkg] === version) return mod
|
if (parsed.dependencies[pkg] === version) return mod
|
||||||
|
|
||||||
|
const proxied = !!(
|
||||||
|
process.env.HTTP_PROXY ||
|
||||||
|
process.env.HTTPS_PROXY ||
|
||||||
|
process.env.http_proxy ||
|
||||||
|
process.env.https_proxy
|
||||||
|
)
|
||||||
|
|
||||||
// Build command arguments
|
// Build command arguments
|
||||||
const args = ["add", "--force", "--exact", "--cwd", Global.Path.cache, pkg + "@" + version]
|
const args = [
|
||||||
|
"add",
|
||||||
|
"--force",
|
||||||
|
"--exact",
|
||||||
|
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||||
|
...(proxied ? ["--no-cache"] : []),
|
||||||
|
"--cwd",
|
||||||
|
Global.Path.cache,
|
||||||
|
pkg + "@" + version,
|
||||||
|
]
|
||||||
|
|
||||||
// Let Bun handle registry resolution:
|
// Let Bun handle registry resolution:
|
||||||
// - If .npmrc files exist, Bun will use them automatically
|
// - If .npmrc files exist, Bun will use them automatically
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
|||||||
|
|
||||||
const log = Log.create({ service: "acp-command" })
|
const log = Log.create({ service: "acp-command" })
|
||||||
|
|
||||||
process.on("unhandledRejection", (reason, promise) => {
|
|
||||||
log.error("Unhandled rejection", {
|
|
||||||
promise,
|
|
||||||
reason,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const AcpCommand = cmd({
|
export const AcpCommand = cmd({
|
||||||
command: "acp",
|
command: "acp",
|
||||||
describe: "start ACP (Agent Client Protocol) server",
|
describe: "start ACP (Agent Client Protocol) server",
|
||||||
|
|||||||
@@ -241,7 +241,8 @@ const AgentListCommand = cmd({
|
|||||||
})
|
})
|
||||||
|
|
||||||
for (const agent of sortedAgents) {
|
for (const agent of sortedAgents) {
|
||||||
process.stdout.write(`${agent.name} (${agent.mode})${EOL}`)
|
process.stdout.write(`${agent.name} (${agent.mode})` + EOL)
|
||||||
|
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
|
|||||||
const method = plugin.auth.methods[index]
|
const method = plugin.auth.methods[index]
|
||||||
|
|
||||||
// Handle prompts for all auth types
|
// Handle prompts for all auth types
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
await Bun.sleep(10)
|
||||||
const inputs: Record<string, string> = {}
|
const inputs: Record<string, string> = {}
|
||||||
if (method.prompts) {
|
if (method.prompts) {
|
||||||
for (const prompt of method.prompts) {
|
for (const prompt of method.prompts) {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { EOL } from "os"
|
||||||
|
import { basename } from "path"
|
||||||
|
import { Agent } from "../../../agent/agent"
|
||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
|
export const AgentCommand = cmd({
|
||||||
|
command: "agent <name>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("name", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
description: "Agent name",
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap(process.cwd(), async () => {
|
||||||
|
const agentName = args.name as string
|
||||||
|
const agent = await Agent.get(agentName)
|
||||||
|
if (!agent) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
process.stdout.write(JSON.stringify(agent, null, 2) + EOL)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import { RipgrepCommand } from "./ripgrep"
|
|||||||
import { ScrapCommand } from "./scrap"
|
import { ScrapCommand } from "./scrap"
|
||||||
import { SkillCommand } from "./skill"
|
import { SkillCommand } from "./skill"
|
||||||
import { SnapshotCommand } from "./snapshot"
|
import { SnapshotCommand } from "./snapshot"
|
||||||
|
import { AgentCommand } from "./agent"
|
||||||
|
|
||||||
export const DebugCommand = cmd({
|
export const DebugCommand = cmd({
|
||||||
command: "debug",
|
command: "debug",
|
||||||
@@ -20,6 +21,7 @@ export const DebugCommand = cmd({
|
|||||||
.command(ScrapCommand)
|
.command(ScrapCommand)
|
||||||
.command(SkillCommand)
|
.command(SkillCommand)
|
||||||
.command(SnapshotCommand)
|
.command(SnapshotCommand)
|
||||||
|
.command(AgentCommand)
|
||||||
.command(PathsCommand)
|
.command(PathsCommand)
|
||||||
.command({
|
.command({
|
||||||
command: "wait",
|
command: "wait",
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ export const GithubInstallCommand = cmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
retries++
|
retries++
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
await Bun.sleep(1000)
|
||||||
} while (true)
|
} while (true)
|
||||||
|
|
||||||
s.stop("Installed GitHub app")
|
s.stop("Installed GitHub app")
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ export const ImportCommand = cmd({
|
|||||||
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
|
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
|
||||||
|
|
||||||
if (isUrl) {
|
if (isUrl) {
|
||||||
const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
|
const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/share\/([a-zA-Z0-9_-]+)/)
|
||||||
if (!urlMatch) {
|
if (!urlMatch) {
|
||||||
process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/s/<slug>`)
|
process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/share/<slug>`)
|
||||||
process.stdout.write(EOL)
|
process.stdout.write(EOL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,14 +202,14 @@ export const RunCommand = cmd({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "permission.updated") {
|
if (event.type === "permission.asked") {
|
||||||
const permission = event.properties
|
const permission = event.properties
|
||||||
if (permission.sessionID !== sessionID) continue
|
if (permission.sessionID !== sessionID) continue
|
||||||
const result = await select({
|
const result = await select({
|
||||||
message: `Permission required to run: ${permission.title}`,
|
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
|
||||||
options: [
|
options: [
|
||||||
{ value: "once", label: "Allow once" },
|
{ value: "once", label: "Allow once" },
|
||||||
{ value: "always", label: "Always allow" },
|
{ value: "always", label: "Always allow: " + permission.always.join(", ") },
|
||||||
{ value: "reject", label: "Reject" },
|
{ value: "reject", label: "Reject" },
|
||||||
],
|
],
|
||||||
initialValue: "once",
|
initialValue: "once",
|
||||||
|
|||||||
@@ -4,7 +4,36 @@ import { Session } from "../../session"
|
|||||||
import { bootstrap } from "../bootstrap"
|
import { bootstrap } from "../bootstrap"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
|
import { Flag } from "../../flag/flag"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
function pagerCmd(): string[] {
|
||||||
|
const lessOptions = ["-R", "-S"]
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return ["less", ...lessOptions]
|
||||||
|
}
|
||||||
|
|
||||||
|
// user could have less installed via other options
|
||||||
|
const lessOnPath = Bun.which("less")
|
||||||
|
if (lessOnPath) {
|
||||||
|
if (Bun.file(lessOnPath).size) return [lessOnPath, ...lessOptions]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Flag.OPENCODE_GIT_BASH_PATH) {
|
||||||
|
const less = path.join(Flag.OPENCODE_GIT_BASH_PATH, "..", "..", "usr", "bin", "less.exe")
|
||||||
|
if (Bun.file(less).size) return [less, ...lessOptions]
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = Bun.which("git")
|
||||||
|
if (git) {
|
||||||
|
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
|
||||||
|
if (Bun.file(less).size) return [less, ...lessOptions]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Windows built-in more (via cmd.exe)
|
||||||
|
return ["cmd", "/c", "more"]
|
||||||
|
}
|
||||||
|
|
||||||
export const SessionCommand = cmd({
|
export const SessionCommand = cmd({
|
||||||
command: "session",
|
command: "session",
|
||||||
@@ -58,7 +87,7 @@ export const SessionListCommand = cmd({
|
|||||||
|
|
||||||
if (shouldPaginate) {
|
if (shouldPaginate) {
|
||||||
const proc = Bun.spawn({
|
const proc = Bun.spawn({
|
||||||
cmd: ["less", "-R", "-S"],
|
cmd: pagerCmd(),
|
||||||
stdin: "pipe",
|
stdin: "pipe",
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
|||||||
return Date.now() - days * MS_IN_DAY
|
return Date.now() - days * MS_IN_DAY
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
const windowDays = (() => {
|
||||||
|
if (days === undefined) return
|
||||||
|
if (days === 0) return 1
|
||||||
|
return days
|
||||||
|
})()
|
||||||
|
|
||||||
let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
|
let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
|
||||||
|
|
||||||
if (projectFilter !== undefined) {
|
if (projectFilter !== undefined) {
|
||||||
@@ -159,6 +165,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filteredSessions.length === 0) {
|
if (filteredSessions.length === 0) {
|
||||||
|
stats.days = windowDays ?? 0
|
||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +238,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
|||||||
sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
|
sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
|
||||||
sessionToolUsage,
|
sessionToolUsage,
|
||||||
sessionModelUsage,
|
sessionModelUsage,
|
||||||
earliestTime: session.time.created,
|
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
|
||||||
latestTime: session.time.updated,
|
latestTime: session.time.updated,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -271,13 +278,14 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY))
|
const rangeDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY))
|
||||||
|
const effectiveDays = windowDays ?? rangeDays
|
||||||
stats.dateRange = {
|
stats.dateRange = {
|
||||||
earliest: earliestTime,
|
earliest: earliestTime,
|
||||||
latest: latestTime,
|
latest: latestTime,
|
||||||
}
|
}
|
||||||
stats.days = actualDays
|
stats.days = effectiveDays
|
||||||
stats.costPerDay = stats.totalCost / actualDays
|
stats.costPerDay = stats.totalCost / effectiveDays
|
||||||
const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning
|
const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning
|
||||||
stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0
|
stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0
|
||||||
sessionTotalTokens.sort((a, b) => a - b)
|
sessionTotalTokens.sort((a, b) => a - b)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { TextAttributes } from "@opentui/core"
|
|||||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { Global } from "@/global"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||||
@@ -34,6 +33,7 @@ import { KVProvider, useKV } from "./context/kv"
|
|||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
|
import { writeHeapSnapshot } from "v8"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
|
|
||||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||||
@@ -476,6 +476,20 @@ function App() {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Write heap snapshot",
|
||||||
|
category: "System",
|
||||||
|
value: "app.heap_snapshot",
|
||||||
|
onSelect: (dialog) => {
|
||||||
|
const path = writeHeapSnapshot()
|
||||||
|
toast.show({
|
||||||
|
variant: "info",
|
||||||
|
message: `Heap snapshot written to ${path}`,
|
||||||
|
duration: 5000,
|
||||||
|
})
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Suspend terminal",
|
title: "Suspend terminal",
|
||||||
value: "terminal.suspend",
|
value: "terminal.suspend",
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export function Autocomplete(props: {
|
|||||||
const s = session()
|
const s = session()
|
||||||
for (const command of sync.data.command) {
|
for (const command of sync.data.command) {
|
||||||
results.push({
|
results.push({
|
||||||
display: "/" + command.name,
|
display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
|
||||||
description: command.description,
|
description: command.description,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
const newText = "/" + command.name + " "
|
const newText = "/" + command.name + " "
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { useKV } from "../../context/kv"
|
|||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
|
visible?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
ref?: (ref: PromptRef) => void
|
ref?: (ref: PromptRef) => void
|
||||||
@@ -202,7 +203,11 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
syncedSessionID = sessionID
|
syncedSessionID = sessionID
|
||||||
|
|
||||||
if (msg.agent) local.agent.set(msg.agent)
|
// Only set agent if it's a primary agent (not a subagent)
|
||||||
|
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
|
||||||
|
if (msg.agent && isPrimaryAgent) {
|
||||||
|
local.agent.set(msg.agent)
|
||||||
|
}
|
||||||
if (msg.model) local.model.set(msg.model)
|
if (msg.model) local.model.set(msg.model)
|
||||||
if (msg.variant) local.model.variant.set(msg.variant)
|
if (msg.variant) local.model.variant.set(msg.variant)
|
||||||
}
|
}
|
||||||
@@ -369,7 +374,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
input.focus()
|
if (props.visible !== false) input?.focus()
|
||||||
|
if (props.visible === false) input?.blur()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -794,7 +800,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
agentStyleId={agentStyleId}
|
agentStyleId={agentStyleId}
|
||||||
promptPartTypeId={() => promptPartTypeId}
|
promptPartTypeId={() => promptPartTypeId}
|
||||||
/>
|
/>
|
||||||
<box ref={(r) => (anchor = r)}>
|
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
|
||||||
<box
|
<box
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={highlight()}
|
borderColor={highlight()}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const TIPS = [
|
|||||||
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
|
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
|
||||||
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
|
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
|
||||||
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
|
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
|
||||||
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
|
"Use {highlight}@<agent-name>{/highlight} in prompts to invoke specialized subagents.",
|
||||||
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
|
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
|
||||||
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
|
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
|
||||||
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
|
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const [agentStore, setAgentStore] = createStore<{
|
const [agentStore, setAgentStore] = createStore<{
|
||||||
current: string
|
current: string
|
||||||
}>({
|
}>({
|
||||||
current: agents().find((x) => x.default)?.name ?? agents()[0].name,
|
current: agents()[0].name,
|
||||||
})
|
})
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const colors = createMemo(() => [
|
const colors = createMemo(() => [
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
Config,
|
Config,
|
||||||
Todo,
|
Todo,
|
||||||
Command,
|
Command,
|
||||||
Permission,
|
PermissionRequest,
|
||||||
LspStatus,
|
LspStatus,
|
||||||
McpStatus,
|
McpStatus,
|
||||||
FormatterStatus,
|
FormatterStatus,
|
||||||
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
agent: Agent[]
|
agent: Agent[]
|
||||||
command: Command[]
|
command: Command[]
|
||||||
permission: {
|
permission: {
|
||||||
[sessionID: string]: Permission[]
|
[sessionID: string]: PermissionRequest[]
|
||||||
}
|
}
|
||||||
config: Config
|
config: Config
|
||||||
session: Session[]
|
session: Session[]
|
||||||
@@ -97,30 +97,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
sdk.event.listen((e) => {
|
sdk.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "permission.updated": {
|
case "server.instance.disposed":
|
||||||
const permissions = store.permission[event.properties.sessionID]
|
bootstrap()
|
||||||
if (!permissions) {
|
|
||||||
setStore("permission", event.properties.sessionID, [event.properties])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const match = Binary.search(permissions, event.properties.id, (p) => p.id)
|
|
||||||
setStore(
|
|
||||||
"permission",
|
|
||||||
event.properties.sessionID,
|
|
||||||
produce((draft) => {
|
|
||||||
if (match.found) {
|
|
||||||
draft[match.index] = event.properties
|
|
||||||
return
|
|
||||||
}
|
|
||||||
draft.push(event.properties)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
}
|
|
||||||
|
|
||||||
case "permission.replied": {
|
case "permission.replied": {
|
||||||
const permissions = store.permission[event.properties.sessionID]
|
const requests = store.permission[event.properties.sessionID]
|
||||||
const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
|
if (!requests) break
|
||||||
|
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
|
||||||
if (!match.found) break
|
if (!match.found) break
|
||||||
setStore(
|
setStore(
|
||||||
"permission",
|
"permission",
|
||||||
@@ -132,6 +115,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "permission.asked": {
|
||||||
|
const request = event.properties
|
||||||
|
const requests = store.permission[request.sessionID]
|
||||||
|
if (!requests) {
|
||||||
|
setStore("permission", request.sessionID, [request])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const match = Binary.search(requests, request.id, (r) => r.id)
|
||||||
|
if (match.found) {
|
||||||
|
setStore("permission", request.sessionID, match.index, reconcile(request))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
"permission",
|
||||||
|
request.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(match.index, 0, request)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "todo.updated":
|
case "todo.updated":
|
||||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||||
break
|
break
|
||||||
@@ -258,28 +263,26 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const sessionListPromise = sdk.client.session.list().then((x) =>
|
console.log("bootstrapping")
|
||||||
setStore(
|
const sessionListPromise = sdk.client.session
|
||||||
"session",
|
.list()
|
||||||
(x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)),
|
.then((x) => setStore("session", reconcile((x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// blocking - include session.list when continuing a session
|
// blocking - include session.list when continuing a session
|
||||||
const blockingRequests: Promise<unknown>[] = [
|
const blockingRequests: Promise<unknown>[] = [
|
||||||
sdk.client.config.providers({}, { throwOnError: true }).then((x) => {
|
sdk.client.config.providers({}, { throwOnError: true }).then((x) => {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("provider", x.data!.providers)
|
setStore("provider", reconcile(x.data!.providers))
|
||||||
setStore("provider_default", x.data!.default)
|
setStore("provider_default", reconcile(x.data!.default))
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
sdk.client.provider.list({}, { throwOnError: true }).then((x) => {
|
sdk.client.provider.list({}, { throwOnError: true }).then((x) => {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("provider_next", x.data!)
|
setStore("provider_next", reconcile(x.data!))
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
sdk.client.app.agents({}, { throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
|
sdk.client.app.agents({}, { throwOnError: true }).then((x) => setStore("agent", reconcile(x.data ?? []))),
|
||||||
sdk.client.config.get({}, { throwOnError: true }).then((x) => setStore("config", x.data!)),
|
sdk.client.config.get({}, { throwOnError: true }).then((x) => setStore("config", reconcile(x.data!))),
|
||||||
...(args.continue ? [sessionListPromise] : []),
|
...(args.continue ? [sessionListPromise] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -289,14 +292,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
// non-blocking
|
// non-blocking
|
||||||
Promise.all([
|
Promise.all([
|
||||||
...(args.continue ? [] : [sessionListPromise]),
|
...(args.continue ? [] : [sessionListPromise]),
|
||||||
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
|
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||||
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
|
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
|
||||||
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
|
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
|
||||||
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
|
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
|
||||||
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
|
sdk.client.session.status().then((x) => {
|
||||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
|
setStore("session_status", reconcile(x.data!))
|
||||||
sdk.client.vcs.get().then((x) => setStore("vcs", x.data)),
|
}),
|
||||||
sdk.client.path.get().then((x) => setStore("path", x.data!)),
|
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||||
|
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
||||||
|
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
setStore("status", "complete")
|
setStore("status", "complete")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import mercury from "./theme/mercury.json" with { type: "json" }
|
|||||||
import monokai from "./theme/monokai.json" with { type: "json" }
|
import monokai from "./theme/monokai.json" with { type: "json" }
|
||||||
import nightowl from "./theme/nightowl.json" with { type: "json" }
|
import nightowl from "./theme/nightowl.json" with { type: "json" }
|
||||||
import nord from "./theme/nord.json" with { type: "json" }
|
import nord from "./theme/nord.json" with { type: "json" }
|
||||||
|
import osakaJade from "./theme/osaka-jade.json" with { type: "json" }
|
||||||
import onedark from "./theme/one-dark.json" with { type: "json" }
|
import onedark from "./theme/one-dark.json" with { type: "json" }
|
||||||
import opencode from "./theme/opencode.json" with { type: "json" }
|
import opencode from "./theme/opencode.json" with { type: "json" }
|
||||||
import orng from "./theme/orng.json" with { type: "json" }
|
import orng from "./theme/orng.json" with { type: "json" }
|
||||||
@@ -155,6 +156,7 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
|||||||
nightowl,
|
nightowl,
|
||||||
nord,
|
nord,
|
||||||
["one-dark"]: onedark,
|
["one-dark"]: onedark,
|
||||||
|
["osaka-jade"]: osakaJade,
|
||||||
opencode,
|
opencode,
|
||||||
orng,
|
orng,
|
||||||
["lucent-orng"]: lucentOrng,
|
["lucent-orng"]: lucentOrng,
|
||||||
@@ -283,6 +285,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
ready: false,
|
ready: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const theme = sync.data.config.theme
|
||||||
|
console.log("theme", theme)
|
||||||
|
if (theme) setStore("active", theme)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
getCustomThemes()
|
getCustomThemes()
|
||||||
.then((custom) => {
|
.then((custom) => {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/theme.json",
|
||||||
|
"defs": {
|
||||||
|
"darkBg0": "#111c18",
|
||||||
|
"darkBg1": "#1a2520",
|
||||||
|
"darkBg2": "#23372B",
|
||||||
|
"darkBg3": "#3d4a44",
|
||||||
|
"darkFg0": "#C1C497",
|
||||||
|
"darkFg1": "#9aa88a",
|
||||||
|
"darkGray": "#53685B",
|
||||||
|
"darkRed": "#FF5345",
|
||||||
|
"darkGreen": "#549e6a",
|
||||||
|
"darkYellow": "#459451",
|
||||||
|
"darkBlue": "#509475",
|
||||||
|
"darkMagenta": "#D2689C",
|
||||||
|
"darkCyan": "#2DD5B7",
|
||||||
|
"darkWhite": "#F6F5DD",
|
||||||
|
"darkRedBright": "#db9f9c",
|
||||||
|
"darkGreenBright": "#63b07a",
|
||||||
|
"darkYellowBright": "#E5C736",
|
||||||
|
"darkBlueBright": "#ACD4CF",
|
||||||
|
"darkMagentaBright": "#75bbb3",
|
||||||
|
"darkCyanBright": "#8CD3CB",
|
||||||
|
"lightBg0": "#F6F5DD",
|
||||||
|
"lightBg1": "#E8E7CC",
|
||||||
|
"lightBg2": "#D5D4B8",
|
||||||
|
"lightBg3": "#A8A78C",
|
||||||
|
"lightFg0": "#111c18",
|
||||||
|
"lightFg1": "#1a2520",
|
||||||
|
"lightGray": "#53685B",
|
||||||
|
"lightRed": "#c7392d",
|
||||||
|
"lightGreen": "#3d7a52",
|
||||||
|
"lightYellow": "#b5a020",
|
||||||
|
"lightBlue": "#3d7560",
|
||||||
|
"lightMagenta": "#a8527a",
|
||||||
|
"lightCyan": "#1faa90"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"primary": { "dark": "darkCyan", "light": "lightCyan" },
|
||||||
|
"secondary": { "dark": "darkMagenta", "light": "lightMagenta" },
|
||||||
|
"accent": { "dark": "darkGreen", "light": "lightGreen" },
|
||||||
|
"error": { "dark": "darkRed", "light": "lightRed" },
|
||||||
|
"warning": { "dark": "darkYellowBright", "light": "lightYellow" },
|
||||||
|
"success": { "dark": "darkGreen", "light": "lightGreen" },
|
||||||
|
"info": { "dark": "darkCyan", "light": "lightCyan" },
|
||||||
|
"text": { "dark": "darkFg0", "light": "lightFg0" },
|
||||||
|
"textMuted": { "dark": "darkGray", "light": "lightGray" },
|
||||||
|
"background": { "dark": "darkBg0", "light": "lightBg0" },
|
||||||
|
"backgroundPanel": { "dark": "darkBg1", "light": "lightBg1" },
|
||||||
|
"backgroundElement": { "dark": "darkBg2", "light": "lightBg2" },
|
||||||
|
"border": { "dark": "darkBg3", "light": "lightBg3" },
|
||||||
|
"borderActive": { "dark": "darkCyan", "light": "lightCyan" },
|
||||||
|
"borderSubtle": { "dark": "darkBg2", "light": "lightBg2" },
|
||||||
|
"diffAdded": { "dark": "darkGreen", "light": "lightGreen" },
|
||||||
|
"diffRemoved": { "dark": "darkRed", "light": "lightRed" },
|
||||||
|
"diffContext": { "dark": "darkGray", "light": "lightGray" },
|
||||||
|
"diffHunkHeader": { "dark": "darkCyan", "light": "lightCyan" },
|
||||||
|
"diffHighlightAdded": { "dark": "darkGreenBright", "light": "lightGreen" },
|
||||||
|
"diffHighlightRemoved": { "dark": "darkRedBright", "light": "lightRed" },
|
||||||
|
"diffAddedBg": { "dark": "#15241c", "light": "#e0eee5" },
|
||||||
|
"diffRemovedBg": { "dark": "#241515", "light": "#eee0e0" },
|
||||||
|
"diffContextBg": { "dark": "darkBg1", "light": "lightBg1" },
|
||||||
|
"diffLineNumber": { "dark": "darkBg3", "light": "lightBg3" },
|
||||||
|
"diffAddedLineNumberBg": { "dark": "#121f18", "light": "#d5e5da" },
|
||||||
|
"diffRemovedLineNumberBg": { "dark": "#1f1212", "light": "#e5d5d5" },
|
||||||
|
"markdownText": { "dark": "darkFg0", "light": "lightFg0" },
|
||||||
|
"markdownHeading": { "dark": "darkCyan", "light": "lightCyan" },
|
||||||
|
"markdownLink": { "dark": "darkCyanBright", "light": "lightCyan" },
|
||||||
|
"markdownLinkText": { "dark": "darkGreen", "light": "lightGreen" },
|
||||||
|
"markdownCode": { "dark": "darkGreenBright", "light": "lightGreen" },
|
||||||
|
"markdownBlockQuote": { "dark": "darkGray", "light": "lightGray" },
|
||||||
|
"markdownEmph": { "dark": "darkMagenta", "light": "lightMagenta" },
|
||||||
|
"markdownStrong": { "dark": "darkFg0", "light": "lightFg0" },
|
||||||
|
"markdownHorizontalRule": { "dark": "darkGray", "light": "lightGray" },
|
||||||
|
"markdownListItem": { "dark": "darkCyan", "light": "lightCyan" },
|
||||||
|
"markdownListEnumeration": {
|
||||||
|
"dark": "darkCyanBright",
|
||||||
|
"light": "lightCyan"
|
||||||
|
},
|
||||||
|
"markdownImage": { "dark": "darkCyanBright", "light": "lightCyan" },
|
||||||
|
"markdownImageText": { "dark": "darkGreen", "light": "lightGreen" },
|
||||||
|
"markdownCodeBlock": { "dark": "darkFg0", "light": "lightFg0" },
|
||||||
|
"syntaxComment": { "dark": "darkGray", "light": "lightGray" },
|
||||||
|
"syntaxKeyword": { "dark": "darkCyan", "light": "lightCyan" },
|
||||||
|
"syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" },
|
||||||
|
"syntaxVariable": { "dark": "darkFg0", "light": "lightFg0" },
|
||||||
|
"syntaxString": { "dark": "darkGreenBright", "light": "lightGreen" },
|
||||||
|
"syntaxNumber": { "dark": "darkMagenta", "light": "lightMagenta" },
|
||||||
|
"syntaxType": { "dark": "darkGreen", "light": "lightGreen" },
|
||||||
|
"syntaxOperator": { "dark": "darkYellow", "light": "lightYellow" },
|
||||||
|
"syntaxPunctuation": { "dark": "darkFg0", "light": "lightFg0" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ export function Footer() {
|
|||||||
<Match when={connected()}>
|
<Match when={connected()}>
|
||||||
<Show when={permissions().length > 0}>
|
<Show when={permissions().length > 0}>
|
||||||
<text fg={theme.warning}>
|
<text fg={theme.warning}>
|
||||||
<span style={{ fg: theme.warning }}>◉</span> {permissions().length} Permission
|
<span style={{ fg: theme.warning }}>△</span> {permissions().length} Permission
|
||||||
{permissions().length > 1 ? "s" : ""}
|
{permissions().length > 1 ? "s" : ""}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
|||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||||
|
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||||
|
import { useTheme } from "../../context/theme"
|
||||||
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||||
|
import { useSDK } from "../../context/sdk"
|
||||||
|
import { SplitBorder } from "../../component/border"
|
||||||
|
import { useSync } from "../../context/sync"
|
||||||
|
import path from "path"
|
||||||
|
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||||
|
import { Locale } from "@/util/locale"
|
||||||
|
|
||||||
|
function normalizePath(input?: string) {
|
||||||
|
if (!input) return ""
|
||||||
|
if (path.isAbsolute(input)) {
|
||||||
|
return path.relative(process.cwd(), input) || "."
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
function filetype(input?: string) {
|
||||||
|
if (!input) return "none"
|
||||||
|
const ext = path.extname(input)
|
||||||
|
const language = LANGUAGE_EXTENSIONS[ext]
|
||||||
|
if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
|
||||||
|
return language
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditBody(props: { request: PermissionRequest }) {
|
||||||
|
const { theme, syntax } = useTheme()
|
||||||
|
const sync = useSync()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
|
||||||
|
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
|
||||||
|
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
|
||||||
|
|
||||||
|
const view = createMemo(() => {
|
||||||
|
const diffStyle = sync.data.config.tui?.diff_style
|
||||||
|
if (diffStyle === "stacked") return "unified"
|
||||||
|
return dimensions().width > 120 ? "split" : "unified"
|
||||||
|
})
|
||||||
|
|
||||||
|
const ft = createMemo(() => filetype(filepath()))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||||
|
<text fg={theme.textMuted}>{"→"}</text>
|
||||||
|
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
|
||||||
|
</box>
|
||||||
|
<Show when={diff()}>
|
||||||
|
<box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
|
||||||
|
<diff
|
||||||
|
diff={diff()}
|
||||||
|
view={view()}
|
||||||
|
filetype={ft()}
|
||||||
|
syntaxStyle={syntax()}
|
||||||
|
showLineNumbers={true}
|
||||||
|
width="100%"
|
||||||
|
wrapMode="word"
|
||||||
|
fg={theme.text}
|
||||||
|
addedBg={theme.diffAddedBg}
|
||||||
|
removedBg={theme.diffRemovedBg}
|
||||||
|
contextBg={theme.diffContextBg}
|
||||||
|
addedSignColor={theme.diffHighlightAdded}
|
||||||
|
removedSignColor={theme.diffHighlightRemoved}
|
||||||
|
lineNumberFg={theme.diffLineNumber}
|
||||||
|
lineNumberBg={theme.diffContextBg}
|
||||||
|
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
||||||
|
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextBody(props: { title: string; description?: string; icon?: string }) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||||
|
<Show when={props.icon}>
|
||||||
|
<text fg={theme.textMuted} flexShrink={0}>
|
||||||
|
{props.icon}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={theme.textMuted}>{props.title}</text>
|
||||||
|
</box>
|
||||||
|
<Show when={props.description}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.text}>{props.description}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
always: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = createMemo(() => {
|
||||||
|
const tool = props.request.tool
|
||||||
|
if (!tool) return {}
|
||||||
|
const parts = sync.data.part[tool.messageID] ?? []
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
|
||||||
|
return part.state.input ?? {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Match when={store.always}>
|
||||||
|
<Prompt
|
||||||
|
title="Always allow"
|
||||||
|
body={
|
||||||
|
<Switch>
|
||||||
|
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
|
||||||
|
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<box paddingLeft={1} gap={1}>
|
||||||
|
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
|
||||||
|
<box>
|
||||||
|
<For each={props.request.always}>
|
||||||
|
{(pattern) => (
|
||||||
|
<text fg={theme.text}>
|
||||||
|
{"- "}
|
||||||
|
{pattern}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
}
|
||||||
|
options={{ confirm: "Confirm", cancel: "Cancel" }}
|
||||||
|
onSelect={(option) => {
|
||||||
|
setStore("always", false)
|
||||||
|
if (option === "cancel") return
|
||||||
|
sdk.client.permission.reply({
|
||||||
|
reply: "always",
|
||||||
|
requestID: props.request.id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={!store.always}>
|
||||||
|
<Prompt
|
||||||
|
title="Permission required"
|
||||||
|
body={
|
||||||
|
<Switch>
|
||||||
|
<Match when={props.request.permission === "edit"}>
|
||||||
|
<EditBody request={props.request} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.request.permission === "read"}>
|
||||||
|
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.request.permission === "glob"}>
|
||||||
|
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.request.permission === "grep"}>
|
||||||
|
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.request.permission === "list"}>
|
||||||
|
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.request.permission === "bash"}>
|
||||||
|
<TextBody
|
||||||
|
icon="#"
|
||||||
|
title={(input().description as string) ?? ""}
|
||||||
|
description={("$ " + input().command) as string}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.request.permission === "task"}>
|
||||||
|
<TextBody
|
||||||
|
icon="#"
|
||||||
|
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
|
||||||
|
description={"◉ " + input().description}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.request.permission === "webfetch"}>
|
||||||
|
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.request.permission === "websearch"}>
|
||||||
|
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.request.permission === "codesearch"}>
|
||||||
|
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.request.permission === "external_directory"}>
|
||||||
|
<TextBody icon="⚠" title={`Access external directory ` + normalizePath(input().path as string)} />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.request.permission === "doom_loop"}>
|
||||||
|
<TextBody icon="⟳" title="Continue after repeated failures" />
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
}
|
||||||
|
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
|
||||||
|
onSelect={(option) => {
|
||||||
|
if (option === "always") {
|
||||||
|
setStore("always", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sdk.client.permission.reply({
|
||||||
|
reply: option as "once" | "reject",
|
||||||
|
requestID: props.request.id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Prompt<const T extends Record<string, string>>(props: {
|
||||||
|
title: string
|
||||||
|
body: JSX.Element
|
||||||
|
options: T
|
||||||
|
onSelect: (option: keyof T) => void
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const keys = Object.keys(props.options) as (keyof T)[]
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
selected: keys[0],
|
||||||
|
})
|
||||||
|
|
||||||
|
useKeyboard((evt) => {
|
||||||
|
if (evt.name === "left" || evt.name == "h") {
|
||||||
|
evt.preventDefault()
|
||||||
|
const idx = keys.indexOf(store.selected)
|
||||||
|
const next = keys[(idx - 1 + keys.length) % keys.length]
|
||||||
|
setStore("selected", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evt.name === "right" || evt.name == "l") {
|
||||||
|
evt.preventDefault()
|
||||||
|
const idx = keys.indexOf(store.selected)
|
||||||
|
const next = keys[(idx + 1) % keys.length]
|
||||||
|
setStore("selected", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evt.name === "return") {
|
||||||
|
evt.preventDefault()
|
||||||
|
props.onSelect(store.selected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
backgroundColor={theme.backgroundPanel}
|
||||||
|
border={["left"]}
|
||||||
|
borderColor={theme.warning}
|
||||||
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
|
>
|
||||||
|
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||||
|
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||||
|
<text fg={theme.warning}>{"△"}</text>
|
||||||
|
<text fg={theme.text}>{props.title}</text>
|
||||||
|
</box>
|
||||||
|
{props.body}
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
flexShrink={0}
|
||||||
|
gap={1}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={3}
|
||||||
|
paddingBottom={1}
|
||||||
|
backgroundColor={theme.backgroundElement}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<For each={keys}>
|
||||||
|
{(option) => (
|
||||||
|
<box
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
|
||||||
|
>
|
||||||
|
<text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
|
||||||
|
{props.options[option]}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
|
||||||
|
</text>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
enter <span style={{ fg: theme.textMuted }}>confirm</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -77,6 +77,9 @@ export const TuiThreadCommand = cmd({
|
|||||||
process.on("unhandledRejection", (e) => {
|
process.on("unhandledRejection", (e) => {
|
||||||
Log.Default.error(e)
|
Log.Default.error(e)
|
||||||
})
|
})
|
||||||
|
process.on("SIGUSR2", async () => {
|
||||||
|
await client.call("reload", undefined)
|
||||||
|
})
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = await client.call("server", opts)
|
const server = await client.call("server", opts)
|
||||||
const prompt = await iife(async () => {
|
const prompt = await iife(async () => {
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ export type DialogExportOptionsProps = {
|
|||||||
defaultFilename: string
|
defaultFilename: string
|
||||||
defaultThinking: boolean
|
defaultThinking: boolean
|
||||||
defaultToolDetails: boolean
|
defaultToolDetails: boolean
|
||||||
onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void
|
defaultAssistantMetadata: boolean
|
||||||
|
defaultOpenWithoutSaving: boolean
|
||||||
|
onConfirm?: (options: {
|
||||||
|
filename: string
|
||||||
|
thinking: boolean
|
||||||
|
toolDetails: boolean
|
||||||
|
assistantMetadata: boolean
|
||||||
|
openWithoutSaving: boolean
|
||||||
|
}) => void
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +28,9 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
thinking: props.defaultThinking,
|
thinking: props.defaultThinking,
|
||||||
toolDetails: props.defaultToolDetails,
|
toolDetails: props.defaultToolDetails,
|
||||||
active: "filename" as "filename" | "thinking" | "toolDetails",
|
assistantMetadata: props.defaultAssistantMetadata,
|
||||||
|
openWithoutSaving: props.defaultOpenWithoutSaving,
|
||||||
|
active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
|
||||||
})
|
})
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
@@ -29,10 +39,18 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
filename: textarea.plainText,
|
filename: textarea.plainText,
|
||||||
thinking: store.thinking,
|
thinking: store.thinking,
|
||||||
toolDetails: store.toolDetails,
|
toolDetails: store.toolDetails,
|
||||||
|
assistantMetadata: store.assistantMetadata,
|
||||||
|
openWithoutSaving: store.openWithoutSaving,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (evt.name === "tab") {
|
if (evt.name === "tab") {
|
||||||
const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"]
|
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
|
||||||
|
"filename",
|
||||||
|
"thinking",
|
||||||
|
"toolDetails",
|
||||||
|
"assistantMetadata",
|
||||||
|
"openWithoutSaving",
|
||||||
|
]
|
||||||
const currentIndex = order.indexOf(store.active)
|
const currentIndex = order.indexOf(store.active)
|
||||||
const nextIndex = (currentIndex + 1) % order.length
|
const nextIndex = (currentIndex + 1) % order.length
|
||||||
setStore("active", order[nextIndex])
|
setStore("active", order[nextIndex])
|
||||||
@@ -41,6 +59,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
if (evt.name === "space") {
|
if (evt.name === "space") {
|
||||||
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
||||||
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
|
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
|
||||||
|
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
|
||||||
|
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -71,6 +91,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
filename: textarea.plainText,
|
filename: textarea.plainText,
|
||||||
thinking: store.thinking,
|
thinking: store.thinking,
|
||||||
toolDetails: store.toolDetails,
|
toolDetails: store.toolDetails,
|
||||||
|
assistantMetadata: store.assistantMetadata,
|
||||||
|
openWithoutSaving: store.openWithoutSaving,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
height={3}
|
height={3}
|
||||||
@@ -108,6 +130,30 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
</text>
|
</text>
|
||||||
<text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
|
<text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
|
||||||
</box>
|
</box>
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={2}
|
||||||
|
paddingLeft={1}
|
||||||
|
backgroundColor={store.active === "assistantMetadata" ? theme.backgroundElement : undefined}
|
||||||
|
onMouseUp={() => setStore("active", "assistantMetadata")}
|
||||||
|
>
|
||||||
|
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.textMuted}>
|
||||||
|
{store.assistantMetadata ? "[x]" : "[ ]"}
|
||||||
|
</text>
|
||||||
|
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.text}>Include assistant metadata</text>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={2}
|
||||||
|
paddingLeft={1}
|
||||||
|
backgroundColor={store.active === "openWithoutSaving" ? theme.backgroundElement : undefined}
|
||||||
|
onMouseUp={() => setStore("active", "openWithoutSaving")}
|
||||||
|
>
|
||||||
|
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.textMuted}>
|
||||||
|
{store.openWithoutSaving ? "[x]" : "[ ]"}
|
||||||
|
</text>
|
||||||
|
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.text}>Open without saving</text>
|
||||||
|
</box>
|
||||||
</box>
|
</box>
|
||||||
<Show when={store.active !== "filename"}>
|
<Show when={store.active !== "filename"}>
|
||||||
<text fg={theme.textMuted} paddingBottom={1}>
|
<text fg={theme.textMuted} paddingBottom={1}>
|
||||||
@@ -130,14 +176,24 @@ DialogExportOptions.show = (
|
|||||||
defaultFilename: string,
|
defaultFilename: string,
|
||||||
defaultThinking: boolean,
|
defaultThinking: boolean,
|
||||||
defaultToolDetails: boolean,
|
defaultToolDetails: boolean,
|
||||||
|
defaultAssistantMetadata: boolean,
|
||||||
|
defaultOpenWithoutSaving: boolean,
|
||||||
) => {
|
) => {
|
||||||
return new Promise<{ filename: string; thinking: boolean; toolDetails: boolean } | null>((resolve) => {
|
return new Promise<{
|
||||||
|
filename: string
|
||||||
|
thinking: boolean
|
||||||
|
toolDetails: boolean
|
||||||
|
assistantMetadata: boolean
|
||||||
|
openWithoutSaving: boolean
|
||||||
|
} | null>((resolve) => {
|
||||||
dialog.replace(
|
dialog.replace(
|
||||||
() => (
|
() => (
|
||||||
<DialogExportOptions
|
<DialogExportOptions
|
||||||
defaultFilename={defaultFilename}
|
defaultFilename={defaultFilename}
|
||||||
defaultThinking={defaultThinking}
|
defaultThinking={defaultThinking}
|
||||||
defaultToolDetails={defaultToolDetails}
|
defaultToolDetails={defaultToolDetails}
|
||||||
|
defaultAssistantMetadata={defaultAssistantMetadata}
|
||||||
|
defaultOpenWithoutSaving={defaultOpenWithoutSaving}
|
||||||
onConfirm={(options) => resolve(options)}
|
onConfirm={(options) => resolve(options)}
|
||||||
onCancel={() => resolve(null)}
|
onCancel={() => resolve(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ function init() {
|
|||||||
replace(input: any, onClose?: () => void) {
|
replace(input: any, onClose?: () => void) {
|
||||||
if (store.stack.length === 0) {
|
if (store.stack.length === 0) {
|
||||||
focus = renderer.currentFocusedRenderable
|
focus = renderer.currentFocusedRenderable
|
||||||
|
focus?.blur()
|
||||||
}
|
}
|
||||||
for (const item of store.stack) {
|
for (const item of store.stack) {
|
||||||
if (item.onClose) item.onClose()
|
if (item.onClose) item.onClose()
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
import { Locale } from "@/util/locale"
|
||||||
|
|
||||||
|
export type TranscriptOptions = {
|
||||||
|
thinking: boolean
|
||||||
|
toolDetails: boolean
|
||||||
|
assistantMetadata: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionInfo = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
time: {
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageWithParts = {
|
||||||
|
info: UserMessage | AssistantMessage
|
||||||
|
parts: Part[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTranscript(
|
||||||
|
session: SessionInfo,
|
||||||
|
messages: MessageWithParts[],
|
||||||
|
options: TranscriptOptions,
|
||||||
|
): string {
|
||||||
|
let transcript = `# ${session.title}\n\n`
|
||||||
|
transcript += `**Session ID:** ${session.id}\n`
|
||||||
|
transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n`
|
||||||
|
transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n`
|
||||||
|
transcript += `---\n\n`
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
transcript += formatMessage(msg.info, msg.parts, options)
|
||||||
|
transcript += `---\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
return transcript
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMessage(msg: UserMessage | AssistantMessage, parts: Part[], options: TranscriptOptions): string {
|
||||||
|
let result = ""
|
||||||
|
|
||||||
|
if (msg.role === "user") {
|
||||||
|
result += `## User\n\n`
|
||||||
|
} else {
|
||||||
|
result += formatAssistantHeader(msg, options.assistantMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
result += formatPart(part, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAssistantHeader(msg: AssistantMessage, includeMetadata: boolean): string {
|
||||||
|
if (!includeMetadata) {
|
||||||
|
return `## Assistant\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration =
|
||||||
|
msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : ""
|
||||||
|
|
||||||
|
return `## Assistant (${Locale.titlecase(msg.agent)} · ${msg.modelID}${duration ? ` · ${duration}` : ""})\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPart(part: Part, options: TranscriptOptions): string {
|
||||||
|
if (part.type === "text" && !part.synthetic) {
|
||||||
|
return `${part.text}\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "reasoning") {
|
||||||
|
if (options.thinking) {
|
||||||
|
return `_Thinking:_\n\n${part.text}\n\n`
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "tool") {
|
||||||
|
let result = `\`\`\`\nTool: ${part.tool}\n`
|
||||||
|
if (options.toolDetails && part.state.input) {
|
||||||
|
result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
|
||||||
|
}
|
||||||
|
if (options.toolDetails && part.state.status === "completed" && part.state.output) {
|
||||||
|
result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
|
||||||
|
}
|
||||||
|
if (options.toolDetails && part.state.status === "error" && part.state.error) {
|
||||||
|
result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
|
||||||
|
}
|
||||||
|
result += `\n\`\`\`\n\n`
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { InstanceBootstrap } from "@/project/bootstrap"
|
|||||||
import { Rpc } from "@/util/rpc"
|
import { Rpc } from "@/util/rpc"
|
||||||
import { upgrade } from "@/cli/upgrade"
|
import { upgrade } from "@/cli/upgrade"
|
||||||
import type { BunWebSocketData } from "hono/bun"
|
import type { BunWebSocketData } from "hono/bun"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
|
||||||
await Log.init({
|
await Log.init({
|
||||||
print: process.argv.includes("--print-logs"),
|
print: process.argv.includes("--print-logs"),
|
||||||
@@ -51,6 +52,10 @@ export const rpc = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
async reload() {
|
||||||
|
Config.global.reset()
|
||||||
|
await Instance.disposeAll()
|
||||||
|
},
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
Log.Default.info("worker shutting down")
|
Log.Default.info("worker shutting down")
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ const options = {
|
|||||||
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
|
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
cors: {
|
||||||
|
type: "string" as const,
|
||||||
|
array: true,
|
||||||
|
describe: "additional domains to allow for CORS",
|
||||||
|
default: [] as string[],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NetworkOptions = InferredOptionTypes<typeof options>
|
export type NetworkOptions = InferredOptionTypes<typeof options>
|
||||||
@@ -30,6 +36,7 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
|
|||||||
const portExplicitlySet = process.argv.includes("--port")
|
const portExplicitlySet = process.argv.includes("--port")
|
||||||
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||||
|
const corsExplicitlySet = process.argv.includes("--cors")
|
||||||
|
|
||||||
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
|
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
|
||||||
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
|
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
|
||||||
@@ -38,6 +45,9 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
|
|||||||
: mdns && !config?.server?.hostname
|
: mdns && !config?.server?.hostname
|
||||||
? "0.0.0.0"
|
? "0.0.0.0"
|
||||||
: (config?.server?.hostname ?? args.hostname)
|
: (config?.server?.hostname ?? args.hostname)
|
||||||
|
const configCors = config?.server?.cors ?? []
|
||||||
|
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
|
||||||
|
const cors = [...configCors, ...argsCors]
|
||||||
|
|
||||||
return { hostname, port, mdns }
|
return { hostname, port, mdns, cors }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Bus } from "@/bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
@@ -27,6 +26,7 @@ export namespace Command {
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
agent: z.string().optional(),
|
agent: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
|
mcp: z.boolean().optional(),
|
||||||
// workaround for zod not supporting async functions natively so we use getters
|
// workaround for zod not supporting async functions natively so we use getters
|
||||||
// https://zod.dev/v4/changelog?id=zfunction
|
// https://zod.dev/v4/changelog?id=zfunction
|
||||||
template: z.promise(z.string()).or(z.string()),
|
template: z.promise(z.string()).or(z.string()),
|
||||||
@@ -94,6 +94,7 @@ export namespace Command {
|
|||||||
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
|
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
|
||||||
result[name] = {
|
result[name] = {
|
||||||
name,
|
name,
|
||||||
|
mcp: true,
|
||||||
description: prompt.description,
|
description: prompt.description,
|
||||||
get template() {
|
get template() {
|
||||||
// since a getter can't be async we need to manually return a promise here
|
// since a getter can't be async we need to manually return a promise here
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export namespace Config {
|
|||||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||||
}
|
}
|
||||||
|
|
||||||
const promises: Promise<void>[] = []
|
|
||||||
for (const dir of unique(directories)) {
|
for (const dir of unique(directories)) {
|
||||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||||
@@ -103,13 +102,12 @@ export namespace Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
promises.push(installDependencies(dir))
|
installDependencies(dir)
|
||||||
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
|
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
|
||||||
result.agent = mergeDeep(result.agent, await loadAgent(dir))
|
result.agent = mergeDeep(result.agent, await loadAgent(dir))
|
||||||
result.agent = mergeDeep(result.agent, await loadMode(dir))
|
result.agent = mergeDeep(result.agent, await loadMode(dir))
|
||||||
result.plugin.push(...(await loadPlugin(dir)))
|
result.plugin.push(...(await loadPlugin(dir)))
|
||||||
}
|
}
|
||||||
await Promise.allSettled(promises)
|
|
||||||
|
|
||||||
// Migrate deprecated mode field to agent field
|
// Migrate deprecated mode field to agent field
|
||||||
for (const [name, mode] of Object.entries(result.mode)) {
|
for (const [name, mode] of Object.entries(result.mode)) {
|
||||||
@@ -125,13 +123,22 @@ export namespace Config {
|
|||||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.username) result.username = os.userInfo().username
|
// Backwards compatibility: legacy top-level `tools` config
|
||||||
|
if (result.tools) {
|
||||||
// Handle migration from autoshare to share field
|
const perms: Record<string, Config.PermissionAction> = {}
|
||||||
if (result.autoshare === true && !result.share) {
|
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||||
result.share = "auto"
|
const action: Config.PermissionAction = enabled ? "allow" : "deny"
|
||||||
|
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||||
|
perms.edit = action
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
perms[tool] = action
|
||||||
|
}
|
||||||
|
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result.username) result.username = os.userInfo().username
|
||||||
|
|
||||||
// Handle migration from autoshare to share field
|
// Handle migration from autoshare to share field
|
||||||
if (result.autoshare === true && !result.share) {
|
if (result.autoshare === true && !result.share) {
|
||||||
result.share = "auto"
|
result.share = "auto"
|
||||||
@@ -259,7 +266,7 @@ export namespace Config {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_GLOB = new Bun.Glob("{mode,modes}/**/*.md")
|
const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md")
|
||||||
async function loadMode(dir: string) {
|
async function loadMode(dir: string) {
|
||||||
const result: Record<string, Agent> = {}
|
const result: Record<string, Agent> = {}
|
||||||
for await (const item of MODE_GLOB.scan({
|
for await (const item of MODE_GLOB.scan({
|
||||||
@@ -288,7 +295,7 @@ export namespace Config {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/**/*.{ts,js}")
|
const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}")
|
||||||
async function loadPlugin(dir: string) {
|
async function loadPlugin(dir: string) {
|
||||||
const plugins: string[] = []
|
const plugins: string[] = []
|
||||||
|
|
||||||
@@ -370,7 +377,45 @@ export namespace Config {
|
|||||||
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
||||||
export type Mcp = z.infer<typeof Mcp>
|
export type Mcp = z.infer<typeof Mcp>
|
||||||
|
|
||||||
export const Permission = z.enum(["ask", "allow", "deny"])
|
export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({
|
||||||
|
ref: "PermissionActionConfig",
|
||||||
|
})
|
||||||
|
export type PermissionAction = z.infer<typeof PermissionAction>
|
||||||
|
|
||||||
|
export const PermissionObject = z.record(z.string(), PermissionAction).meta({
|
||||||
|
ref: "PermissionObjectConfig",
|
||||||
|
})
|
||||||
|
export type PermissionObject = z.infer<typeof PermissionObject>
|
||||||
|
|
||||||
|
export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({
|
||||||
|
ref: "PermissionRuleConfig",
|
||||||
|
})
|
||||||
|
export type PermissionRule = z.infer<typeof PermissionRule>
|
||||||
|
|
||||||
|
export const Permission = z
|
||||||
|
.object({
|
||||||
|
read: PermissionRule.optional(),
|
||||||
|
edit: PermissionRule.optional(),
|
||||||
|
glob: PermissionRule.optional(),
|
||||||
|
grep: PermissionRule.optional(),
|
||||||
|
list: PermissionRule.optional(),
|
||||||
|
bash: PermissionRule.optional(),
|
||||||
|
task: PermissionRule.optional(),
|
||||||
|
external_directory: PermissionRule.optional(),
|
||||||
|
todowrite: PermissionAction.optional(),
|
||||||
|
todoread: PermissionAction.optional(),
|
||||||
|
webfetch: PermissionAction.optional(),
|
||||||
|
websearch: PermissionAction.optional(),
|
||||||
|
codesearch: PermissionAction.optional(),
|
||||||
|
lsp: PermissionRule.optional(),
|
||||||
|
doom_loop: PermissionAction.optional(),
|
||||||
|
})
|
||||||
|
.catchall(PermissionRule)
|
||||||
|
.or(PermissionAction)
|
||||||
|
.transform((x) => (typeof x === "string" ? { "*": x } : x))
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionConfig",
|
||||||
|
})
|
||||||
export type Permission = z.infer<typeof Permission>
|
export type Permission = z.infer<typeof Permission>
|
||||||
|
|
||||||
export const Command = z.object({
|
export const Command = z.object({
|
||||||
@@ -388,33 +433,70 @@ export namespace Config {
|
|||||||
temperature: z.number().optional(),
|
temperature: z.number().optional(),
|
||||||
top_p: z.number().optional(),
|
top_p: z.number().optional(),
|
||||||
prompt: z.string().optional(),
|
prompt: z.string().optional(),
|
||||||
tools: z.record(z.string(), z.boolean()).optional(),
|
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
|
||||||
disable: z.boolean().optional(),
|
disable: z.boolean().optional(),
|
||||||
description: z.string().optional().describe("Description of when to use the agent"),
|
description: z.string().optional().describe("Description of when to use the agent"),
|
||||||
mode: z.enum(["subagent", "primary", "all"]).optional(),
|
mode: z.enum(["subagent", "primary", "all"]).optional(),
|
||||||
|
options: z.record(z.string(), z.any()).optional(),
|
||||||
color: z
|
color: z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
|
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Hex color code for the agent (e.g., #FF5733)"),
|
.describe("Hex color code for the agent (e.g., #FF5733)"),
|
||||||
maxSteps: z
|
steps: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Maximum number of agentic iterations before forcing text-only response"),
|
.describe("Maximum number of agentic iterations before forcing text-only response"),
|
||||||
permission: z
|
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
|
||||||
.object({
|
permission: Permission.optional(),
|
||||||
edit: Permission.optional(),
|
|
||||||
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
|
|
||||||
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
|
|
||||||
webfetch: Permission.optional(),
|
|
||||||
doom_loop: Permission.optional(),
|
|
||||||
external_directory: Permission.optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
})
|
||||||
.catchall(z.any())
|
.catchall(z.any())
|
||||||
|
.transform((agent, ctx) => {
|
||||||
|
const knownKeys = new Set([
|
||||||
|
"model",
|
||||||
|
"prompt",
|
||||||
|
"description",
|
||||||
|
"temperature",
|
||||||
|
"top_p",
|
||||||
|
"mode",
|
||||||
|
"color",
|
||||||
|
"steps",
|
||||||
|
"maxSteps",
|
||||||
|
"options",
|
||||||
|
"permission",
|
||||||
|
"disable",
|
||||||
|
"tools",
|
||||||
|
])
|
||||||
|
|
||||||
|
// Extract unknown properties into options
|
||||||
|
const options: Record<string, unknown> = { ...agent.options }
|
||||||
|
for (const [key, value] of Object.entries(agent)) {
|
||||||
|
if (!knownKeys.has(key)) options[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert legacy tools config to permissions
|
||||||
|
const permission: Permission = { ...agent.permission }
|
||||||
|
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
|
||||||
|
const action = enabled ? "allow" : "deny"
|
||||||
|
// write, edit, patch, multiedit all map to edit permission
|
||||||
|
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||||
|
permission.edit = action
|
||||||
|
} else {
|
||||||
|
permission[tool] = action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert legacy maxSteps to steps
|
||||||
|
const steps = agent.steps ?? agent.maxSteps
|
||||||
|
|
||||||
|
return { ...agent, options, permission, steps } as typeof agent & {
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
permission?: Permission
|
||||||
|
steps?: number
|
||||||
|
}
|
||||||
|
})
|
||||||
.meta({
|
.meta({
|
||||||
ref: "AgentConfig",
|
ref: "AgentConfig",
|
||||||
})
|
})
|
||||||
@@ -586,6 +668,7 @@ export namespace Config {
|
|||||||
port: z.number().int().positive().optional().describe("Port to listen on"),
|
port: z.number().int().positive().optional().describe("Port to listen on"),
|
||||||
hostname: z.string().optional().describe("Hostname to listen on"),
|
hostname: z.string().optional().describe("Hostname to listen on"),
|
||||||
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
|
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
|
||||||
|
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.meta({
|
.meta({
|
||||||
@@ -786,16 +869,7 @@ export namespace Config {
|
|||||||
),
|
),
|
||||||
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
|
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
|
||||||
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
|
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
|
||||||
permission: z
|
permission: Permission.optional(),
|
||||||
.object({
|
|
||||||
edit: Permission.optional(),
|
|
||||||
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
|
|
||||||
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
|
|
||||||
webfetch: Permission.optional(),
|
|
||||||
doom_loop: Permission.optional(),
|
|
||||||
external_directory: Permission.optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
tools: z.record(z.string(), z.boolean()).optional(),
|
tools: z.record(z.string(), z.boolean()).optional(),
|
||||||
enterprise: z
|
enterprise: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export namespace Installation {
|
|||||||
throw new UpgradeFailedError({
|
throw new UpgradeFailedError({
|
||||||
stderr: result.stderr.toString("utf8"),
|
stderr: result.stderr.toString("utf8"),
|
||||||
})
|
})
|
||||||
|
await $`${process.execPath} --version`.nothrow().quiet().text()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export namespace LSPClient {
|
|||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
configuration: true,
|
configuration: true,
|
||||||
|
didChangeWatchedFiles: {
|
||||||
|
dynamicRegistration: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
textDocument: {
|
textDocument: {
|
||||||
synchronization: {
|
synchronization: {
|
||||||
@@ -151,6 +154,16 @@ export namespace LSPClient {
|
|||||||
|
|
||||||
const version = files[input.path]
|
const version = files[input.path]
|
||||||
if (version !== undefined) {
|
if (version !== undefined) {
|
||||||
|
log.info("workspace/didChangeWatchedFiles", input)
|
||||||
|
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
uri: pathToFileURL(input.path).href,
|
||||||
|
type: 2, // Changed
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
const next = version + 1
|
const next = version + 1
|
||||||
files[input.path] = next
|
files[input.path] = next
|
||||||
log.info("textDocument/didChange", {
|
log.info("textDocument/didChange", {
|
||||||
@@ -167,6 +180,16 @@ export namespace LSPClient {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("workspace/didChangeWatchedFiles", input)
|
||||||
|
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
uri: pathToFileURL(input.path).href,
|
||||||
|
type: 1, // Created
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
log.info("textDocument/didOpen", input)
|
log.info("textDocument/didOpen", input)
|
||||||
diagnostics.delete(input.path)
|
diagnostics.delete(input.path)
|
||||||
await connection.sendNotification("textDocument/didOpen", {
|
await connection.sendNotification("textDocument/didOpen", {
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export namespace MCP {
|
|||||||
for (const prompt of prompts.prompts) {
|
for (const prompt of prompts.prompts) {
|
||||||
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||||
const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||||
const key = sanitizedClientName + ":" + sanitizedPromptName + " (MCP)"
|
const key = sanitizedClientName + ":" + sanitizedPromptName
|
||||||
|
|
||||||
commands[key] = { ...prompt, client: clientName }
|
commands[key] = { ...prompt, client: clientName }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
export namespace BashArity {
|
||||||
|
export function prefix(tokens: string[]) {
|
||||||
|
for (let len = tokens.length; len > 0; len--) {
|
||||||
|
const prefix = tokens.slice(0, len).join(" ")
|
||||||
|
const arity = ARITY[prefix]
|
||||||
|
if (arity !== undefined) return tokens.slice(0, arity)
|
||||||
|
}
|
||||||
|
if (tokens.length === 0) return []
|
||||||
|
return tokens.slice(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generated with following prompt:
|
||||||
|
You are generating a dictionary of command-prefix arities for bash-style commands.
|
||||||
|
This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command.
|
||||||
|
2. **Flags NEVER count as tokens**. Only subcommands count.
|
||||||
|
3. **Longest matching prefix wins**.
|
||||||
|
4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity.
|
||||||
|
5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical
|
||||||
|
6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` → `touch` (arity 1, explicitly listed)
|
||||||
|
* `git checkout main` → `git checkout` (because `git` has arity 2)
|
||||||
|
* `npm install` → `npm install` (because `npm` has arity 2)
|
||||||
|
* `npm run dev` → `npm run dev` (because `npm run` has arity 3)
|
||||||
|
* `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.**
|
||||||
|
*/
|
||||||
|
const ARITY: Record<string, number> = {
|
||||||
|
cat: 1, // cat file.txt
|
||||||
|
cd: 1, // cd /path/to/dir
|
||||||
|
chmod: 1, // chmod 755 script.sh
|
||||||
|
chown: 1, // chown user:group file.txt
|
||||||
|
cp: 1, // cp source.txt dest.txt
|
||||||
|
echo: 1, // echo "hello world"
|
||||||
|
env: 1, // env
|
||||||
|
export: 1, // export PATH=/usr/bin
|
||||||
|
grep: 1, // grep pattern file.txt
|
||||||
|
kill: 1, // kill 1234
|
||||||
|
killall: 1, // killall process
|
||||||
|
ln: 1, // ln -s source target
|
||||||
|
ls: 1, // ls -la
|
||||||
|
mkdir: 1, // mkdir new-dir
|
||||||
|
mv: 1, // mv old.txt new.txt
|
||||||
|
ps: 1, // ps aux
|
||||||
|
pwd: 1, // pwd
|
||||||
|
rm: 1, // rm file.txt
|
||||||
|
rmdir: 1, // rmdir empty-dir
|
||||||
|
sleep: 1, // sleep 5
|
||||||
|
source: 1, // source ~/.bashrc
|
||||||
|
tail: 1, // tail -f log.txt
|
||||||
|
touch: 1, // touch file.txt
|
||||||
|
unset: 1, // unset VAR
|
||||||
|
which: 1, // which node
|
||||||
|
aws: 3, // aws s3 ls
|
||||||
|
az: 3, // az storage blob list
|
||||||
|
bazel: 2, // bazel build
|
||||||
|
brew: 2, // brew install node
|
||||||
|
bun: 2, // bun install
|
||||||
|
"bun run": 3, // bun run dev
|
||||||
|
"bun x": 3, // bun x vite
|
||||||
|
cargo: 2, // cargo build
|
||||||
|
"cargo add": 3, // cargo add tokio
|
||||||
|
"cargo run": 3, // cargo run main
|
||||||
|
cdk: 2, // cdk deploy
|
||||||
|
cf: 2, // cf push app
|
||||||
|
cmake: 2, // cmake build
|
||||||
|
composer: 2, // composer require laravel
|
||||||
|
consul: 2, // consul members
|
||||||
|
"consul kv": 3, // consul kv get config/app
|
||||||
|
crictl: 2, // crictl ps
|
||||||
|
deno: 2, // deno run server.ts
|
||||||
|
"deno task": 3, // deno task dev
|
||||||
|
doctl: 3, // doctl kubernetes cluster list
|
||||||
|
docker: 2, // docker run nginx
|
||||||
|
"docker builder": 3, // docker builder prune
|
||||||
|
"docker compose": 3, // docker compose up
|
||||||
|
"docker container": 3, // docker container ls
|
||||||
|
"docker image": 3, // docker image prune
|
||||||
|
"docker network": 3, // docker network inspect
|
||||||
|
"docker volume": 3, // docker volume ls
|
||||||
|
eksctl: 2, // eksctl get clusters
|
||||||
|
"eksctl create": 3, // eksctl create cluster
|
||||||
|
firebase: 2, // firebase deploy
|
||||||
|
flyctl: 2, // flyctl deploy
|
||||||
|
gcloud: 3, // gcloud compute instances list
|
||||||
|
gh: 3, // gh pr list
|
||||||
|
git: 2, // git checkout main
|
||||||
|
"git config": 3, // git config user.name
|
||||||
|
"git remote": 3, // git remote add origin
|
||||||
|
"git stash": 3, // git stash pop
|
||||||
|
go: 2, // go build
|
||||||
|
gradle: 2, // gradle build
|
||||||
|
helm: 2, // helm install mychart
|
||||||
|
heroku: 2, // heroku logs
|
||||||
|
hugo: 2, // hugo new site blog
|
||||||
|
ip: 2, // ip link show
|
||||||
|
"ip addr": 3, // ip addr show
|
||||||
|
"ip link": 3, // ip link set eth0 up
|
||||||
|
"ip netns": 3, // ip netns exec foo bash
|
||||||
|
"ip route": 3, // ip route add default via 1.1.1.1
|
||||||
|
kind: 2, // kind delete cluster
|
||||||
|
"kind create": 3, // kind create cluster
|
||||||
|
kubectl: 2, // kubectl get pods
|
||||||
|
"kubectl kustomize": 3, // kubectl kustomize overlays/dev
|
||||||
|
"kubectl rollout": 3, // kubectl rollout restart deploy/api
|
||||||
|
kustomize: 2, // kustomize build .
|
||||||
|
make: 2, // make build
|
||||||
|
mc: 2, // mc ls myminio
|
||||||
|
"mc admin": 3, // mc admin info myminio
|
||||||
|
minikube: 2, // minikube start
|
||||||
|
mongosh: 2, // mongosh test
|
||||||
|
mysql: 2, // mysql -u root
|
||||||
|
mvn: 2, // mvn compile
|
||||||
|
ng: 2, // ng generate component home
|
||||||
|
npm: 2, // npm install
|
||||||
|
"npm exec": 3, // npm exec vite
|
||||||
|
"npm init": 3, // npm init vue
|
||||||
|
"npm run": 3, // npm run dev
|
||||||
|
"npm view": 3, // npm view react version
|
||||||
|
nvm: 2, // nvm use 18
|
||||||
|
nx: 2, // nx build
|
||||||
|
openssl: 2, // openssl genrsa 2048
|
||||||
|
"openssl req": 3, // openssl req -new -key key.pem
|
||||||
|
"openssl x509": 3, // openssl x509 -in cert.pem
|
||||||
|
pip: 2, // pip install numpy
|
||||||
|
pipenv: 2, // pipenv install flask
|
||||||
|
pnpm: 2, // pnpm install
|
||||||
|
"pnpm dlx": 3, // pnpm dlx create-next-app
|
||||||
|
"pnpm exec": 3, // pnpm exec vite
|
||||||
|
"pnpm run": 3, // pnpm run dev
|
||||||
|
poetry: 2, // poetry add requests
|
||||||
|
podman: 2, // podman run alpine
|
||||||
|
"podman container": 3, // podman container ls
|
||||||
|
"podman image": 3, // podman image prune
|
||||||
|
psql: 2, // psql -d mydb
|
||||||
|
pulumi: 2, // pulumi up
|
||||||
|
"pulumi stack": 3, // pulumi stack output
|
||||||
|
pyenv: 2, // pyenv install 3.11
|
||||||
|
python: 2, // python -m venv env
|
||||||
|
rake: 2, // rake db:migrate
|
||||||
|
rbenv: 2, // rbenv install 3.2.0
|
||||||
|
"redis-cli": 2, // redis-cli ping
|
||||||
|
rustup: 2, // rustup update
|
||||||
|
serverless: 2, // serverless invoke
|
||||||
|
sfdx: 3, // sfdx force:org:list
|
||||||
|
skaffold: 2, // skaffold dev
|
||||||
|
sls: 2, // sls deploy
|
||||||
|
sst: 2, // sst deploy
|
||||||
|
swift: 2, // swift build
|
||||||
|
systemctl: 2, // systemctl restart nginx
|
||||||
|
terraform: 2, // terraform apply
|
||||||
|
"terraform workspace": 3, // terraform workspace select prod
|
||||||
|
tmux: 2, // tmux new -s dev
|
||||||
|
turbo: 2, // turbo run build
|
||||||
|
ufw: 2, // ufw allow 22
|
||||||
|
vault: 2, // vault login
|
||||||
|
"vault auth": 3, // vault auth list
|
||||||
|
"vault kv": 3, // vault kv get secret/api
|
||||||
|
vercel: 2, // vercel deploy
|
||||||
|
volta: 2, // volta install node
|
||||||
|
wp: 2, // wp plugin install
|
||||||
|
yarn: 2, // yarn add react
|
||||||
|
"yarn dlx": 3, // yarn dlx create-react-app
|
||||||
|
"yarn run": 3, // yarn run dev
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export namespace Permission {
|
|||||||
sessionID: z.string(),
|
sessionID: z.string(),
|
||||||
messageID: z.string(),
|
messageID: z.string(),
|
||||||
callID: z.string().optional(),
|
callID: z.string().optional(),
|
||||||
title: z.string(),
|
message: z.string(),
|
||||||
metadata: z.record(z.string(), z.any()),
|
metadata: z.record(z.string(), z.any()),
|
||||||
time: z.object({
|
time: z.object({
|
||||||
created: z.number(),
|
created: z.number(),
|
||||||
@@ -99,7 +99,7 @@ export namespace Permission {
|
|||||||
|
|
||||||
export async function ask(input: {
|
export async function ask(input: {
|
||||||
type: Info["type"]
|
type: Info["type"]
|
||||||
title: Info["title"]
|
message: Info["message"]
|
||||||
pattern?: Info["pattern"]
|
pattern?: Info["pattern"]
|
||||||
callID?: Info["callID"]
|
callID?: Info["callID"]
|
||||||
sessionID: Info["sessionID"]
|
sessionID: Info["sessionID"]
|
||||||
@@ -123,7 +123,7 @@ export namespace Permission {
|
|||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
messageID: input.messageID,
|
messageID: input.messageID,
|
||||||
callID: input.callID,
|
callID: input.callID,
|
||||||
title: input.title,
|
message: input.message,
|
||||||
metadata: input.metadata,
|
metadata: input.metadata,
|
||||||
time: {
|
time: {
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { Identifier } from "@/id/id"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import { Storage } from "@/storage/storage"
|
||||||
|
import { fn } from "@/util/fn"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { Wildcard } from "@/util/wildcard"
|
||||||
|
import z from "zod"
|
||||||
|
|
||||||
|
export namespace PermissionNext {
|
||||||
|
const log = Log.create({ service: "permission" })
|
||||||
|
|
||||||
|
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||||
|
ref: "PermissionAction",
|
||||||
|
})
|
||||||
|
export type Action = z.infer<typeof Action>
|
||||||
|
|
||||||
|
export const Rule = z
|
||||||
|
.object({
|
||||||
|
permission: z.string(),
|
||||||
|
pattern: z.string(),
|
||||||
|
action: Action,
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRule",
|
||||||
|
})
|
||||||
|
export type Rule = z.infer<typeof Rule>
|
||||||
|
|
||||||
|
export const Ruleset = Rule.array().meta({
|
||||||
|
ref: "PermissionRuleset",
|
||||||
|
})
|
||||||
|
export type Ruleset = z.infer<typeof Ruleset>
|
||||||
|
|
||||||
|
export function fromConfig(permission: Config.Permission) {
|
||||||
|
const ruleset: Ruleset = []
|
||||||
|
for (const [key, value] of Object.entries(permission)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
ruleset.push({
|
||||||
|
permission: key,
|
||||||
|
action: value,
|
||||||
|
pattern: "*",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ruleset.push(...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern, action })))
|
||||||
|
}
|
||||||
|
return ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||||
|
return rulesets.flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Request = z
|
||||||
|
.object({
|
||||||
|
id: Identifier.schema("permission"),
|
||||||
|
sessionID: Identifier.schema("session"),
|
||||||
|
permission: z.string(),
|
||||||
|
patterns: z.string().array(),
|
||||||
|
metadata: z.record(z.string(), z.any()),
|
||||||
|
always: z.string().array(),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: z.string(),
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRequest",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
|
export const Reply = z.enum(["once", "always", "reject"])
|
||||||
|
export type Reply = z.infer<typeof Reply>
|
||||||
|
|
||||||
|
export const Approval = z.object({
|
||||||
|
projectID: z.string(),
|
||||||
|
patterns: z.string().array(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("permission.asked", Request),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"permission.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: z.string(),
|
||||||
|
requestID: z.string(),
|
||||||
|
reply: Reply,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = Instance.state(async () => {
|
||||||
|
const projectID = Instance.project.id
|
||||||
|
const stored = await Storage.read<Ruleset>(["permission", projectID]).catch(() => [] as Ruleset)
|
||||||
|
|
||||||
|
const pending: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
info: Request
|
||||||
|
resolve: () => void
|
||||||
|
reject: (e: any) => void
|
||||||
|
}
|
||||||
|
> = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
approved: stored,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ask = fn(
|
||||||
|
Request.partial({ id: true }).extend({
|
||||||
|
ruleset: Ruleset,
|
||||||
|
}),
|
||||||
|
async (input) => {
|
||||||
|
const s = await state()
|
||||||
|
const { ruleset, ...request } = input
|
||||||
|
for (const pattern of request.patterns ?? []) {
|
||||||
|
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
|
||||||
|
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||||
|
if (rule.action === "deny")
|
||||||
|
throw new AutoRejectedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
|
||||||
|
if (rule.action === "ask") {
|
||||||
|
const id = input.id ?? Identifier.ascending("permission")
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const info: Request = {
|
||||||
|
id,
|
||||||
|
...request,
|
||||||
|
}
|
||||||
|
s.pending[id] = {
|
||||||
|
info,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
}
|
||||||
|
Bus.publish(Event.Asked, info)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (rule.action === "allow") continue
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const reply = fn(
|
||||||
|
z.object({
|
||||||
|
requestID: Identifier.schema("permission"),
|
||||||
|
reply: Reply,
|
||||||
|
}),
|
||||||
|
async (input) => {
|
||||||
|
const s = await state()
|
||||||
|
const existing = s.pending[input.requestID]
|
||||||
|
if (!existing) return
|
||||||
|
delete s.pending[input.requestID]
|
||||||
|
Bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
reply: input.reply,
|
||||||
|
})
|
||||||
|
if (input.reply === "reject") {
|
||||||
|
existing.reject(new RejectedError())
|
||||||
|
// Reject all other pending permissions for this session
|
||||||
|
const sessionID = existing.info.sessionID
|
||||||
|
for (const [id, pending] of Object.entries(s.pending)) {
|
||||||
|
if (pending.info.sessionID === sessionID) {
|
||||||
|
delete s.pending[id]
|
||||||
|
Bus.publish(Event.Replied, {
|
||||||
|
sessionID: pending.info.sessionID,
|
||||||
|
requestID: pending.info.id,
|
||||||
|
reply: "reject",
|
||||||
|
})
|
||||||
|
pending.reject(new RejectedError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (input.reply === "once") {
|
||||||
|
existing.resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (input.reply === "always") {
|
||||||
|
for (const pattern of existing.info.always) {
|
||||||
|
s.approved.push({
|
||||||
|
permission: existing.info.permission,
|
||||||
|
pattern,
|
||||||
|
action: "allow",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.resolve()
|
||||||
|
|
||||||
|
const sessionID = existing.info.sessionID
|
||||||
|
for (const [id, pending] of Object.entries(s.pending)) {
|
||||||
|
if (pending.info.sessionID !== sessionID) continue
|
||||||
|
const ok = pending.info.patterns.every(
|
||||||
|
(pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
|
||||||
|
)
|
||||||
|
if (!ok) continue
|
||||||
|
delete s.pending[id]
|
||||||
|
Bus.publish(Event.Replied, {
|
||||||
|
sessionID: pending.info.sessionID,
|
||||||
|
requestID: pending.info.id,
|
||||||
|
reply: "always",
|
||||||
|
})
|
||||||
|
pending.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: we don't save the permission ruleset to disk yet until there's
|
||||||
|
// UI to manage it
|
||||||
|
// await Storage.write(["permission", Instance.project.id], s.approved)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
|
const merged = merge(...rulesets)
|
||||||
|
log.info("evaluate", { permission, pattern, ruleset: merged })
|
||||||
|
const match = merged.findLast(
|
||||||
|
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||||
|
)
|
||||||
|
return match ?? { action: "ask", permission, pattern: "*" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
|
||||||
|
|
||||||
|
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||||
|
const result = new Set<string>()
|
||||||
|
for (const tool of tools) {
|
||||||
|
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||||
|
if (evaluate(permission, "*", ruleset).action === "deny") {
|
||||||
|
result.add(tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AutoRejectedError extends Error {
|
||||||
|
constructor(public readonly ruleset: Ruleset) {
|
||||||
|
super(
|
||||||
|
`The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list() {
|
||||||
|
return state().then((x) => Object.values(x.pending).map((x) => x.info))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import { Flag } from "../flag/flag"
|
|||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|
||||||
|
const BUILTIN = ["opencode-copilot-auth@0.0.9", "opencode-anthropic-auth@0.0.5"]
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
const state = Instance.state(async () => {
|
||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
baseUrl: "http://localhost:4096",
|
baseUrl: "http://localhost:4096",
|
||||||
@@ -29,8 +31,7 @@ export namespace Plugin {
|
|||||||
}
|
}
|
||||||
const plugins = [...(config.plugin ?? [])]
|
const plugins = [...(config.plugin ?? [])]
|
||||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||||
plugins.push("opencode-copilot-auth@0.0.9")
|
plugins.push(...BUILTIN)
|
||||||
plugins.push("opencode-anthropic-auth@0.0.5")
|
|
||||||
}
|
}
|
||||||
for (let plugin of plugins) {
|
for (let plugin of plugins) {
|
||||||
log.info("loading plugin", { path: plugin })
|
log.info("loading plugin", { path: plugin })
|
||||||
@@ -38,7 +39,11 @@ export namespace Plugin {
|
|||||||
const lastAtIndex = plugin.lastIndexOf("@")
|
const lastAtIndex = plugin.lastIndexOf("@")
|
||||||
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
||||||
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
|
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
|
||||||
plugin = await BunProc.install(pkg, version)
|
plugin = await BunProc.install(pkg, version).catch((err) => {
|
||||||
|
if (BUILTIN.includes(pkg)) return ""
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
if (!plugin) continue
|
||||||
}
|
}
|
||||||
const mod = await import(plugin)
|
const mod = await import(plugin)
|
||||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||||
@@ -78,6 +83,7 @@ export namespace Plugin {
|
|||||||
const hooks = await state().then((x) => x.hooks)
|
const hooks = await state().then((x) => x.hooks)
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
for (const hook of hooks) {
|
for (const hook of hooks) {
|
||||||
|
// @ts-expect-error this is because we haven't moved plugin to sdk v2
|
||||||
await hook.config?.(config)
|
await hook.config?.(config)
|
||||||
}
|
}
|
||||||
Bus.subscribeAll(async (input) => {
|
Bus.subscribeAll(async (input) => {
|
||||||
|
|||||||
@@ -501,7 +501,7 @@ export namespace Provider {
|
|||||||
api: {
|
api: {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
url: provider.api!,
|
url: provider.api!,
|
||||||
npm: model.provider?.npm ?? provider.npm ?? provider.id,
|
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
|
||||||
},
|
},
|
||||||
status: model.status ?? "active",
|
status: model.status ?? "active",
|
||||||
headers: model.headers ?? {},
|
headers: model.headers ?? {},
|
||||||
@@ -646,7 +646,11 @@ export namespace Provider {
|
|||||||
api: {
|
api: {
|
||||||
id: model.id ?? existingModel?.api.id ?? modelID,
|
id: model.id ?? existingModel?.api.id ?? modelID,
|
||||||
npm:
|
npm:
|
||||||
model.provider?.npm ?? provider.npm ?? existingModel?.api.npm ?? modelsDev[providerID]?.npm ?? providerID,
|
model.provider?.npm ??
|
||||||
|
provider.npm ??
|
||||||
|
existingModel?.api.npm ??
|
||||||
|
modelsDev[providerID]?.npm ??
|
||||||
|
"@ai-sdk/openai-compatible",
|
||||||
url: provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
|
url: provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
|
||||||
},
|
},
|
||||||
status: model.status ?? existingModel?.status ?? "active",
|
status: model.status ?? existingModel?.status ?? "active",
|
||||||
@@ -1022,10 +1026,6 @@ export namespace Provider {
|
|||||||
"gemini-2.5-flash",
|
"gemini-2.5-flash",
|
||||||
"gpt-5-nano",
|
"gpt-5-nano",
|
||||||
]
|
]
|
||||||
// claude-haiku-4.5 is considered a premium model in github copilot, we shouldn't use premium requests for title gen
|
|
||||||
if (providerID === "github-copilot") {
|
|
||||||
priority = priority.filter((m) => m !== "claude-haiku-4.5")
|
|
||||||
}
|
|
||||||
if (providerID.startsWith("opencode")) {
|
if (providerID.startsWith("opencode")) {
|
||||||
priority = ["gpt-5-nano"]
|
priority = ["gpt-5-nano"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,32 +7,12 @@ import { Log } from "../util/log"
|
|||||||
import type { WSContext } from "hono/ws"
|
import type { WSContext } from "hono/ws"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { lazy } from "@opencode-ai/util/lazy"
|
import { lazy } from "@opencode-ai/util/lazy"
|
||||||
import {} from "process"
|
|
||||||
import { Installation } from "@/installation"
|
|
||||||
import { Shell } from "@/shell/shell"
|
import { Shell } from "@/shell/shell"
|
||||||
|
|
||||||
export namespace Pty {
|
export namespace Pty {
|
||||||
const log = Log.create({ service: "pty" })
|
const log = Log.create({ service: "pty" })
|
||||||
|
|
||||||
const pty = lazy(async () => {
|
const pty = lazy(async () => {
|
||||||
if (!Installation.isLocal()) {
|
|
||||||
const path = require(
|
|
||||||
`bun-pty/rust-pty/target/release/${
|
|
||||||
process.platform === "win32"
|
|
||||||
? "rust_pty.dll"
|
|
||||||
: process.platform === "linux" && process.arch === "x64"
|
|
||||||
? "librust_pty.so"
|
|
||||||
: process.platform === "darwin" && process.arch === "x64"
|
|
||||||
? "librust_pty.dylib"
|
|
||||||
: process.platform === "darwin" && process.arch === "arm64"
|
|
||||||
? "librust_pty_arm64.dylib"
|
|
||||||
: process.platform === "linux" && process.arch === "arm64"
|
|
||||||
? "librust_pty_arm64.so"
|
|
||||||
: ""
|
|
||||||
}`,
|
|
||||||
)
|
|
||||||
process.env.BUN_PTY_LIB = path
|
|
||||||
}
|
|
||||||
const { spawn } = await import("bun-pty")
|
const { spawn } = await import("bun-pty")
|
||||||
return spawn
|
return spawn
|
||||||
})
|
})
|
||||||
@@ -128,6 +108,7 @@ export namespace Pty {
|
|||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
})
|
})
|
||||||
|
|
||||||
const info = {
|
const info = {
|
||||||
id,
|
id,
|
||||||
title: input.title || `Terminal ${id.slice(-4)}`,
|
title: input.title || `Terminal ${id.slice(-4)}`,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { LSP } from "../lsp"
|
|||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { MessageV2 } from "../session/message-v2"
|
import { MessageV2 } from "../session/message-v2"
|
||||||
import { TuiRoute } from "./tui"
|
import { TuiRoute } from "./tui"
|
||||||
import { Permission } from "../permission"
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Vcs } from "../project/vcs"
|
import { Vcs } from "../project/vcs"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
@@ -45,9 +44,9 @@ import { Snapshot } from "@/snapshot"
|
|||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
import { SessionStatus } from "@/session/status"
|
import { SessionStatus } from "@/session/status"
|
||||||
import { upgradeWebSocket, websocket } from "hono/bun"
|
import { upgradeWebSocket, websocket } from "hono/bun"
|
||||||
import type { BunWebSocketData } from "hono/bun"
|
|
||||||
import { errors } from "./error"
|
import { errors } from "./error"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
|
import { PermissionNext } from "@/permission/next"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { MDNS } from "./mdns"
|
import { MDNS } from "./mdns"
|
||||||
|
|
||||||
@@ -58,6 +57,7 @@ export namespace Server {
|
|||||||
const log = Log.create({ service: "server" })
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
let _url: URL | undefined
|
let _url: URL | undefined
|
||||||
|
let _corsWhitelist: string[] = []
|
||||||
|
|
||||||
export function url(): URL {
|
export function url(): URL {
|
||||||
return _url ?? new URL("http://localhost:4096")
|
return _url ?? new URL("http://localhost:4096")
|
||||||
@@ -117,6 +117,10 @@ export namespace Server {
|
|||||||
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
|
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
if (_corsWhitelist.includes(input)) {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1520,6 +1524,7 @@ export namespace Server {
|
|||||||
"/session/:sessionID/permissions/:permissionID",
|
"/session/:sessionID/permissions/:permissionID",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Respond to permission",
|
summary: "Respond to permission",
|
||||||
|
deprecated: true,
|
||||||
description: "Approve or deny a permission request from the AI assistant.",
|
description: "Approve or deny a permission request from the AI assistant.",
|
||||||
operationId: "permission.respond",
|
operationId: "permission.respond",
|
||||||
responses: {
|
responses: {
|
||||||
@@ -1541,15 +1546,47 @@ export namespace Server {
|
|||||||
permissionID: z.string(),
|
permissionID: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator("json", z.object({ response: Permission.Response })),
|
validator("json", z.object({ response: PermissionNext.Reply })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
const sessionID = params.sessionID
|
PermissionNext.reply({
|
||||||
const permissionID = params.permissionID
|
requestID: params.permissionID,
|
||||||
Permission.respond({
|
reply: c.req.valid("json").response,
|
||||||
sessionID,
|
})
|
||||||
permissionID,
|
return c.json(true)
|
||||||
response: c.req.valid("json").response,
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/permission/:requestID/reply",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Respond to permission request",
|
||||||
|
description: "Approve or deny a permission request from the AI assistant.",
|
||||||
|
operationId: "permission.reply",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Permission processed successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400, 404),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
requestID: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
validator("json", z.object({ reply: PermissionNext.Reply })),
|
||||||
|
async (c) => {
|
||||||
|
const params = c.req.valid("param")
|
||||||
|
const json = c.req.valid("json")
|
||||||
|
await PermissionNext.reply({
|
||||||
|
requestID: params.requestID,
|
||||||
|
reply: json.reply,
|
||||||
})
|
})
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
@@ -1565,14 +1602,14 @@ export namespace Server {
|
|||||||
description: "List of pending permissions",
|
description: "List of pending permissions",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Permission.Info.array()),
|
schema: resolver(PermissionNext.Request.array()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const permissions = Permission.list()
|
const permissions = await PermissionNext.list()
|
||||||
return c.json(permissions)
|
return c.json(permissions)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2653,12 +2690,44 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.all("/*", async (c) => {
|
.all("/*", async (c) => {
|
||||||
return proxy(`https://app.opencode.ai${c.req.path}`, {
|
const path = c.req.path
|
||||||
|
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||||
...c.req,
|
...c.req,
|
||||||
headers: {
|
headers: {
|
||||||
host: "app.opencode.ai",
|
host: "app.opencode.ai",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// Cloudflare doesn't return Content-Type for static assets, so we need to add it
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
".js": "application/javascript",
|
||||||
|
".mjs": "application/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
".json": "application/json",
|
||||||
|
".wasm": "application/wasm",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".eot": "application/vnd.ms-fontobject",
|
||||||
|
}
|
||||||
|
for (const [ext, mime] of Object.entries(mimeTypes)) {
|
||||||
|
if (path.endsWith(ext)) {
|
||||||
|
const headers = new Headers(response.headers)
|
||||||
|
headers.set("Content-Type", mime)
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2676,7 +2745,9 @@ export namespace Server {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listen(opts: { port: number; hostname: string; mdns?: boolean }) {
|
export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
||||||
|
_corsWhitelist = opts.cors ?? []
|
||||||
|
|
||||||
const args = {
|
const args = {
|
||||||
hostname: opts.hostname,
|
hostname: opts.hostname,
|
||||||
idleTimeout: 0,
|
idleTimeout: 0,
|
||||||
@@ -2702,7 +2773,7 @@ export namespace Server {
|
|||||||
opts.hostname !== "localhost" &&
|
opts.hostname !== "localhost" &&
|
||||||
opts.hostname !== "::1"
|
opts.hostname !== "::1"
|
||||||
if (shouldPublishMDNS) {
|
if (shouldPublishMDNS) {
|
||||||
MDNS.publish(server.port!)
|
MDNS.publish(server.port!, `opencode-${server.port!}`)
|
||||||
} else if (opts.mdns) {
|
} else if (opts.mdns) {
|
||||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Command } from "../command"
|
|||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
|
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
|
import { PermissionNext } from "@/permission/next"
|
||||||
|
|
||||||
export namespace Session {
|
export namespace Session {
|
||||||
const log = Log.create({ service: "session" })
|
const log = Log.create({ service: "session" })
|
||||||
@@ -62,6 +63,7 @@ export namespace Session {
|
|||||||
compacting: z.number().optional(),
|
compacting: z.number().optional(),
|
||||||
archived: z.number().optional(),
|
archived: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
|
permission: PermissionNext.Ruleset.optional(),
|
||||||
revert: z
|
revert: z
|
||||||
.object({
|
.object({
|
||||||
messageID: z.string(),
|
messageID: z.string(),
|
||||||
@@ -126,6 +128,7 @@ export namespace Session {
|
|||||||
.object({
|
.object({
|
||||||
parentID: Identifier.schema("session").optional(),
|
parentID: Identifier.schema("session").optional(),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
permission: Info.shape.permission,
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
async (input) => {
|
async (input) => {
|
||||||
@@ -133,6 +136,7 @@ export namespace Session {
|
|||||||
parentID: input?.parentID,
|
parentID: input?.parentID,
|
||||||
directory: Instance.directory,
|
directory: Instance.directory,
|
||||||
title: input?.title,
|
title: input?.title,
|
||||||
|
permission: input?.permission,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -174,7 +178,13 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) {
|
export async function createNext(input: {
|
||||||
|
id?: string
|
||||||
|
title?: string
|
||||||
|
parentID?: string
|
||||||
|
directory: string
|
||||||
|
permission?: PermissionNext.Ruleset
|
||||||
|
}) {
|
||||||
const result: Info = {
|
const result: Info = {
|
||||||
id: Identifier.descending("session", input.id),
|
id: Identifier.descending("session", input.id),
|
||||||
version: Installation.VERSION,
|
version: Installation.VERSION,
|
||||||
@@ -182,6 +192,7 @@ export namespace Session {
|
|||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
parentID: input.parentID,
|
parentID: input.parentID,
|
||||||
title: input.title ?? createDefaultTitle(!!input.parentID),
|
title: input.title ?? createDefaultTitle(!!input.parentID),
|
||||||
|
permission: input.permission,
|
||||||
time: {
|
time: {
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
updated: Date.now(),
|
updated: Date.now(),
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import type { Agent } from "@/agent/agent"
|
|||||||
import type { MessageV2 } from "./message-v2"
|
import type { MessageV2 } from "./message-v2"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { ToolRegistry } from "@/tool/registry"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { PermissionNext } from "@/permission/next"
|
||||||
|
|
||||||
export namespace LLM {
|
export namespace LLM {
|
||||||
const log = Log.create({ service: "llm" })
|
const log = Log.create({ service: "llm" })
|
||||||
@@ -200,13 +200,11 @@ export namespace LLM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
|
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
|
||||||
const enabled = pipe(
|
const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
|
||||||
input.agent.tools,
|
for (const tool of Object.keys(input.tools)) {
|
||||||
mergeDeep(await ToolRegistry.enabled(input.agent)),
|
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
|
||||||
mergeDeep(input.user.tools ?? {}),
|
delete input.tools[tool]
|
||||||
)
|
}
|
||||||
for (const [key, value] of Object.entries(enabled)) {
|
|
||||||
if (value === false) delete input.tools[key]
|
|
||||||
}
|
}
|
||||||
return input.tools
|
return input.tools
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Log } from "@/util/log"
|
|||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
import { Session } from "."
|
import { Session } from "."
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Permission } from "@/permission"
|
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { SessionSummary } from "./summary"
|
import { SessionSummary } from "./summary"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
@@ -13,6 +12,8 @@ import { Plugin } from "@/plugin"
|
|||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
import { SessionCompaction } from "./compaction"
|
||||||
|
import { PermissionNext } from "@/permission/next"
|
||||||
|
|
||||||
export namespace SessionProcessor {
|
export namespace SessionProcessor {
|
||||||
const DOOM_LOOP_THRESHOLD = 3
|
const DOOM_LOOP_THRESHOLD = 3
|
||||||
@@ -31,6 +32,7 @@ export namespace SessionProcessor {
|
|||||||
let snapshot: string | undefined
|
let snapshot: string | undefined
|
||||||
let blocked = false
|
let blocked = false
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
|
let needsCompaction = false
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
get message() {
|
get message() {
|
||||||
@@ -41,6 +43,7 @@ export namespace SessionProcessor {
|
|||||||
},
|
},
|
||||||
async process(streamInput: LLM.StreamInput) {
|
async process(streamInput: LLM.StreamInput) {
|
||||||
log.info("process")
|
log.info("process")
|
||||||
|
needsCompaction = false
|
||||||
const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true
|
const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
@@ -149,32 +152,18 @@ export namespace SessionProcessor {
|
|||||||
JSON.stringify(p.state.input) === JSON.stringify(value.input),
|
JSON.stringify(p.state.input) === JSON.stringify(value.input),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
const permission = await Agent.get(input.assistantMessage.mode).then((x) => x.permission)
|
const agent = await Agent.get(input.assistantMessage.agent)
|
||||||
if (permission.doom_loop === "ask") {
|
await PermissionNext.ask({
|
||||||
await Permission.ask({
|
permission: "doom_loop",
|
||||||
type: "doom_loop",
|
patterns: [value.toolName],
|
||||||
pattern: value.toolName,
|
sessionID: input.assistantMessage.sessionID,
|
||||||
sessionID: input.assistantMessage.sessionID,
|
metadata: {
|
||||||
messageID: input.assistantMessage.id,
|
tool: value.toolName,
|
||||||
callID: value.toolCallId,
|
input: value.input,
|
||||||
title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`,
|
},
|
||||||
metadata: {
|
always: [value.toolName],
|
||||||
tool: value.toolName,
|
ruleset: agent.permission,
|
||||||
input: value.input,
|
})
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (permission.doom_loop === "deny") {
|
|
||||||
throw new Permission.RejectedError(
|
|
||||||
input.assistantMessage.sessionID,
|
|
||||||
"doom_loop",
|
|
||||||
value.toolCallId,
|
|
||||||
{
|
|
||||||
tool: value.toolName,
|
|
||||||
input: value.input,
|
|
||||||
},
|
|
||||||
`You seem to be stuck in a doom loop, please stop repeating the same action`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -212,7 +201,6 @@ export namespace SessionProcessor {
|
|||||||
status: "error",
|
status: "error",
|
||||||
input: value.input,
|
input: value.input,
|
||||||
error: (value.error as any).toString(),
|
error: (value.error as any).toString(),
|
||||||
metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined,
|
|
||||||
time: {
|
time: {
|
||||||
start: match.state.time.start,
|
start: match.state.time.start,
|
||||||
end: Date.now(),
|
end: Date.now(),
|
||||||
@@ -220,7 +208,7 @@ export namespace SessionProcessor {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (value.error instanceof Permission.RejectedError) {
|
if (value.error instanceof PermissionNext.RejectedError) {
|
||||||
blocked = shouldBreak
|
blocked = shouldBreak
|
||||||
}
|
}
|
||||||
delete toolcalls[value.toolCallId]
|
delete toolcalls[value.toolCallId]
|
||||||
@@ -279,6 +267,9 @@ export namespace SessionProcessor {
|
|||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
messageID: input.assistantMessage.parentID,
|
messageID: input.assistantMessage.parentID,
|
||||||
})
|
})
|
||||||
|
if (await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model })) {
|
||||||
|
needsCompaction = true
|
||||||
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
case "text-start":
|
case "text-start":
|
||||||
@@ -339,6 +330,7 @@ export namespace SessionProcessor {
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (needsCompaction) break
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
log.error("process", {
|
log.error("process", {
|
||||||
@@ -398,6 +390,7 @@ export namespace SessionProcessor {
|
|||||||
}
|
}
|
||||||
input.assistantMessage.time.completed = Date.now()
|
input.assistantMessage.time.completed = Date.now()
|
||||||
await Session.updateMessage(input.assistantMessage)
|
await Session.updateMessage(input.assistantMessage)
|
||||||
|
if (needsCompaction) return "compact"
|
||||||
if (blocked) return "stop"
|
if (blocked) return "stop"
|
||||||
if (input.assistantMessage.error) return "stop"
|
if (input.assistantMessage.error) return "stop"
|
||||||
return "continue"
|
return "continue"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { SessionRevert } from "./revert"
|
|||||||
import { Session } from "."
|
import { Session } from "."
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { Provider } from "../provider/provider"
|
import { Provider } from "../provider/provider"
|
||||||
import { type Tool as AITool, tool, jsonSchema } from "ai"
|
import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai"
|
||||||
import { SessionCompaction } from "./compaction"
|
import { SessionCompaction } from "./compaction"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
@@ -20,9 +20,8 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
|
|||||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||||
import { defer } from "../util/defer"
|
import { defer } from "../util/defer"
|
||||||
import { clone, mergeDeep, pipe } from "remeda"
|
import { clone } from "remeda"
|
||||||
import { ToolRegistry } from "../tool/registry"
|
import { ToolRegistry } from "../tool/registry"
|
||||||
import { Wildcard } from "../util/wildcard"
|
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { ReadTool } from "../tool/read"
|
import { ReadTool } from "../tool/read"
|
||||||
@@ -39,6 +38,8 @@ import { NamedError } from "@opencode-ai/util/error"
|
|||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { SessionProcessor } from "./processor"
|
import { SessionProcessor } from "./processor"
|
||||||
import { TaskTool } from "@/tool/task"
|
import { TaskTool } from "@/tool/task"
|
||||||
|
import { Tool } from "@/tool/tool"
|
||||||
|
import { PermissionNext } from "@/permission/next"
|
||||||
import { SessionStatus } from "./status"
|
import { SessionStatus } from "./status"
|
||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
@@ -68,6 +69,9 @@ export namespace SessionPrompt {
|
|||||||
async (current) => {
|
async (current) => {
|
||||||
for (const item of Object.values(current)) {
|
for (const item of Object.values(current)) {
|
||||||
item.abort.abort()
|
item.abort.abort()
|
||||||
|
for (const callback of item.callbacks) {
|
||||||
|
callback.reject()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -88,7 +92,12 @@ export namespace SessionPrompt {
|
|||||||
.optional(),
|
.optional(),
|
||||||
agent: z.string().optional(),
|
agent: z.string().optional(),
|
||||||
noReply: z.boolean().optional(),
|
noReply: z.boolean().optional(),
|
||||||
tools: z.record(z.string(), z.boolean()).optional(),
|
tools: z
|
||||||
|
.record(z.string(), z.boolean())
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
|
||||||
|
),
|
||||||
system: z.string().optional(),
|
system: z.string().optional(),
|
||||||
variant: z.string().optional(),
|
variant: z.string().optional(),
|
||||||
parts: z.array(
|
parts: z.array(
|
||||||
@@ -145,6 +154,23 @@ export namespace SessionPrompt {
|
|||||||
const message = await createUserMessage(input)
|
const message = await createUserMessage(input)
|
||||||
await Session.touch(input.sessionID)
|
await Session.touch(input.sessionID)
|
||||||
|
|
||||||
|
// this is backwards compatibility for allowing `tools` to be specified when
|
||||||
|
// prompting
|
||||||
|
const permissions: PermissionNext.Ruleset = []
|
||||||
|
for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
|
||||||
|
permissions.push({
|
||||||
|
permission: tool,
|
||||||
|
action: enabled ? "allow" : "deny",
|
||||||
|
pattern: "*",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (permissions.length > 0) {
|
||||||
|
session.permission = permissions
|
||||||
|
await Session.update(session.id, (draft) => {
|
||||||
|
draft.permission = permissions
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (input.noReply === true) {
|
if (input.noReply === true) {
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
@@ -240,6 +266,7 @@ export namespace SessionPrompt {
|
|||||||
using _ = defer(() => cancel(sessionID))
|
using _ = defer(() => cancel(sessionID))
|
||||||
|
|
||||||
let step = 0
|
let step = 0
|
||||||
|
const session = await Session.get(sessionID)
|
||||||
while (true) {
|
while (true) {
|
||||||
SessionStatus.set(sessionID, { type: "busy" })
|
SessionStatus.set(sessionID, { type: "busy" })
|
||||||
log.info("loop", { step, sessionID })
|
log.info("loop", { step, sessionID })
|
||||||
@@ -276,7 +303,7 @@ export namespace SessionPrompt {
|
|||||||
step++
|
step++
|
||||||
if (step === 1)
|
if (step === 1)
|
||||||
ensureTitle({
|
ensureTitle({
|
||||||
session: await Session.get(sessionID),
|
session,
|
||||||
modelID: lastUser.model.modelID,
|
modelID: lastUser.model.modelID,
|
||||||
providerID: lastUser.model.providerID,
|
providerID: lastUser.model.providerID,
|
||||||
message: msgs.find((m) => m.info.role === "user")!,
|
message: msgs.find((m) => m.info.role === "user")!,
|
||||||
@@ -350,28 +377,35 @@ export namespace SessionPrompt {
|
|||||||
{ args: taskArgs },
|
{ args: taskArgs },
|
||||||
)
|
)
|
||||||
let executionError: Error | undefined
|
let executionError: Error | undefined
|
||||||
const result = await taskTool
|
const taskAgent = await Agent.get(task.agent)
|
||||||
.execute(taskArgs, {
|
const taskCtx: Tool.Context = {
|
||||||
agent: task.agent,
|
agent: task.agent,
|
||||||
messageID: assistantMessage.id,
|
messageID: assistantMessage.id,
|
||||||
sessionID: sessionID,
|
sessionID: sessionID,
|
||||||
abort,
|
abort,
|
||||||
async metadata(input) {
|
async metadata(input) {
|
||||||
await Session.updatePart({
|
await Session.updatePart({
|
||||||
...part,
|
...part,
|
||||||
type: "tool",
|
type: "tool",
|
||||||
state: {
|
state: {
|
||||||
...part.state,
|
...part.state,
|
||||||
...input,
|
...input,
|
||||||
},
|
},
|
||||||
} satisfies MessageV2.ToolPart)
|
} satisfies MessageV2.ToolPart)
|
||||||
},
|
},
|
||||||
})
|
async ask(req) {
|
||||||
.catch((error) => {
|
await PermissionNext.ask({
|
||||||
executionError = error
|
...req,
|
||||||
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
|
sessionID: sessionID,
|
||||||
return undefined
|
ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => {
|
||||||
|
executionError = error
|
||||||
|
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
await Plugin.trigger(
|
await Plugin.trigger(
|
||||||
"tool.execute.after",
|
"tool.execute.after",
|
||||||
{
|
{
|
||||||
@@ -473,7 +507,7 @@ export namespace SessionPrompt {
|
|||||||
|
|
||||||
// normal processing
|
// normal processing
|
||||||
const agent = await Agent.get(lastUser.agent)
|
const agent = await Agent.get(lastUser.agent)
|
||||||
const maxSteps = agent.maxSteps ?? Infinity
|
const maxSteps = agent.steps ?? Infinity
|
||||||
const isLastStep = step >= maxSteps
|
const isLastStep = step >= maxSteps
|
||||||
msgs = insertReminders({
|
msgs = insertReminders({
|
||||||
messages: msgs,
|
messages: msgs,
|
||||||
@@ -511,7 +545,7 @@ export namespace SessionPrompt {
|
|||||||
})
|
})
|
||||||
const tools = await resolveTools({
|
const tools = await resolveTools({
|
||||||
agent,
|
agent,
|
||||||
sessionID,
|
session,
|
||||||
model,
|
model,
|
||||||
tools: lastUser.tools,
|
tools: lastUser.tools,
|
||||||
processor,
|
processor,
|
||||||
@@ -549,6 +583,14 @@ export namespace SessionPrompt {
|
|||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
if (result === "stop") break
|
if (result === "stop") break
|
||||||
|
if (result === "compact") {
|
||||||
|
await SessionCompaction.create({
|
||||||
|
sessionID,
|
||||||
|
agent: lastUser.agent,
|
||||||
|
model: lastUser.model,
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
SessionCompaction.prune({ sessionID })
|
SessionCompaction.prune({ sessionID })
|
||||||
@@ -573,67 +615,73 @@ export namespace SessionPrompt {
|
|||||||
async function resolveTools(input: {
|
async function resolveTools(input: {
|
||||||
agent: Agent.Info
|
agent: Agent.Info
|
||||||
model: Provider.Model
|
model: Provider.Model
|
||||||
sessionID: string
|
session: Session.Info
|
||||||
tools?: Record<string, boolean>
|
tools?: Record<string, boolean>
|
||||||
processor: SessionProcessor.Info
|
processor: SessionProcessor.Info
|
||||||
}) {
|
}) {
|
||||||
using _ = log.time("resolveTools")
|
using _ = log.time("resolveTools")
|
||||||
const tools: Record<string, AITool> = {}
|
const tools: Record<string, AITool> = {}
|
||||||
const enabledTools = pipe(
|
|
||||||
input.agent.tools,
|
const context = (args: any, options: ToolCallOptions): Tool.Context => ({
|
||||||
mergeDeep(await ToolRegistry.enabled(input.agent)),
|
sessionID: input.session.id,
|
||||||
mergeDeep(input.tools ?? {}),
|
abort: options.abortSignal!,
|
||||||
)
|
messageID: input.processor.message.id,
|
||||||
for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
|
callID: options.toolCallId,
|
||||||
if (Wildcard.all(item.id, enabledTools) === false) continue
|
extra: { model: input.model },
|
||||||
|
agent: input.agent.name,
|
||||||
|
metadata: async (val: { title?: string; metadata?: any }) => {
|
||||||
|
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||||
|
if (match && match.state.status === "running") {
|
||||||
|
await Session.updatePart({
|
||||||
|
...match,
|
||||||
|
state: {
|
||||||
|
title: val.title,
|
||||||
|
metadata: val.metadata,
|
||||||
|
status: "running",
|
||||||
|
input: args,
|
||||||
|
time: {
|
||||||
|
start: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ask(req) {
|
||||||
|
await PermissionNext.ask({
|
||||||
|
...req,
|
||||||
|
sessionID: input.session.id,
|
||||||
|
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
||||||
|
ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const item of await ToolRegistry.tools(input.model.providerID)) {
|
||||||
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
||||||
tools[item.id] = tool({
|
tools[item.id] = tool({
|
||||||
id: item.id as any,
|
id: item.id as any,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
inputSchema: jsonSchema(schema as any),
|
inputSchema: jsonSchema(schema as any),
|
||||||
async execute(args, options) {
|
async execute(args, options) {
|
||||||
|
const ctx = context(args, options)
|
||||||
await Plugin.trigger(
|
await Plugin.trigger(
|
||||||
"tool.execute.before",
|
"tool.execute.before",
|
||||||
{
|
{
|
||||||
tool: item.id,
|
tool: item.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
callID: options.toolCallId,
|
callID: ctx.callID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
args,
|
args,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const result = await item.execute(args, {
|
const result = await item.execute(args, ctx)
|
||||||
sessionID: input.sessionID,
|
|
||||||
abort: options.abortSignal!,
|
|
||||||
messageID: input.processor.message.id,
|
|
||||||
callID: options.toolCallId,
|
|
||||||
extra: { model: input.model },
|
|
||||||
agent: input.agent.name,
|
|
||||||
metadata: async (val) => {
|
|
||||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
|
||||||
if (match && match.state.status === "running") {
|
|
||||||
await Session.updatePart({
|
|
||||||
...match,
|
|
||||||
state: {
|
|
||||||
title: val.title,
|
|
||||||
metadata: val.metadata,
|
|
||||||
status: "running",
|
|
||||||
input: args,
|
|
||||||
time: {
|
|
||||||
start: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await Plugin.trigger(
|
await Plugin.trigger(
|
||||||
"tool.execute.after",
|
"tool.execute.after",
|
||||||
{
|
{
|
||||||
tool: item.id,
|
tool: item.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
callID: options.toolCallId,
|
callID: ctx.callID,
|
||||||
},
|
},
|
||||||
result,
|
result,
|
||||||
)
|
)
|
||||||
@@ -647,31 +695,41 @@ export namespace SessionPrompt {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, item] of Object.entries(await MCP.tools())) {
|
for (const [key, item] of Object.entries(await MCP.tools())) {
|
||||||
if (Wildcard.all(key, enabledTools) === false) continue
|
|
||||||
const execute = item.execute
|
const execute = item.execute
|
||||||
if (!execute) continue
|
if (!execute) continue
|
||||||
|
|
||||||
// Wrap execute to add plugin hooks and format output
|
// Wrap execute to add plugin hooks and format output
|
||||||
item.execute = async (args, opts) => {
|
item.execute = async (args, opts) => {
|
||||||
|
const ctx = context(args, opts)
|
||||||
|
|
||||||
await Plugin.trigger(
|
await Plugin.trigger(
|
||||||
"tool.execute.before",
|
"tool.execute.before",
|
||||||
{
|
{
|
||||||
tool: key,
|
tool: key,
|
||||||
sessionID: input.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
callID: opts.toolCallId,
|
callID: opts.toolCallId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
args,
|
args,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await ctx.ask({
|
||||||
|
permission: key,
|
||||||
|
metadata: {},
|
||||||
|
patterns: ["*"],
|
||||||
|
always: ["*"],
|
||||||
|
})
|
||||||
|
|
||||||
const result = await execute(args, opts)
|
const result = await execute(args, opts)
|
||||||
|
|
||||||
await Plugin.trigger(
|
await Plugin.trigger(
|
||||||
"tool.execute.after",
|
"tool.execute.after",
|
||||||
{
|
{
|
||||||
tool: key,
|
tool: key,
|
||||||
sessionID: input.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
callID: opts.toolCallId,
|
callID: opts.toolCallId,
|
||||||
},
|
},
|
||||||
result,
|
result,
|
||||||
@@ -686,7 +744,7 @@ export namespace SessionPrompt {
|
|||||||
} else if (contentItem.type === "image") {
|
} else if (contentItem.type === "image") {
|
||||||
attachments.push({
|
attachments.push({
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
sessionID: input.sessionID,
|
sessionID: input.session.id,
|
||||||
messageID: input.processor.message.id,
|
messageID: input.processor.message.id,
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: contentItem.mimeType,
|
mime: contentItem.mimeType,
|
||||||
@@ -826,14 +884,16 @@ export namespace SessionPrompt {
|
|||||||
await ReadTool.init()
|
await ReadTool.init()
|
||||||
.then(async (t) => {
|
.then(async (t) => {
|
||||||
const model = await Provider.getModel(info.model.providerID, info.model.modelID)
|
const model = await Provider.getModel(info.model.providerID, info.model.modelID)
|
||||||
const result = await t.execute(args, {
|
const readCtx: Tool.Context = {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
abort: new AbortController().signal,
|
abort: new AbortController().signal,
|
||||||
agent: input.agent!,
|
agent: input.agent!,
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
extra: { bypassCwdCheck: true, model },
|
extra: { bypassCwdCheck: true, model },
|
||||||
metadata: async () => {},
|
metadata: async () => {},
|
||||||
})
|
ask: async () => {},
|
||||||
|
}
|
||||||
|
const result = await t.execute(args, readCtx)
|
||||||
pieces.push({
|
pieces.push({
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
@@ -885,16 +945,16 @@ export namespace SessionPrompt {
|
|||||||
|
|
||||||
if (part.mime === "application/x-directory") {
|
if (part.mime === "application/x-directory") {
|
||||||
const args = { path: filepath }
|
const args = { path: filepath }
|
||||||
const result = await ListTool.init().then((t) =>
|
const listCtx: Tool.Context = {
|
||||||
t.execute(args, {
|
sessionID: input.sessionID,
|
||||||
sessionID: input.sessionID,
|
abort: new AbortController().signal,
|
||||||
abort: new AbortController().signal,
|
agent: input.agent!,
|
||||||
agent: input.agent!,
|
messageID: info.id,
|
||||||
messageID: info.id,
|
extra: { bypassCwdCheck: true },
|
||||||
extra: { bypassCwdCheck: true },
|
metadata: async () => {},
|
||||||
metadata: async () => {},
|
ask: async () => {},
|
||||||
}),
|
}
|
||||||
)
|
const result = await ListTool.init().then((t) => t.execute(args, listCtx))
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
@@ -1331,7 +1391,7 @@ export namespace SessionPrompt {
|
|||||||
}
|
}
|
||||||
if (command.agent) {
|
if (command.agent) {
|
||||||
const cmdAgent = await Agent.get(command.agent)
|
const cmdAgent = await Agent.get(command.agent)
|
||||||
if (cmdAgent.model) {
|
if (cmdAgent?.model) {
|
||||||
return cmdAgent.model
|
return cmdAgent.model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1353,6 +1413,16 @@ export namespace SessionPrompt {
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
const agent = await Agent.get(agentName)
|
const agent = await Agent.get(agentName)
|
||||||
|
if (!agent) {
|
||||||
|
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
|
||||||
|
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||||
|
const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
|
||||||
|
Bus.publish(Session.Event.Error, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
error: error.toObject(),
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
const parts =
|
const parts =
|
||||||
(agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
|
(agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
|
||||||
|
|||||||
@@ -5,18 +5,22 @@ export namespace SessionRetry {
|
|||||||
export const RETRY_INITIAL_DELAY = 2000
|
export const RETRY_INITIAL_DELAY = 2000
|
||||||
export const RETRY_BACKOFF_FACTOR = 2
|
export const RETRY_BACKOFF_FACTOR = 2
|
||||||
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
|
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
|
||||||
|
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
|
||||||
|
|
||||||
export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(resolve, ms)
|
const abortHandler = () => {
|
||||||
signal.addEventListener(
|
clearTimeout(timeout)
|
||||||
"abort",
|
reject(new DOMException("Aborted", "AbortError"))
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
clearTimeout(timeout)
|
signal.removeEventListener("abort", abortHandler)
|
||||||
reject(new DOMException("Aborted", "AbortError"))
|
resolve()
|
||||||
},
|
},
|
||||||
{ once: true },
|
Math.min(ms, RETRY_MAX_DELAY),
|
||||||
)
|
)
|
||||||
|
signal.addEventListener("abort", abortHandler, { once: true })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export namespace SystemPrompt {
|
|||||||
`</env>`,
|
`</env>`,
|
||||||
`<files>`,
|
`<files>`,
|
||||||
` ${
|
` ${
|
||||||
project.vcs === "git"
|
project.vcs === "git" && false
|
||||||
? await Ripgrep.tree({
|
? await Ripgrep.tree({
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
limit: 200,
|
limit: 200,
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export namespace ShareNext {
|
|||||||
const queued = queue.get(sessionID)
|
const queued = queue.get(sessionID)
|
||||||
if (!queued) return
|
if (!queued) return
|
||||||
queue.delete(sessionID)
|
queue.delete(sessionID)
|
||||||
const share = await get(sessionID)
|
const share = await get(sessionID).catch(() => undefined)
|
||||||
if (!share) return
|
if (!share) return
|
||||||
|
|
||||||
await fetch(`${await url()}/api/share/${share.id}/sync`, {
|
await fetch(`${await url()}/api/share/${share.id}/sync`, {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user