Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd9c13bb27 | ||
|
|
3019b1f825 | ||
|
|
119cc8c795 | ||
|
|
be9b2bab15 | ||
|
|
c949e5b390 | ||
|
|
1c717d62e4 | ||
|
|
27675dfd70 | ||
|
|
374275eeb6 | ||
|
|
faa848cfb1 | ||
|
|
80772e5ac2 | ||
|
|
be34747526 | ||
|
|
49d9f99924 | ||
|
|
1f9e195cd8 | ||
|
|
539d6baa8c | ||
|
|
f6fc693c1f | ||
|
|
50d8396c9a | ||
|
|
22dd70b75b | ||
|
|
b4f8de0c0a | ||
|
|
768e0553bd | ||
|
|
f3e79235fb | ||
|
|
eacf3ad361 | ||
|
|
72062d22a0 | ||
|
|
9f90f0e8ed | ||
|
|
c74bc323b6 | ||
|
|
c7b825a42a | ||
|
|
b1a613b3b9 | ||
|
|
958f1edfef | ||
|
|
2bb299d741 | ||
|
|
9930ac6929 | ||
|
|
1906a347f3 | ||
|
|
e5d0c63b29 | ||
|
|
970796b832 | ||
|
|
3c5043497c | ||
|
|
4d09c5618e | ||
|
|
adae0d1853 | ||
|
|
61aeb2a2a7 | ||
|
|
4b0f7b82ba | ||
|
|
9fb24074c8 | ||
|
|
542c9d5346 | ||
|
|
d5f0e3fccc | ||
|
|
7d2bb5cb2b | ||
|
|
ca7a70b628 | ||
|
|
b3a2f9fb4e | ||
|
|
8be5a29870 | ||
|
|
68092f22e1 | ||
|
|
83f3c729e9 | ||
|
|
e37fd9c105 | ||
|
|
2e4fe973c9 | ||
|
|
1b82511fbd | ||
|
|
f24314438b | ||
|
|
361a962673 | ||
|
|
fa9c283fcf | ||
|
|
947b864d96 | ||
|
|
03eabb10e4 | ||
|
|
34c9d106ee | ||
|
|
fe57d7bb38 | ||
|
|
68cf6b04a0 | ||
|
|
9ffaf81fb3 | ||
|
|
50530b1ea7 | ||
|
|
a160eee499 | ||
|
|
d9aef1d73d | ||
|
|
4ba0b22b04 | ||
|
|
662d2b205a | ||
|
|
75960ae00c | ||
|
|
528f198c39 | ||
|
|
184834da98 | ||
|
|
008a5c10cc | ||
|
|
2d5b9a5cc6 | ||
|
|
fb3ca895d6 | ||
|
|
d3d379fe2e | ||
|
|
b41626049c | ||
|
|
e59be27810 | ||
|
|
1e2992244f | ||
|
|
fd22b26478 | ||
|
|
ea2ee46f45 | ||
|
|
4e1b6b3417 | ||
|
|
2d52a461a0 | ||
|
|
9cce0cf4f4 | ||
|
|
a41c8508da | ||
|
|
4f7458b47d | ||
|
|
270cd05195 | ||
|
|
24c933ae60 | ||
|
|
2b7a021ba3 | ||
|
|
cbf87c50b9 | ||
|
|
3c375b971e | ||
|
|
6590c1641f | ||
|
|
0ffe496869 | ||
|
|
ce4e595881 | ||
|
|
e91cc7e514 | ||
|
|
c961072d20 | ||
|
|
429240f439 | ||
|
|
a0dc90bfcc | ||
|
|
6bac501be5 | ||
|
|
b5be883758 | ||
|
|
0021a09ba8 | ||
|
|
a8c2928a87 | ||
|
|
79f6910697 | ||
|
|
04cea9cf11 | ||
|
|
61c334f1fb | ||
|
|
85ed329318 | ||
|
|
37decee795 | ||
|
|
5d0007ade4 | ||
|
|
31dd9fd13a | ||
|
|
23fc675ad5 | ||
|
|
22b058a33d | ||
|
|
939c0940aa | ||
|
|
fd7b7eacd3 | ||
|
|
eaa0826e7f | ||
|
|
f6055ad3d2 | ||
|
|
761863ae35 | ||
|
|
dadc08ddc7 | ||
|
|
d7afb01d13 | ||
|
|
5168988156 | ||
|
|
9a3bd0ade1 | ||
|
|
4e5b0b00b0 | ||
|
|
7672b722ca | ||
|
|
488c3502a7 | ||
|
|
a2f80f7c0d | ||
|
|
dee0226741 | ||
|
|
58aba1c797 | ||
|
|
9906d42e1c | ||
|
|
43eefbc349 | ||
|
|
625c9dae5c | ||
|
|
b2341c2d9a | ||
|
|
dadddcaf57 | ||
|
|
f7b3371b02 | ||
|
|
409f8f678e | ||
|
|
dc62f9393a | ||
|
|
32e0b612d9 | ||
|
|
7d6ce6fc5e | ||
|
|
ba105246ea | ||
|
|
a10cc63403 | ||
|
|
cde06e90d0 | ||
|
|
d4e7a88bba | ||
|
|
630476afc0 | ||
|
|
5181e4e90a | ||
|
|
5db78f20e9 | ||
|
|
5fc4472921 | ||
|
|
3049ac576a | ||
|
|
494e03490e | ||
|
|
675eba6588 | ||
|
|
01eadf3ded | ||
|
|
c7a2c737e8 | ||
|
|
bb09df0c77 | ||
|
|
ecbcbfbe90 | ||
|
|
485aadcbfa | ||
|
|
d76d6db589 | ||
|
|
aa612b27d4 | ||
|
|
a35c278424 | ||
|
|
8265621d48 | ||
|
|
1016a52cf1 | ||
|
|
d0a1e6fa46 | ||
|
|
f0e559c0ed | ||
|
|
528c6c1a75 | ||
|
|
6092f8792e | ||
|
|
4142e1bcf6 | ||
|
|
49d837e0c1 | ||
|
|
b88bcd49fd | ||
|
|
3f463bc916 | ||
|
|
0b02f6d22f | ||
|
|
4ecb305820 | ||
|
|
e5a868157e | ||
|
|
f510d17bd3 | ||
|
|
45fea6587e | ||
|
|
a721810682 | ||
|
|
d486c1c7c8 | ||
|
|
9197a2a7a1 | ||
|
|
8da890649f | ||
|
|
21053732e7 | ||
|
|
cf069dd046 | ||
|
|
4dc3cb9115 | ||
|
|
5c66c8b8e1 | ||
|
|
2ca0ae7755 | ||
|
|
19123b6803 | ||
|
|
4137c66581 | ||
|
|
48d14d4cac | ||
|
|
8996185f3b | ||
|
|
4f49967518 | ||
|
|
ec637aa21e | ||
|
|
2ff9a757b6 | ||
|
|
cb8533ef5b | ||
|
|
cdbb009ab0 | ||
|
|
001b486356 | ||
|
|
d315026abc | ||
|
|
d1191675c6 | ||
|
|
362a657b4f | ||
|
|
0276885181 | ||
|
|
5a38a6f248 | ||
|
|
aef01003e7 | ||
|
|
a38e1701ee | ||
|
|
bf9ee32d4a | ||
|
|
0917991361 | ||
|
|
c6a241e331 | ||
|
|
4b7301e8ca | ||
|
|
1bf20f0a2b | ||
|
|
e3b4d4ad49 | ||
|
|
6b207b09d6 | ||
|
|
9771325026 | ||
|
|
bbd1c071c4 | ||
|
|
8e9a0c4ad0 | ||
|
|
ced093e646 | ||
|
|
283bdce358 | ||
|
|
91d5ce8bf3 | ||
|
|
7f870cc9d4 | ||
|
|
2cb3b0484b | ||
|
|
11b0df6b86 | ||
|
|
e15af828fa | ||
|
|
265cbaea7c | ||
|
|
d39ebbc947 | ||
|
|
06acd70670 | ||
|
|
c285304acf |
@@ -44,6 +44,8 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "Check for duplicate PRs related to this new PR:"
|
echo "Check for duplicate PRs related to this new PR:"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "CURRENT_PR_NUMBER: $PR_NUMBER"
|
||||||
|
echo ""
|
||||||
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
|
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Description:"
|
echo "Description:"
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
name: PR Standards
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, edited, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-standards:
|
||||||
|
if: |
|
||||||
|
github.event.pull_request.user.login != 'actions-user' &&
|
||||||
|
github.event.pull_request.user.login != 'opencode' &&
|
||||||
|
github.event.pull_request.user.login != 'rekram1-node' &&
|
||||||
|
github.event.pull_request.user.login != 'thdxr' &&
|
||||||
|
github.event.pull_request.user.login != 'kommander' &&
|
||||||
|
github.event.pull_request.user.login != 'jayair' &&
|
||||||
|
github.event.pull_request.user.login != 'fwang' &&
|
||||||
|
github.event.pull_request.user.login != 'adamdotdevin' &&
|
||||||
|
github.event.pull_request.user.login != 'iamdavidhill' &&
|
||||||
|
github.event.pull_request.user.login != 'opencode-agent[bot]'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Check PR standards
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const pr = context.payload.pull_request;
|
||||||
|
const title = pr.title;
|
||||||
|
|
||||||
|
async function addLabel(label) {
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
labels: [label]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLabel(label) {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
name: label
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Label wasn't present, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function comment(marker, body) {
|
||||||
|
const markerText = `<!-- pr-standards:${marker} -->`;
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = comments.find(c => c.body.includes(markerText));
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
body: markerText + '\n' + body
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Check title format
|
||||||
|
// Matches: feat:, feat(scope):, feat (scope):, etc.
|
||||||
|
const titlePattern = /^(feat|fix|docs|chore|refactor|test)\s*(\([a-zA-Z0-9-]+\))?\s*:/;
|
||||||
|
const hasValidTitle = titlePattern.test(title);
|
||||||
|
|
||||||
|
if (!hasValidTitle) {
|
||||||
|
await addLabel('needs:title');
|
||||||
|
await comment('title', `Hey! Your PR title \`${title}\` doesn't follow conventional commit format.
|
||||||
|
|
||||||
|
Please update it to start with one of:
|
||||||
|
- \`feat:\` or \`feat(scope):\` new feature
|
||||||
|
- \`fix:\` or \`fix(scope):\` bug fix
|
||||||
|
- \`docs:\` or \`docs(scope):\` documentation changes
|
||||||
|
- \`chore:\` or \`chore(scope):\` maintenance tasks
|
||||||
|
- \`refactor:\` or \`refactor(scope):\` code refactoring
|
||||||
|
- \`test:\` or \`test(scope):\` adding or updating tests
|
||||||
|
|
||||||
|
Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`).
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeLabel('needs:title');
|
||||||
|
|
||||||
|
// Step 2: Check for linked issue (skip for docs/refactor PRs)
|
||||||
|
const skipIssueCheck = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
||||||
|
if (skipIssueCheck) {
|
||||||
|
await removeLabel('needs:issue');
|
||||||
|
console.log('Skipping issue check for docs/refactor PR');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const query = `
|
||||||
|
query($owner: String!, $repo: String!, $number: Int!) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
pullRequest(number: $number) {
|
||||||
|
closingIssuesReferences(first: 1) {
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await github.graphql(query, {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
number: pr.number
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
|
||||||
|
|
||||||
|
if (linkedIssues === 0) {
|
||||||
|
await addLabel('needs:issue');
|
||||||
|
await comment('issue', `Thanks for your contribution!
|
||||||
|
|
||||||
|
This PR doesn't have a linked issue. All PRs must reference an existing issue.
|
||||||
|
|
||||||
|
Please:
|
||||||
|
1. Open an issue describing the bug/feature (if one doesn't exist)
|
||||||
|
2. Add \`Fixes #<number>\` or \`Closes #<number>\` to this PR description
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeLabel('needs:issue');
|
||||||
|
console.log('PR meets all standards');
|
||||||
@@ -177,8 +177,22 @@ jobs:
|
|||||||
cargo tauri --version
|
cargo tauri --version
|
||||||
|
|
||||||
- name: Build and upload artifacts
|
- name: Build and upload artifacts
|
||||||
timeout-minutes: 20
|
uses: Wandalen/wretry.action@v3
|
||||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
timeout-minutes: 60
|
||||||
|
with:
|
||||||
|
attempt_limit: 3
|
||||||
|
attempt_delay: 10000
|
||||||
|
action: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||||
|
with: |
|
||||||
|
projectPath: packages/desktop
|
||||||
|
uploadWorkflowArtifacts: true
|
||||||
|
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
||||||
|
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
|
||||||
|
updaterJsonPreferNsis: true
|
||||||
|
releaseId: ${{ needs.publish.outputs.release }}
|
||||||
|
tagName: ${{ needs.publish.outputs.tag }}
|
||||||
|
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
||||||
|
releaseDraft: true
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
|
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
|
||||||
@@ -190,16 +204,6 @@ jobs:
|
|||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||||
with:
|
|
||||||
projectPath: packages/desktop
|
|
||||||
uploadWorkflowArtifacts: true
|
|
||||||
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
|
||||||
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
|
|
||||||
updaterJsonPreferNsis: true
|
|
||||||
releaseId: ${{ needs.publish.outputs.release }}
|
|
||||||
tagName: ${{ needs.publish.outputs.tag }}
|
|
||||||
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
|
||||||
releaseDraft: true
|
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
needs:
|
needs:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stats:
|
stats:
|
||||||
if: github.repository == 'sst/opencode'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ You are a duplicate PR detection agent. When a PR is opened, your job is to sear
|
|||||||
|
|
||||||
Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature.
|
Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature.
|
||||||
|
|
||||||
|
IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself.
|
||||||
|
|
||||||
Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
|
Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
|
||||||
|
|
||||||
If you find potential duplicates:
|
If you find potential duplicates:
|
||||||
@@ -19,6 +21,6 @@ If you find potential duplicates:
|
|||||||
- List them with their titles and URLs
|
- List them with their titles and URLs
|
||||||
- Briefly explain why they might be related
|
- Briefly explain why they might be related
|
||||||
|
|
||||||
If no duplicates are found, say so clearly.
|
If no duplicates are found, say so clearly. BUT ONLY SAY "No duplicate PRs found" (don't say anything else if no dups)
|
||||||
|
|
||||||
Keep your response concise and actionable.
|
Keep your response concise and actionable.
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ export default tool({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prs = result.items as PR[]
|
const prs = result.items as PR[]
|
||||||
|
|
||||||
|
if (prs.length === 0) {
|
||||||
|
return `No other PRs found matching "${args.query}"`
|
||||||
|
}
|
||||||
|
|
||||||
const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n")
|
const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n")
|
||||||
|
|
||||||
return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
|
return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
|
||||||
|
|||||||
+75
-5
@@ -83,12 +83,30 @@ This starts a local dev server at http://localhost:5173 (or similar port shown i
|
|||||||
|
|
||||||
### Running the Desktop App
|
### Running the Desktop App
|
||||||
|
|
||||||
The desktop app is a native Tauri application that wraps the web UI. To run it:
|
The desktop app is a native Tauri application that wraps the web UI.
|
||||||
|
|
||||||
|
To run the native desktop app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd packages/desktop tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the web dev server on http://localhost:1420 and opens the native window.
|
||||||
|
|
||||||
|
If you only want the web dev server (no native shell):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run --cwd packages/desktop dev
|
bun run --cwd packages/desktop dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To create a production `dist/` and build the native app bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd packages/desktop tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `bun run --cwd packages/desktop build` automatically via Tauri’s `beforeBuildCommand`.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
|
> Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
|
||||||
|
|
||||||
@@ -131,11 +149,63 @@ With that said, you may want to try these methods, as they might work for you.
|
|||||||
|
|
||||||
## Pull Request Expectations
|
## Pull Request Expectations
|
||||||
|
|
||||||
- Try to keep pull requests small and focused.
|
### Issue First Policy
|
||||||
- Link relevant issue(s) in the description
|
|
||||||
|
**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review.
|
||||||
|
|
||||||
|
- Use `Fixes #123` or `Closes #123` in your PR description to link the issue
|
||||||
|
- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem
|
||||||
|
|
||||||
|
### General Requirements
|
||||||
|
|
||||||
|
- Keep pull requests small and focused
|
||||||
- Explain the issue and why your change fixes it
|
- Explain the issue and why your change fixes it
|
||||||
- Avoid having verbose LLM generated PR descriptions
|
- Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase
|
||||||
- Before adding new functions or functionality, ensure that such behavior doesn't already exist elsewhere in the codebase.
|
|
||||||
|
### UI Changes
|
||||||
|
|
||||||
|
If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback.
|
||||||
|
|
||||||
|
### Logic Changes
|
||||||
|
|
||||||
|
For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**:
|
||||||
|
|
||||||
|
- What did you test?
|
||||||
|
- How can a reviewer reproduce/confirm the fix?
|
||||||
|
|
||||||
|
### No AI-Generated Walls of Text
|
||||||
|
|
||||||
|
Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time:
|
||||||
|
|
||||||
|
- Write short, focused descriptions
|
||||||
|
- Explain what changed and why in your own words
|
||||||
|
- If you can't explain it briefly, your PR might be too large
|
||||||
|
|
||||||
|
### PR Titles
|
||||||
|
|
||||||
|
PR titles should follow conventional commit standards:
|
||||||
|
|
||||||
|
- `feat:` new feature or functionality
|
||||||
|
- `fix:` bug fix
|
||||||
|
- `docs:` documentation or README changes
|
||||||
|
- `chore:` maintenance tasks, dependency updates, etc.
|
||||||
|
- `refactor:` code refactoring without changing behavior
|
||||||
|
- `test:` adding or updating tests
|
||||||
|
|
||||||
|
You can optionally include a scope to indicate which package is affected:
|
||||||
|
|
||||||
|
- `feat(app):` feature in the app package
|
||||||
|
- `fix(desktop):` bug fix in the desktop package
|
||||||
|
- `chore(opencode):` maintenance in the opencode package
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `docs: update contributing guidelines`
|
||||||
|
- `fix: resolve crash on startup`
|
||||||
|
- `feat: add dark mode support`
|
||||||
|
- `feat(app): add dark mode support`
|
||||||
|
- `fix(desktop): resolve crash on startup`
|
||||||
|
- `chore: bump dependency versions`
|
||||||
|
|
||||||
### Style Preferences
|
### Style Preferences
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ curl -fsSL https://opencode.ai/install | bash
|
|||||||
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
||||||
scoop bucket add extras; scoop install extras/opencode # Windows
|
scoop bucket add extras; scoop install extras/opencode # Windows
|
||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install opencode # macOS and Linux
|
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
||||||
|
brew install opencode # macOS and Linux (official brew formula, updated less frequently)
|
||||||
paru -S opencode-bin # Arch Linux
|
paru -S opencode-bin # Arch Linux
|
||||||
mise use -g opencode # Any OS
|
mise use -g opencode # Any OS
|
||||||
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
||||||
@@ -70,8 +71,7 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
|||||||
|
|
||||||
### Agents
|
### Agents
|
||||||
|
|
||||||
OpenCode includes two built-in agents you can switch between,
|
OpenCode includes two built-in agents you can switch between with the `Tab` key.
|
||||||
you can switch between these using the `Tab` key.
|
|
||||||
|
|
||||||
- **build** - Default, full access agent for development work
|
- **build** - Default, full access agent for development work
|
||||||
- **plan** - Read-only agent for analysis and code exploration
|
- **plan** - Read-only agent for analysis and code exploration
|
||||||
@@ -108,10 +108,6 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
|
|||||||
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||||
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
||||||
|
|
||||||
#### What's the other repo?
|
|
||||||
|
|
||||||
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
**Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai">
|
||||||
|
<picture>
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||||
|
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">开源的 AI Coding Agent。</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||||
|
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||||
|
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://opencode.ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 直接安装 (YOLO)
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
# 软件包管理器
|
||||||
|
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
|
||||||
|
scoop bucket add extras; scoop install extras/opencode # Windows
|
||||||
|
choco install opencode # Windows
|
||||||
|
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
|
||||||
|
brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低)
|
||||||
|
paru -S opencode-bin # Arch Linux
|
||||||
|
mise use -g opencode # 任意系统
|
||||||
|
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 安装前请先移除 0.1.x 之前的旧版本。
|
||||||
|
|
||||||
|
### 桌面应用程序 (BETA)
|
||||||
|
|
||||||
|
OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下载。
|
||||||
|
|
||||||
|
| 平台 | 下载文件 |
|
||||||
|
| --------------------- | ------------------------------------- |
|
||||||
|
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||||
|
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||||
|
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||||
|
| Linux | `.deb`、`.rpm` 或 AppImage |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew Cask)
|
||||||
|
brew install --cask opencode-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 安装目录
|
||||||
|
|
||||||
|
安装脚本按照以下优先级决定安装路径:
|
||||||
|
|
||||||
|
1. `$OPENCODE_INSTALL_DIR` - 自定义安装目录
|
||||||
|
2. `$XDG_BIN_DIR` - 符合 XDG 基础目录规范的路径
|
||||||
|
3. `$HOME/bin` - 如果存在或可创建的用户二进制目录
|
||||||
|
4. `$HOME/.opencode/bin` - 默认备用路径
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 示例
|
||||||
|
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agents
|
||||||
|
|
||||||
|
OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||||
|
|
||||||
|
- **build** - 默认模式,具备完整权限,适合开发工作
|
||||||
|
- **plan** - 只读模式,适合代码分析与探索
|
||||||
|
- 默认拒绝修改文件
|
||||||
|
- 运行 bash 命令前会询问
|
||||||
|
- 便于探索未知代码库或规划改动
|
||||||
|
|
||||||
|
另外还包含一个 **general** 子 Agent,用于复杂搜索和多步任务,内部使用,也可在消息中输入 `@general` 调用。
|
||||||
|
|
||||||
|
了解更多 [Agents](https://opencode.ai/docs/agents) 相关信息。
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
更多配置说明请查看我们的 [**官方文档**](https://opencode.ai/docs)。
|
||||||
|
|
||||||
|
### 参与贡献
|
||||||
|
|
||||||
|
如有兴趣贡献代码,请在提交 PR 前阅读 [贡献指南 (Contributing Docs)](./CONTRIBUTING.md)。
|
||||||
|
|
||||||
|
### 基于 OpenCode 进行开发
|
||||||
|
|
||||||
|
如果你在项目名中使用了 “opencode”(如 “opencode-dashboard” 或 “opencode-mobile”),请在 README 里注明该项目不是 OpenCode 团队官方开发,且不存在隶属关系。
|
||||||
|
|
||||||
|
### 常见问题 (FAQ)
|
||||||
|
|
||||||
|
#### 这和 Claude Code 有什么不同?
|
||||||
|
|
||||||
|
功能上很相似,关键差异:
|
||||||
|
|
||||||
|
- 100% 开源。
|
||||||
|
- 不绑定特定提供商。推荐使用 [OpenCode Zen](https://opencode.ai/zen) 的模型,但也可搭配 Claude、OpenAI、Google 甚至本地模型。模型迭代会缩小差异、降低成本,因此保持 provider-agnostic 很重要。
|
||||||
|
- 内置 LSP 支持。
|
||||||
|
- 聚焦终端界面 (TUI)。OpenCode 由 Neovim 爱好者和 [terminal.shop](https://terminal.shop) 的创建者打造,会持续探索终端的极限。
|
||||||
|
- 客户端/服务器架构。可在本机运行,同时用移动设备远程驱动。TUI 只是众多潜在客户端之一。
|
||||||
|
|
||||||
|
#### 另一个同名的仓库是什么?
|
||||||
|
|
||||||
|
另一个名字相近的仓库与本项目无关。[点击这里了解背后故事](https://x.com/thdxr/status/1933561254481666466)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||||
+2
-1
@@ -28,7 +28,8 @@ curl -fsSL https://opencode.ai/install | bash
|
|||||||
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
|
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
|
||||||
scoop bucket add extras; scoop install extras/opencode # Windows
|
scoop bucket add extras; scoop install extras/opencode # Windows
|
||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install opencode # macOS 與 Linux
|
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
|
||||||
|
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
|
||||||
paru -S opencode-bin # Arch Linux
|
paru -S opencode-bin # Arch Linux
|
||||||
mise use -g github:anomalyco/opencode # 任何作業系統
|
mise use -g github:anomalyco/opencode # 任何作業系統
|
||||||
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
|
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
|
||||||
|
|||||||
@@ -1,191 +1,197 @@
|
|||||||
# Download Stats
|
# Download Stats
|
||||||
|
|
||||||
| Date | GitHub Downloads | npm Downloads | Total |
|
| Date | GitHub Downloads | npm Downloads | Total |
|
||||||
| ---------- | ------------------- | ------------------- | ------------------- |
|
| ---------- | -------------------- | ------------------- | -------------------- |
|
||||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||||
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
|
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
|
||||||
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
|
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
|
||||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||||
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
||||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||||
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
||||||
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
||||||
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
||||||
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
|
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
|
||||||
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
|
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
|
||||||
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
|
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
|
||||||
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
|
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
|
||||||
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
|
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
|
||||||
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
|
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
|
||||||
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
|
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
|
||||||
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
|
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
|
||||||
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
|
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
|
||||||
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
|
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
|
||||||
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
|
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
|
||||||
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
|
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
|
||||||
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
|
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
|
||||||
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
|
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
|
||||||
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
|
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
|
||||||
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
|
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
|
||||||
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
|
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
|
||||||
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
|
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
|
||||||
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
|
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
|
||||||
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
|
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
|
||||||
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
|
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
|
||||||
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
|
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
|
||||||
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
|
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
|
||||||
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
|
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
|
||||||
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
|
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
|
||||||
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
|
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
|
||||||
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
|
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
|
||||||
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
|
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
|
||||||
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
|
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
|
||||||
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
|
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
|
||||||
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
|
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
|
||||||
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
|
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
|
||||||
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
|
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
|
||||||
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
|
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
|
||||||
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
|
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
|
||||||
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
|
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
|
||||||
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
|
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
|
||||||
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
|
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
|
||||||
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
|
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
|
||||||
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
|
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
|
||||||
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
|
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
|
||||||
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
|
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
|
||||||
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
|
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
|
||||||
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
|
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
|
||||||
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
|
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
|
||||||
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
|
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
|
||||||
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
|
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
|
||||||
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
|
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
|
||||||
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
|
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
|
||||||
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
|
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
|
||||||
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
|
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
|
||||||
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
|
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
|
||||||
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
|
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
|
||||||
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
|
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
|
||||||
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
|
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
|
||||||
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
|
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
|
||||||
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
|
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
|
||||||
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
|
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
|
||||||
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
|
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
|
||||||
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
|
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
|
||||||
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
|
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
|
||||||
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
|
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
|
||||||
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
|
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
|
||||||
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
|
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
|
||||||
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
|
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
|
||||||
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
|
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
|
||||||
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
|
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
|
||||||
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
|
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
|
||||||
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
|
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
|
||||||
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
|
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
|
||||||
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
|
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
|
||||||
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
|
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
|
||||||
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
|
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
|
||||||
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
|
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
|
||||||
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
|
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
|
||||||
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
|
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
|
||||||
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
|
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
|
||||||
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
|
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
|
||||||
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
|
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
|
||||||
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
|
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
|
||||||
| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) |
|
| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) |
|
||||||
| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) |
|
| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) |
|
||||||
| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) |
|
| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) |
|
||||||
| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) |
|
| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) |
|
||||||
| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) |
|
| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) |
|
||||||
| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) |
|
| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) |
|
||||||
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
|
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
|
||||||
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
|
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
|
||||||
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
|
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
|
||||||
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
|
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
|
||||||
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
|
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
|
||||||
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
|
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
|
||||||
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
|
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
|
||||||
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
|
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
|
||||||
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
|
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
|
||||||
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
|
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
|
||||||
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
|
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
|
||||||
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
|
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
|
||||||
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
|
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
|
||||||
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
|
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
|
||||||
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
|
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
|
||||||
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
|
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
|
||||||
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
|
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
|
||||||
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
|
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
|
||||||
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
|
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
|
||||||
| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) |
|
| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) |
|
||||||
| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) |
|
| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) |
|
||||||
| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) |
|
| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) |
|
||||||
| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) |
|
| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) |
|
||||||
| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) |
|
| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) |
|
||||||
| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) |
|
| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) |
|
||||||
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
|
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
|
||||||
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
|
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
|
||||||
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
|
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
|
||||||
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
|
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
|
||||||
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
|
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
|
||||||
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
|
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
|
||||||
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
|
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
|
||||||
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
|
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
|
||||||
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
|
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
|
||||||
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
|
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
|
||||||
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
|
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
|
||||||
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
|
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
|
||||||
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
|
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
|
||||||
| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) |
|
| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) |
|
||||||
| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) |
|
| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) |
|
||||||
| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) |
|
| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) |
|
||||||
| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) |
|
| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) |
|
||||||
| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) |
|
| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) |
|
||||||
| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) |
|
| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) |
|
||||||
| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) |
|
| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) |
|
||||||
| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) |
|
| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) |
|
||||||
| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) |
|
| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) |
|
||||||
| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) |
|
| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) |
|
||||||
| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) |
|
| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) |
|
||||||
| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) |
|
| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) |
|
||||||
| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) |
|
| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) |
|
||||||
| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) |
|
| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) |
|
||||||
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
|
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
|
||||||
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
|
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
|
||||||
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
|
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
|
||||||
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
|
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
|
||||||
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
|
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
|
||||||
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
|
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
|
||||||
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
|
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
|
||||||
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
|
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
|
||||||
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
|
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
|
||||||
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
|
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
|
||||||
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
|
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
|
||||||
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
|
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
|
||||||
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
|
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
|
||||||
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
|
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
|
||||||
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
|
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
|
||||||
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
|
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
|
||||||
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
|
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
|
||||||
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
|
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
|
||||||
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
|
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
|
||||||
| 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) |
|
| 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-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) |
|
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
|
||||||
|
| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) |
|
||||||
|
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
|
||||||
|
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
|
||||||
|
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
|
||||||
|
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
|
||||||
|
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
|
||||||
|
|||||||
+2
-3
@@ -1,9 +1,8 @@
|
|||||||
## Style Guide
|
## Style Guide
|
||||||
|
|
||||||
- Try to keep things in one function unless composable or reusable
|
- Try to keep things in one function unless composable or reusable
|
||||||
- DO NOT do unnecessary destructuring of variables
|
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
|
||||||
- DO NOT use `else` statements unless necessary
|
= obj` just reference it as obj.a and obj.b. this preserves context
|
||||||
- DO NOT use `try`/`catch` if it can be avoided
|
|
||||||
- AVOID `try`/`catch` where possible
|
- AVOID `try`/`catch` where possible
|
||||||
- AVOID `else` statements
|
- AVOID `else` statements
|
||||||
- AVOID using `any` type
|
- AVOID using `any` type
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"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.1.2",
|
"version": "1.1.8",
|
||||||
"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.1.2",
|
"version": "1.1.8",
|
||||||
"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.1.2",
|
"version": "1.1.8",
|
||||||
"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,9 +173,10 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
@@ -201,7 +202,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -230,7 +231,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -246,7 +247,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -276,7 +277,7 @@
|
|||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.15.1",
|
"@modelcontextprotocol/sdk": "1.25.2",
|
||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
@@ -285,8 +286,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||||
"@opentui/core": "0.1.68",
|
"@opentui/core": "0.1.70",
|
||||||
"@opentui/solid": "0.1.68",
|
"@opentui/solid": "0.1.70",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -349,7 +350,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -369,7 +370,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.88.1",
|
"@hey-api/openapi-ts": "0.88.1",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -380,7 +381,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -393,7 +394,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -401,6 +402,7 @@
|
|||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/bounds": "0.1.3",
|
"@solid-primitives/bounds": "0.1.3",
|
||||||
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
@@ -431,7 +433,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -442,7 +444,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -909,6 +911,8 @@
|
|||||||
|
|
||||||
"@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.88.1", "", { "dependencies": { "@hey-api/codegen-core": "^0.3.3", "@hey-api/json-schema-ref-parser": "1.2.2", "ansi-colors": "4.1.3", "c12": "3.3.2", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-x/nDTupOnV9VuSeNIiJpgIpc915GHduhyseJeMTnI0JMsXaObmpa0rgPr3ASVEYMLgpvqozIEG1RTOOnal6zLQ=="],
|
"@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.88.1", "", { "dependencies": { "@hey-api/codegen-core": "^0.3.3", "@hey-api/json-schema-ref-parser": "1.2.2", "ansi-colors": "4.1.3", "c12": "3.3.2", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-x/nDTupOnV9VuSeNIiJpgIpc915GHduhyseJeMTnI0JMsXaObmpa0rgPr3ASVEYMLgpvqozIEG1RTOOnal6zLQ=="],
|
||||||
|
|
||||||
|
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
|
||||||
|
|
||||||
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
|
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
|
||||||
|
|
||||||
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
|
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
|
||||||
@@ -1093,7 +1097,7 @@
|
|||||||
|
|
||||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.15.1", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
|
||||||
|
|
||||||
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
|
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
|
||||||
|
|
||||||
@@ -1197,21 +1201,21 @@
|
|||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.1.68", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.68", "@opentui/core-darwin-x64": "0.1.68", "@opentui/core-linux-arm64": "0.1.68", "@opentui/core-linux-x64": "0.1.68", "@opentui/core-win32-arm64": "0.1.68", "@opentui/core-win32-x64": "0.1.68", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-SZz5qNO+2lJ8jDEoTSieyXH23t49myu6NetLex+xzqOf67XsU6QKlDcw5oMmc3zrKvETXhgbBvlSnbyJNQoBMg=="],
|
"@opentui/core": ["@opentui/core@0.1.70", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.70", "@opentui/core-darwin-x64": "0.1.70", "@opentui/core-linux-arm64": "0.1.70", "@opentui/core-linux-x64": "0.1.70", "@opentui/core-win32-arm64": "0.1.70", "@opentui/core-win32-x64": "0.1.70", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6cPAlbCnaiUUtQtvZNpkr0Xv8AdVAgJuy2VAwIsDN1pIv0zMpa0ZG+mr7afCGygw1eeDRveefrjfgFAB1r0SVw=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.68", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ipPX2gavBLVtw3d8L4ZPJDLlEwIjIRNdlNlxu07rqSEGSfxD5s29yc+33wLAlYXbmnJDajOqm0Dx6HnlY1Y9Fg=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.70", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rM8EnvW1tOAXWnp2Iy2M82I+ViSmRwUagx3v1/ni6N8GCcw/3mE0C6eB3sVlYNXVMwBEgiKpWFn85RCe4+qXQw=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.68", "", { "os": "darwin", "cpu": "x64" }, "sha512-9dW0S9HINnuVjvC9QLj+S+329H7qEBQQtyJ9WHpykemokiJ5k4rnuDkfws5FxgTHIf/ddoYYTyPoGCS7WN5gsQ=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.70", "", { "os": "darwin", "cpu": "x64" }, "sha512-XdBgW+em8J+YGSUpaKF8/NxPjikJygK3dIkeMAw5xQ2lt7jXKxeM5MMmN/V4MfK3pLMtO56rLJlXaLH/h50uQA=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.68", "", { "os": "linux", "cpu": "arm64" }, "sha512-/el6TbSQriBUfPhIa6SBfCCc7tjU98Bnhf2+w0zKwQFBjf3F3kmnI42++YxedMGFmL7bRt3EUawGOkQRZZzFAg=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.70", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSVWNMSOx0Na0M0LCqtWCxeh4SuLSK5lg8ZwVzsEoimIAxh0snp9nRUo/Qi8yD9BP0DSDmXuM/B3ONtzFaf0dw=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.68", "", { "os": "linux", "cpu": "x64" }, "sha512-9NzVI3GZzmICoIu3YhWBdkEt0KvY27m++tu/MqW+xb6fnvN74jZkRWzlgjTdM70obL4eUGQdvU08sDHgZjsIJw=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.70", "", { "os": "linux", "cpu": "x64" }, "sha512-WUrhukefMghcZ7sAjkxEy50vA6ii0X21xh7m8c4omXyYYfQXyDs25pNExB8cwoCrZEaC8RTlF4lRSNPIXsZKhA=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.68", "", { "os": "win32", "cpu": "arm64" }, "sha512-wrAeotyotOplUjQVBSxOGA8GCr9FWXSd6xCEo1PEGo/NjuAOtvHmKoENzyFEP0GzFsjvoUOyy2dZb987oFAn9A=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.70", "", { "os": "win32", "cpu": "arm64" }, "sha512-p1K2VJXGmZqSV7mR61v7KJpT1Zth7DS99wEtaqqfK68OWt33K2XxLmGO0KD142R2JLfXu32NnRmBHxmVx8IjBA=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.68", "", { "os": "win32", "cpu": "x64" }, "sha512-w0yBjvzs/oMIwVdWICL4XlUrfsPoVXd4+RDqiuu+Xi/zD0UgANSTRY2asXca+gPe5zPHLsxvz1bAG0Z7uGtmyw=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.70", "", { "os": "win32", "cpu": "x64" }, "sha512-G6b8te1twMeDhjg1oZa0IcUjhOJZFCSdlQt+q5gu5vVtjCrIwAn9o7m5EwNMPakc31pDWUZ7v0ktgv0Xw1AQVA=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.1.68", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.68", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-S1oHvCQaY+gCQu2kiiksPIScP8i0FiDOlAlLjtfwcRlgeSjzT0wRwFkvoh4uVUPuAlyigox7vMCE3j04SYSGKg=="],
|
"@opentui/solid": ["@opentui/solid@0.1.70", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.70", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-8Cw/w4Of2OJhsFhcp/Wdj8cJRVaGvVsIiUoYiFtyToM01J4en0bg/vnbeZteyuZWeEtA4iz1/rSEQf7Dp+2FIQ=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
@@ -1901,7 +1905,9 @@
|
|||||||
|
|
||||||
"ai": ["ai@5.0.97", "", { "dependencies": { "@ai-sdk/gateway": "2.0.12", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8zBx0b/owis4eJI2tAlV8a1Rv0BANmLxontcAelkLNwEHhgfgXeKpDkhNB6OgV+BJSwboIUDkgd9312DdJnCOQ=="],
|
"ai": ["ai@5.0.97", "", { "dependencies": { "@ai-sdk/gateway": "2.0.12", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8zBx0b/owis4eJI2tAlV8a1Rv0BANmLxontcAelkLNwEHhgfgXeKpDkhNB6OgV+BJSwboIUDkgd9312DdJnCOQ=="],
|
||||||
|
|
||||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||||
|
|
||||||
|
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||||
|
|
||||||
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
||||||
|
|
||||||
@@ -2405,7 +2411,7 @@
|
|||||||
|
|
||||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||||
|
|
||||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||||
|
|
||||||
"fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="],
|
"fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="],
|
||||||
|
|
||||||
@@ -2781,7 +2787,9 @@
|
|||||||
|
|
||||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||||
|
|
||||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|
||||||
|
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||||
|
|
||||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
@@ -3383,6 +3391,8 @@
|
|||||||
|
|
||||||
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
|
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
|
||||||
|
|
||||||
|
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
|
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
|
||||||
|
|
||||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||||
@@ -3757,8 +3767,6 @@
|
|||||||
|
|
||||||
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
|
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
|
||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
|
||||||
|
|
||||||
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
|
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
|
||||||
|
|
||||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||||
@@ -4057,9 +4065,11 @@
|
|||||||
|
|
||||||
"@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
|
"@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
"@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||||
|
|
||||||
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="],
|
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="],
|
||||||
|
|
||||||
@@ -4403,8 +4413,6 @@
|
|||||||
|
|
||||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||||
|
|
||||||
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
|
||||||
|
|
||||||
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||||
|
|
||||||
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
|||||||
"checkout.session.completed",
|
"checkout.session.completed",
|
||||||
"checkout.session.expired",
|
"checkout.session.expired",
|
||||||
"charge.refunded",
|
"charge.refunded",
|
||||||
|
"invoice.payment_succeeded",
|
||||||
"customer.created",
|
"customer.created",
|
||||||
"customer.deleted",
|
"customer.deleted",
|
||||||
"customer.updated",
|
"customer.updated",
|
||||||
@@ -97,6 +98,19 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const zenProduct = new stripe.Product("ZenBlack", {
|
||||||
|
name: "OpenCode Black",
|
||||||
|
})
|
||||||
|
const zenPrice = new stripe.Price("ZenBlackPrice", {
|
||||||
|
product: zenProduct.id,
|
||||||
|
unitAmount: 20000,
|
||||||
|
currency: "usd",
|
||||||
|
recurring: {
|
||||||
|
interval: "month",
|
||||||
|
intervalCount: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const ZEN_MODELS = [
|
const ZEN_MODELS = [
|
||||||
new sst.Secret("ZEN_MODELS1"),
|
new sst.Secret("ZEN_MODELS1"),
|
||||||
new sst.Secret("ZEN_MODELS2"),
|
new sst.Secret("ZEN_MODELS2"),
|
||||||
@@ -106,6 +120,7 @@ const ZEN_MODELS = [
|
|||||||
new sst.Secret("ZEN_MODELS6"),
|
new sst.Secret("ZEN_MODELS6"),
|
||||||
new sst.Secret("ZEN_MODELS7"),
|
new sst.Secret("ZEN_MODELS7"),
|
||||||
]
|
]
|
||||||
|
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
|
||||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||||
properties: { value: auth.url.apply((url) => url!) },
|
properties: { value: auth.url.apply((url) => url!) },
|
||||||
@@ -147,6 +162,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
EMAILOCTOPUS_API_KEY,
|
EMAILOCTOPUS_API_KEY,
|
||||||
AWS_SES_ACCESS_KEY_ID,
|
AWS_SES_ACCESS_KEY_ID,
|
||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
|
ZEN_BLACK,
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
...($dev
|
...($dev
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -16,16 +16,19 @@ Usage: install.sh [options]
|
|||||||
Options:
|
Options:
|
||||||
-h, --help Display this help message
|
-h, --help Display this help message
|
||||||
-v, --version <version> Install a specific version (e.g., 1.0.180)
|
-v, --version <version> Install a specific version (e.g., 1.0.180)
|
||||||
|
-b, --binary <path> Install from a local binary instead of downloading
|
||||||
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
|
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
curl -fsSL https://opencode.ai/install | bash
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
|
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
|
||||||
|
./install --binary /path/to/opencode
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
requested_version=${VERSION:-}
|
requested_version=${VERSION:-}
|
||||||
no_modify_path=false
|
no_modify_path=false
|
||||||
|
binary_path=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -42,6 +45,15 @@ while [[ $# -gt 0 ]]; do
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
-b|--binary)
|
||||||
|
if [[ -n "${2:-}" ]]; then
|
||||||
|
binary_path="$2"
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: --binary requires a path argument${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
--no-modify-path)
|
--no-modify-path)
|
||||||
no_modify_path=true
|
no_modify_path=true
|
||||||
shift
|
shift
|
||||||
@@ -53,119 +65,128 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
raw_os=$(uname -s)
|
|
||||||
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
|
||||||
case "$raw_os" in
|
|
||||||
Darwin*) os="darwin" ;;
|
|
||||||
Linux*) os="linux" ;;
|
|
||||||
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
arch=$(uname -m)
|
|
||||||
if [[ "$arch" == "aarch64" ]]; then
|
|
||||||
arch="arm64"
|
|
||||||
fi
|
|
||||||
if [[ "$arch" == "x86_64" ]]; then
|
|
||||||
arch="x64"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
|
|
||||||
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
|
|
||||||
if [ "$rosetta_flag" = "1" ]; then
|
|
||||||
arch="arm64"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
combo="$os-$arch"
|
|
||||||
case "$combo" in
|
|
||||||
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
archive_ext=".zip"
|
|
||||||
if [ "$os" = "linux" ]; then
|
|
||||||
archive_ext=".tar.gz"
|
|
||||||
fi
|
|
||||||
|
|
||||||
is_musl=false
|
|
||||||
if [ "$os" = "linux" ]; then
|
|
||||||
if [ -f /etc/alpine-release ]; then
|
|
||||||
is_musl=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v ldd >/dev/null 2>&1; then
|
|
||||||
if ldd --version 2>&1 | grep -qi musl; then
|
|
||||||
is_musl=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
needs_baseline=false
|
|
||||||
if [ "$arch" = "x64" ]; then
|
|
||||||
if [ "$os" = "linux" ]; then
|
|
||||||
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
|
||||||
needs_baseline=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$os" = "darwin" ]; then
|
|
||||||
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
|
|
||||||
if [ "$avx2" != "1" ]; then
|
|
||||||
needs_baseline=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
target="$os-$arch"
|
|
||||||
if [ "$needs_baseline" = "true" ]; then
|
|
||||||
target="$target-baseline"
|
|
||||||
fi
|
|
||||||
if [ "$is_musl" = "true" ]; then
|
|
||||||
target="$target-musl"
|
|
||||||
fi
|
|
||||||
|
|
||||||
filename="$APP-$target$archive_ext"
|
|
||||||
|
|
||||||
|
|
||||||
if [ "$os" = "linux" ]; then
|
|
||||||
if ! command -v tar >/dev/null 2>&1; then
|
|
||||||
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if ! command -v unzip >/dev/null 2>&1; then
|
|
||||||
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
INSTALL_DIR=$HOME/.opencode/bin
|
INSTALL_DIR=$HOME/.opencode/bin
|
||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
if [ -z "$requested_version" ]; then
|
# If --binary is provided, skip all download/detection logic
|
||||||
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
|
if [ -n "$binary_path" ]; then
|
||||||
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
|
if [ ! -f "$binary_path" ]; then
|
||||||
|
echo -e "${RED}Error: Binary not found at ${binary_path}${NC}"
|
||||||
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
|
||||||
echo -e "${RED}Failed to fetch version information${NC}"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
specific_version="local"
|
||||||
else
|
else
|
||||||
# Strip leading 'v' if present
|
raw_os=$(uname -s)
|
||||||
requested_version="${requested_version#v}"
|
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
||||||
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
|
case "$raw_os" in
|
||||||
specific_version=$requested_version
|
Darwin*) os="darwin" ;;
|
||||||
|
Linux*) os="linux" ;;
|
||||||
# Verify the release exists before downloading
|
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
|
||||||
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
|
esac
|
||||||
if [ "$http_status" = "404" ]; then
|
|
||||||
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
|
arch=$(uname -m)
|
||||||
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
|
if [[ "$arch" == "aarch64" ]]; then
|
||||||
|
arch="arm64"
|
||||||
|
fi
|
||||||
|
if [[ "$arch" == "x86_64" ]]; then
|
||||||
|
arch="x64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
|
||||||
|
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
|
||||||
|
if [ "$rosetta_flag" = "1" ]; then
|
||||||
|
arch="arm64"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
combo="$os-$arch"
|
||||||
|
case "$combo" in
|
||||||
|
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
archive_ext=".zip"
|
||||||
|
if [ "$os" = "linux" ]; then
|
||||||
|
archive_ext=".tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
is_musl=false
|
||||||
|
if [ "$os" = "linux" ]; then
|
||||||
|
if [ -f /etc/alpine-release ]; then
|
||||||
|
is_musl=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v ldd >/dev/null 2>&1; then
|
||||||
|
if ldd --version 2>&1 | grep -qi musl; then
|
||||||
|
is_musl=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
needs_baseline=false
|
||||||
|
if [ "$arch" = "x64" ]; then
|
||||||
|
if [ "$os" = "linux" ]; then
|
||||||
|
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
||||||
|
needs_baseline=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$os" = "darwin" ]; then
|
||||||
|
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
|
||||||
|
if [ "$avx2" != "1" ]; then
|
||||||
|
needs_baseline=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
target="$os-$arch"
|
||||||
|
if [ "$needs_baseline" = "true" ]; then
|
||||||
|
target="$target-baseline"
|
||||||
|
fi
|
||||||
|
if [ "$is_musl" = "true" ]; then
|
||||||
|
target="$target-musl"
|
||||||
|
fi
|
||||||
|
|
||||||
|
filename="$APP-$target$archive_ext"
|
||||||
|
|
||||||
|
|
||||||
|
if [ "$os" = "linux" ]; then
|
||||||
|
if ! command -v tar >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! command -v unzip >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$requested_version" ]; then
|
||||||
|
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
|
||||||
|
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
|
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
||||||
|
echo -e "${RED}Failed to fetch version information${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Strip leading 'v' if present
|
||||||
|
requested_version="${requested_version#v}"
|
||||||
|
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
|
||||||
|
specific_version=$requested_version
|
||||||
|
|
||||||
|
# Verify the release exists before downloading
|
||||||
|
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
|
||||||
|
if [ "$http_status" = "404" ]; then
|
||||||
|
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
|
||||||
|
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -187,11 +208,8 @@ check_version() {
|
|||||||
if command -v opencode >/dev/null 2>&1; then
|
if command -v opencode >/dev/null 2>&1; then
|
||||||
opencode_path=$(which opencode)
|
opencode_path=$(which opencode)
|
||||||
|
|
||||||
|
## Check the installed version
|
||||||
## TODO: check if version is installed
|
installed_version=$(opencode --version 2>/dev/null || echo "")
|
||||||
# installed_version=$(opencode version)
|
|
||||||
installed_version="0.0.1"
|
|
||||||
installed_version=$(echo $installed_version | awk '{print $2}')
|
|
||||||
|
|
||||||
if [[ "$installed_version" != "$specific_version" ]]; then
|
if [[ "$installed_version" != "$specific_version" ]]; then
|
||||||
print_message info "${MUTED}Installed version: ${NC}$installed_version."
|
print_message info "${MUTED}Installed version: ${NC}$installed_version."
|
||||||
@@ -267,11 +285,11 @@ download_with_progress() {
|
|||||||
{
|
{
|
||||||
local length=0
|
local length=0
|
||||||
local bytes=0
|
local bytes=0
|
||||||
|
|
||||||
while IFS=" " read -r -a line; do
|
while IFS=" " read -r -a line; do
|
||||||
[ "${#line[@]}" -lt 2 ] && continue
|
[ "${#line[@]}" -lt 2 ] && continue
|
||||||
local tag="${line[0]} ${line[1]}"
|
local tag="${line[0]} ${line[1]}"
|
||||||
|
|
||||||
if [ "$tag" = "0000: content-length:" ]; then
|
if [ "$tag" = "0000: content-length:" ]; then
|
||||||
length="${line[2]}"
|
length="${line[2]}"
|
||||||
length=$(echo "$length" | tr -d '\r')
|
length=$(echo "$length" | tr -d '\r')
|
||||||
@@ -296,7 +314,7 @@ download_and_install() {
|
|||||||
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
|
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
|
||||||
local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
|
local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
|
||||||
mkdir -p "$tmp_dir"
|
mkdir -p "$tmp_dir"
|
||||||
|
|
||||||
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
|
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
|
||||||
# Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
|
# Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
|
||||||
curl -# -L -o "$tmp_dir/$filename" "$url"
|
curl -# -L -o "$tmp_dir/$filename" "$url"
|
||||||
@@ -307,14 +325,24 @@ download_and_install() {
|
|||||||
else
|
else
|
||||||
unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
|
unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mv "$tmp_dir/opencode" "$INSTALL_DIR"
|
mv "$tmp_dir/opencode" "$INSTALL_DIR"
|
||||||
chmod 755 "${INSTALL_DIR}/opencode"
|
chmod 755 "${INSTALL_DIR}/opencode"
|
||||||
rm -rf "$tmp_dir"
|
rm -rf "$tmp_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
check_version
|
install_from_binary() {
|
||||||
download_and_install
|
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}from: ${NC}$binary_path"
|
||||||
|
cp "$binary_path" "${INSTALL_DIR}/opencode"
|
||||||
|
chmod 755 "${INSTALL_DIR}/opencode"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "$binary_path" ]; then
|
||||||
|
install_from_binary
|
||||||
|
else
|
||||||
|
check_version
|
||||||
|
download_and_install
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
add_to_path() {
|
add_to_path() {
|
||||||
@@ -416,4 +444,3 @@ echo -e ""
|
|||||||
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
|
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
|
||||||
echo -e ""
|
echo -e ""
|
||||||
echo -e ""
|
echo -e ""
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": "sha256-OJ3C4RMzfbbG1Fwa/5yru0rlISj+28UPITMNBEU5AeM="
|
"nodeModules": "sha256-KjBAaI9Kv6huOmPvUbtyYsMhbScI91w1lOZyXpIWqI0="
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ const result = await Bun.build({
|
|||||||
compile: {
|
compile: {
|
||||||
target,
|
target,
|
||||||
outfile: "opencode",
|
outfile: "opencode",
|
||||||
execArgv: ["--user-agent=opencode/" + version, '--env-file=""', "--"],
|
autoloadBunfig: false,
|
||||||
|
autoloadDotenv: false,
|
||||||
|
//@ts-ignore (bun types aren't up to date)
|
||||||
|
autoloadTsconfig: true,
|
||||||
|
autoloadPackageJson: true,
|
||||||
|
execArgv: ["--user-agent=opencode/" + version, "--use-system-ca", "--"],
|
||||||
windows: {},
|
windows: {},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
|
Dependencies for these templates are managed with [pnpm](https://pnpm.io) using `pnpm up -Lri`.
|
||||||
|
|
||||||
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
|
This is the reason you see a `pnpm-lock.yaml`. That said, any package manager will work. This file can safely be removed once you clone a template.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ npm install # or pnpm install or yarn install
|
$ npm install # or pnpm install or yarn install
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -242,6 +242,53 @@ describe("SerializeAddon", () => {
|
|||||||
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
|
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("serialized output should restore after Terminal.reset()", async () => {
|
||||||
|
const { term, addon } = createTerminal()
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
"\x1b[1;32m❯\x1b[0m \x1b[34mcd\x1b[0m /some/path",
|
||||||
|
"\x1b[1;32m❯\x1b[0m \x1b[34mls\x1b[0m -la",
|
||||||
|
"total 42",
|
||||||
|
].join("\r\n")
|
||||||
|
|
||||||
|
await writeAndWait(term, content)
|
||||||
|
|
||||||
|
const serialized = addon.serialize()
|
||||||
|
|
||||||
|
const { term: term2 } = createTerminal()
|
||||||
|
terminals.push(term2)
|
||||||
|
term2.reset()
|
||||||
|
await writeAndWait(term2, serialized)
|
||||||
|
|
||||||
|
expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path")
|
||||||
|
expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la")
|
||||||
|
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("alternate buffer should round-trip without garbage", async () => {
|
||||||
|
const { term, addon } = createTerminal(20, 5)
|
||||||
|
|
||||||
|
await writeAndWait(term, "normal\r\n")
|
||||||
|
await writeAndWait(term, "\x1b[?1049h\x1b[HALT")
|
||||||
|
|
||||||
|
expect(term.buffer.active.type).toBe("alternate")
|
||||||
|
|
||||||
|
const serialized = addon.serialize()
|
||||||
|
|
||||||
|
const { term: term2 } = createTerminal(20, 5)
|
||||||
|
terminals.push(term2)
|
||||||
|
await writeAndWait(term2, serialized)
|
||||||
|
|
||||||
|
expect(term2.buffer.active.type).toBe("alternate")
|
||||||
|
|
||||||
|
const line = term2.buffer.active.getLine(0)
|
||||||
|
expect(line?.translateToString(true)).toBe("ALT")
|
||||||
|
|
||||||
|
// Ensure a cell beyond content isn't garbage
|
||||||
|
const cellCode = line?.getCell(10)?.getCode()
|
||||||
|
expect(cellCode === 0 || cellCode === 32).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("serialized output written to new terminal should match original colors", async () => {
|
test("serialized output written to new terminal should match original colors", async () => {
|
||||||
const { term, addon } = createTerminal(40, 5)
|
const { term, addon } = createTerminal(40, 5)
|
||||||
|
|
||||||
|
|||||||
@@ -157,23 +157,6 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
|
|||||||
abstract class BaseSerializeHandler {
|
abstract class BaseSerializeHandler {
|
||||||
constructor(protected readonly _buffer: IBuffer) {}
|
constructor(protected readonly _buffer: IBuffer) {}
|
||||||
|
|
||||||
private _isRealContent(codepoint: number): boolean {
|
|
||||||
if (codepoint === 0) return false
|
|
||||||
if (codepoint >= 0xf000) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private _findLastContentColumn(line: IBufferLine): number {
|
|
||||||
let lastContent = -1
|
|
||||||
for (let col = 0; col < line.length; col++) {
|
|
||||||
const cell = line.getCell(col)
|
|
||||||
if (cell && this._isRealContent(cell.getCode())) {
|
|
||||||
lastContent = col
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lastContent + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
|
public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
|
||||||
let oldCell = this._buffer.getNullCell()
|
let oldCell = this._buffer.getNullCell()
|
||||||
|
|
||||||
@@ -182,14 +165,14 @@ abstract class BaseSerializeHandler {
|
|||||||
const startColumn = range.start.x
|
const startColumn = range.start.x
|
||||||
const endColumn = range.end.x
|
const endColumn = range.end.x
|
||||||
|
|
||||||
this._beforeSerialize(endRow - startRow, startRow, endRow)
|
this._beforeSerialize(endRow - startRow + 1, startRow, endRow)
|
||||||
|
|
||||||
for (let row = startRow; row <= endRow; row++) {
|
for (let row = startRow; row <= endRow; row++) {
|
||||||
const line = this._buffer.getLine(row)
|
const line = this._buffer.getLine(row)
|
||||||
if (line) {
|
if (line) {
|
||||||
const startLineColumn = row === range.start.y ? startColumn : 0
|
const startLineColumn = row === range.start.y ? startColumn : 0
|
||||||
const maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line)
|
const endLineColumn = Math.min(endColumn, line.length)
|
||||||
const endLineColumn = Math.min(maxColumn, line.length)
|
|
||||||
for (let col = startLineColumn; col < endLineColumn; col++) {
|
for (let col = startLineColumn; col < endLineColumn; col++) {
|
||||||
const c = line.getCell(col)
|
const c = line.getCell(col)
|
||||||
if (!c) {
|
if (!c) {
|
||||||
@@ -243,6 +226,13 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
|
|
||||||
protected _beforeSerialize(rows: number, start: number, _end: number): void {
|
protected _beforeSerialize(rows: number, start: number, _end: number): void {
|
||||||
this._allRows = new Array<string>(rows)
|
this._allRows = new Array<string>(rows)
|
||||||
|
this._allRowSeparators = new Array<string>(rows)
|
||||||
|
this._rowIndex = 0
|
||||||
|
|
||||||
|
this._currentRow = ""
|
||||||
|
this._nullCellCount = 0
|
||||||
|
this._cursorStyle = this._buffer.getNullCell()
|
||||||
|
|
||||||
this._lastContentCursorRow = start
|
this._lastContentCursorRow = start
|
||||||
this._lastCursorRow = start
|
this._lastCursorRow = start
|
||||||
this._firstRow = start
|
this._firstRow = start
|
||||||
@@ -251,6 +241,11 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
protected _rowEnd(row: number, isLastRow: boolean): void {
|
protected _rowEnd(row: number, isLastRow: boolean): void {
|
||||||
let rowSeparator = ""
|
let rowSeparator = ""
|
||||||
|
|
||||||
|
if (this._nullCellCount > 0) {
|
||||||
|
this._currentRow += " ".repeat(this._nullCellCount)
|
||||||
|
this._nullCellCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
if (!isLastRow) {
|
if (!isLastRow) {
|
||||||
const nextLine = this._buffer.getLine(row + 1)
|
const nextLine = this._buffer.getLine(row + 1)
|
||||||
|
|
||||||
@@ -388,7 +383,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const codepoint = cell.getCode()
|
const codepoint = cell.getCode()
|
||||||
const isGarbage = codepoint >= 0xf000
|
const isInvalidCodepoint = codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)
|
||||||
|
const isGarbage = isInvalidCodepoint || (codepoint >= 0xf000 && cell.getWidth() === 1)
|
||||||
const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
|
const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
|
||||||
|
|
||||||
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
|
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
|
||||||
@@ -397,7 +393,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
|
|
||||||
if (styleChanged) {
|
if (styleChanged) {
|
||||||
if (this._nullCellCount > 0) {
|
if (this._nullCellCount > 0) {
|
||||||
this._currentRow += `\u001b[${this._nullCellCount}C`
|
this._currentRow += " ".repeat(this._nullCellCount)
|
||||||
this._nullCellCount = 0
|
this._nullCellCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +413,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
this._nullCellCount += cell.getWidth()
|
this._nullCellCount += cell.getWidth()
|
||||||
} else {
|
} else {
|
||||||
if (this._nullCellCount > 0) {
|
if (this._nullCellCount > 0) {
|
||||||
this._currentRow += `\u001b[${this._nullCellCount}C`
|
this._currentRow += " ".repeat(this._nullCellCount)
|
||||||
this._nullCellCount = 0
|
this._nullCellCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+75
-60
@@ -1,5 +1,5 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { ErrorBoundary, Show, type ParentProps } from "solid-js"
|
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
|
||||||
import { Router, Route, Navigate } from "@solidjs/router"
|
import { Router, Route, Navigate } from "@solidjs/router"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
@@ -20,16 +20,20 @@ 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"
|
||||||
|
import { Logo } from "@opencode-ai/ui/logo"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import Home from "@/pages/home"
|
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import Session from "@/pages/session"
|
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
|
import { Suspense } from "solid-js"
|
||||||
|
|
||||||
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
|
const Loading = () => <div class="size-full flex items-center justify-center text-text-weak">Loading...</div>
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: { updaterEnabled?: boolean; port?: number }
|
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +49,25 @@ const defaultServerUrl = iife(() => {
|
|||||||
return window.location.origin
|
return window.location.origin
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export function AppBaseProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<MetaProvider>
|
||||||
|
<Font />
|
||||||
|
<ThemeProvider>
|
||||||
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
|
<DialogProvider>
|
||||||
|
<MarkedProvider>
|
||||||
|
<DiffComponentProvider component={Diff}>
|
||||||
|
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
|
||||||
|
</DiffComponentProvider>
|
||||||
|
</MarkedProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</ThemeProvider>
|
||||||
|
</MetaProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ServerKey(props: ParentProps) {
|
function ServerKey(props: ParentProps) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
return (
|
return (
|
||||||
@@ -54,62 +77,54 @@ function ServerKey(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function AppInterface() {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<ServerProvider defaultUrl={defaultServerUrl}>
|
||||||
<Font />
|
<ServerKey>
|
||||||
<ThemeProvider>
|
<GlobalSDKProvider>
|
||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<GlobalSyncProvider>
|
||||||
<DialogProvider>
|
<Router
|
||||||
<MarkedProvider>
|
root={(props) => (
|
||||||
<DiffComponentProvider component={Diff}>
|
<PermissionProvider>
|
||||||
<CodeComponentProvider component={Code}>
|
<LayoutProvider>
|
||||||
<ServerProvider defaultUrl={defaultServerUrl}>
|
<NotificationProvider>
|
||||||
<ServerKey>
|
<CommandProvider>
|
||||||
<GlobalSDKProvider>
|
<Layout>{props.children}</Layout>
|
||||||
<GlobalSyncProvider>
|
</CommandProvider>
|
||||||
<Router
|
</NotificationProvider>
|
||||||
root={(props) => (
|
</LayoutProvider>
|
||||||
<PermissionProvider>
|
</PermissionProvider>
|
||||||
<LayoutProvider>
|
)}
|
||||||
<NotificationProvider>
|
>
|
||||||
<CommandProvider>
|
<Route
|
||||||
<Layout>{props.children}</Layout>
|
path="/"
|
||||||
</CommandProvider>
|
component={() => (
|
||||||
</NotificationProvider>
|
<Suspense fallback={<Loading />}>
|
||||||
</LayoutProvider>
|
<Home />
|
||||||
</PermissionProvider>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
<Route path="/" component={Home} />
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route
|
||||||
<Route
|
path="/session/:id?"
|
||||||
path="/session/:id?"
|
component={() => (
|
||||||
component={(p) => (
|
<TerminalProvider>
|
||||||
<Show when={p.params.id ?? "new"} keyed>
|
<FileProvider>
|
||||||
<TerminalProvider>
|
<PromptProvider>
|
||||||
<FileProvider>
|
<Suspense fallback={<Loading />}>
|
||||||
<PromptProvider>
|
<Session />
|
||||||
<Session />
|
</Suspense>
|
||||||
</PromptProvider>
|
</PromptProvider>
|
||||||
</FileProvider>
|
</FileProvider>
|
||||||
</TerminalProvider>
|
</TerminalProvider>
|
||||||
</Show>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
</Route>
|
||||||
</Route>
|
</Router>
|
||||||
</Router>
|
</GlobalSyncProvider>
|
||||||
</GlobalSyncProvider>
|
</GlobalSDKProvider>
|
||||||
</GlobalSDKProvider>
|
</ServerKey>
|
||||||
</ServerKey>
|
</ServerProvider>
|
||||||
</ServerProvider>
|
|
||||||
</CodeComponentProvider>
|
|
||||||
</DiffComponentProvider>
|
|
||||||
</MarkedProvider>
|
|
||||||
</DialogProvider>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</ThemeProvider>
|
|
||||||
</MetaProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,11 @@ import { createMemo, createSignal, For, Show } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
function getFilename(input: string) {
|
|
||||||
const parts = input.split("/")
|
|
||||||
return parts[parts.length - 1] || input
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogEditProject(props: { project: LocalProject }) {
|
export function DialogEditProject(props: { project: LocalProject }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function DialogSelectFile() {
|
|||||||
const value = file.tab(path)
|
const value = file.tab(path)
|
||||||
tabs().open(value)
|
tabs().open(value)
|
||||||
file.load(path)
|
file.load(path)
|
||||||
|
layout.review.open()
|
||||||
}
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
|
import {
|
||||||
|
createEffect,
|
||||||
|
on,
|
||||||
|
Component,
|
||||||
|
Show,
|
||||||
|
For,
|
||||||
|
onMount,
|
||||||
|
onCleanup,
|
||||||
|
Switch,
|
||||||
|
Match,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
} from "solid-js"
|
||||||
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"
|
||||||
@@ -30,7 +42,7 @@ 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 { Persist, 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"
|
import { usePermission } from "@/context/permission"
|
||||||
@@ -153,6 +165,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const working = createMemo(() => status()?.type !== "idle")
|
const working = createMemo(() => status()?.type !== "idle")
|
||||||
|
const imageAttachments = createMemo(
|
||||||
|
() => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[],
|
||||||
|
)
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
popover: "at" | "slash" | null
|
popover: "at" | "slash" | null
|
||||||
@@ -160,7 +175,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
savedPrompt: Prompt | null
|
savedPrompt: Prompt | null
|
||||||
placeholder: number
|
placeholder: number
|
||||||
dragging: boolean
|
dragging: boolean
|
||||||
imageAttachments: ImageAttachmentPart[]
|
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
applyingHistory: boolean
|
applyingHistory: boolean
|
||||||
}>({
|
}>({
|
||||||
@@ -169,14 +183,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
savedPrompt: null,
|
savedPrompt: null,
|
||||||
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
||||||
dragging: false,
|
dragging: false,
|
||||||
imageAttachments: [],
|
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const MAX_HISTORY = 100
|
const MAX_HISTORY = 100
|
||||||
const [history, setHistory] = persisted(
|
const [history, setHistory] = persisted(
|
||||||
"prompt-history.v1",
|
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
entries: Prompt[]
|
entries: Prompt[]
|
||||||
}>({
|
}>({
|
||||||
@@ -184,7 +197,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const [shellHistory, setShellHistory] = persisted(
|
const [shellHistory, setShellHistory] = persisted(
|
||||||
"prompt-history-shell.v1",
|
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
entries: Prompt[]
|
entries: Prompt[]
|
||||||
}>({
|
}>({
|
||||||
@@ -235,6 +248,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFocused = createFocusSignal(() => editorRef)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
params.id
|
params.id
|
||||||
editorRef.focus()
|
editorRef.focus()
|
||||||
@@ -245,7 +260,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onCleanup(() => clearInterval(interval))
|
onCleanup(() => clearInterval(interval))
|
||||||
})
|
})
|
||||||
|
|
||||||
const isFocused = createFocusSignal(() => editorRef)
|
const [composing, setComposing] = createSignal(false)
|
||||||
|
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||||
|
|
||||||
const addImageAttachment = async (file: File) => {
|
const addImageAttachment = async (file: File) => {
|
||||||
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
|
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
|
||||||
@@ -260,21 +276,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
mime: file.type,
|
mime: file.type,
|
||||||
dataUrl,
|
dataUrl,
|
||||||
}
|
}
|
||||||
setStore(
|
const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
|
||||||
produce((draft) => {
|
prompt.set([...prompt.current(), attachment], cursorPosition)
|
||||||
draft.imageAttachments.push(attachment)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeImageAttachment = (id: string) => {
|
const removeImageAttachment = (id: string) => {
|
||||||
setStore(
|
const current = prompt.current()
|
||||||
produce((draft) => {
|
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||||
draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id)
|
prompt.set(next, prompt.cursor())
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePaste = async (event: ClipboardEvent) => {
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
@@ -282,12 +293,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const clipboardData = event.clipboardData
|
const clipboardData = event.clipboardData
|
||||||
if (!clipboardData) return
|
if (!clipboardData) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
const items = Array.from(clipboardData.items)
|
const items = Array.from(clipboardData.items)
|
||||||
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
||||||
|
|
||||||
if (imageItems.length > 0) {
|
if (imageItems.length > 0) {
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
for (const item of imageItems) {
|
for (const item of imageItems) {
|
||||||
const file = item.getAsFile()
|
const file = item.getAsFile()
|
||||||
if (file) await addImageAttachment(file)
|
if (file) await addImageAttachment(file)
|
||||||
@@ -295,13 +307,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
const plainText = clipboardData.getData("text/plain") ?? ""
|
const plainText = clipboardData.getData("text/plain") ?? ""
|
||||||
addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGlobalDragOver = (event: DragEvent) => {
|
const handleGlobalDragOver = (event: DragEvent) => {
|
||||||
|
if (dialog.active) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const hasFiles = event.dataTransfer?.types.includes("Files")
|
const hasFiles = event.dataTransfer?.types.includes("Files")
|
||||||
if (hasFiles) {
|
if (hasFiles) {
|
||||||
@@ -310,6 +322,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGlobalDragLeave = (event: DragEvent) => {
|
const handleGlobalDragLeave = (event: DragEvent) => {
|
||||||
|
if (dialog.active) return
|
||||||
|
|
||||||
// relatedTarget is null when leaving the document window
|
// relatedTarget is null when leaving the document window
|
||||||
if (!event.relatedTarget) {
|
if (!event.relatedTarget) {
|
||||||
setStore("dragging", false)
|
setStore("dragging", false)
|
||||||
@@ -317,6 +331,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGlobalDrop = async (event: DragEvent) => {
|
const handleGlobalDrop = async (event: DragEvent) => {
|
||||||
|
if (dialog.active) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setStore("dragging", false)
|
setStore("dragging", false)
|
||||||
|
|
||||||
@@ -331,13 +347,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
editorRef.addEventListener("paste", handlePaste)
|
|
||||||
document.addEventListener("dragover", handleGlobalDragOver)
|
document.addEventListener("dragover", handleGlobalDragOver)
|
||||||
document.addEventListener("dragleave", handleGlobalDragLeave)
|
document.addEventListener("dragleave", handleGlobalDragLeave)
|
||||||
document.addEventListener("drop", handleGlobalDrop)
|
document.addEventListener("drop", handleGlobalDrop)
|
||||||
})
|
})
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
editorRef.removeEventListener("paste", handlePaste)
|
|
||||||
document.removeEventListener("dragover", handleGlobalDragOver)
|
document.removeEventListener("dragover", handleGlobalDragOver)
|
||||||
document.removeEventListener("dragleave", handleGlobalDragLeave)
|
document.removeEventListener("dragleave", handleGlobalDragLeave)
|
||||||
document.removeEventListener("drop", handleGlobalDrop)
|
document.removeEventListener("drop", handleGlobalDrop)
|
||||||
@@ -518,8 +532,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
on(
|
on(
|
||||||
() => prompt.current(),
|
() => prompt.current(),
|
||||||
(currentParts) => {
|
(currentParts) => {
|
||||||
|
const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
|
||||||
const domParts = parseFromDOM()
|
const domParts = parseFromDOM()
|
||||||
if (isNormalizedEditor() && isPromptEqual(currentParts, domParts)) return
|
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||||
|
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
let cursorPosition: number | null = null
|
let cursorPosition: number | null = null
|
||||||
@@ -527,7 +542,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
cursorPosition = getCursorPosition(editorRef)
|
cursorPosition = getCursorPosition(editorRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderEditor(currentParts)
|
renderEditor(inputParts)
|
||||||
|
|
||||||
if (cursorPosition !== null) {
|
if (cursorPosition !== null) {
|
||||||
setCursorPosition(editorRef, cursorPosition)
|
setCursorPosition(editorRef, cursorPosition)
|
||||||
@@ -618,11 +633,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const handleInput = () => {
|
const handleInput = () => {
|
||||||
const rawParts = parseFromDOM()
|
const rawParts = parseFromDOM()
|
||||||
|
const images = imageAttachments()
|
||||||
const cursorPosition = getCursorPosition(editorRef)
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
||||||
const trimmed = rawText.replace(/\u200B/g, "").trim()
|
const trimmed = rawText.replace(/\u200B/g, "").trim()
|
||||||
const hasNonText = rawParts.some((part) => part.type !== "text")
|
const hasNonText = rawParts.some((part) => part.type !== "text")
|
||||||
const shouldReset = trimmed.length === 0 && !hasNonText
|
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
|
||||||
|
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
setStore("popover", null)
|
setStore("popover", null)
|
||||||
@@ -661,7 +677,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
setStore("savedPrompt", null)
|
setStore("savedPrompt", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt.set(rawParts, cursorPosition)
|
prompt.set([...rawParts, ...images], cursorPosition)
|
||||||
queueScroll()
|
queueScroll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,16 +780,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
.map((p) => ("content" in p ? p.content : ""))
|
.map((p) => ("content" in p ? p.content : ""))
|
||||||
.join("")
|
.join("")
|
||||||
.trim()
|
.trim()
|
||||||
if (!text) return
|
const hasImages = prompt.some((part) => part.type === "image")
|
||||||
|
if (!text && !hasImages) return
|
||||||
|
|
||||||
const entry = clonePromptParts(prompt)
|
const entry = clonePromptParts(prompt)
|
||||||
const currentHistory = mode === "shell" ? shellHistory : history
|
const currentHistory = mode === "shell" ? shellHistory : history
|
||||||
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
||||||
const lastEntry = currentHistory.entries[0]
|
const lastEntry = currentHistory.entries[0]
|
||||||
if (lastEntry) {
|
if (lastEntry && isPromptEqual(lastEntry, entry)) return
|
||||||
const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
|
|
||||||
if (lastText === text) return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
|
setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
|
||||||
}
|
}
|
||||||
@@ -862,6 +876,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter" && isImeComposing(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
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 === "at") {
|
if (store.popover === "at") {
|
||||||
atOnKeyDown(event)
|
atOnKeyDown(event)
|
||||||
@@ -943,7 +961,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const currentPrompt = prompt.current()
|
const currentPrompt = prompt.current()
|
||||||
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||||
const images = store.imageAttachments.slice()
|
const images = imageAttachments().slice()
|
||||||
const mode = store.mode
|
const mode = store.mode
|
||||||
|
|
||||||
if (text.trim().length === 0 && images.length === 0) {
|
if (text.trim().length === 0 && images.length === 0) {
|
||||||
@@ -1037,14 +1055,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const clearInput = () => {
|
const clearInput = () => {
|
||||||
prompt.reset()
|
prompt.reset()
|
||||||
setStore("imageAttachments", [])
|
|
||||||
setStore("mode", "normal")
|
setStore("mode", "normal")
|
||||||
setStore("popover", null)
|
setStore("popover", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreInput = () => {
|
const restoreInput = () => {
|
||||||
prompt.set(currentPrompt, promptLength(currentPrompt))
|
prompt.set(currentPrompt, promptLength(currentPrompt))
|
||||||
setStore("imageAttachments", images)
|
|
||||||
setStore("mode", mode)
|
setStore("mode", mode)
|
||||||
setStore("popover", null)
|
setStore("popover", null)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -1087,6 +1103,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
agent,
|
agent,
|
||||||
model: `${model.providerID}/${model.modelID}`,
|
model: `${model.providerID}/${model.modelID}`,
|
||||||
variant,
|
variant,
|
||||||
|
parts: images.map((attachment) => ({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "file" as const,
|
||||||
|
mime: attachment.mime,
|
||||||
|
url: attachment.dataUrl,
|
||||||
|
filename: attachment.filename,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -1440,9 +1463,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={store.imageAttachments.length > 0}>
|
<Show when={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={imageAttachments()}>
|
||||||
{(attachment) => (
|
{(attachment) => (
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<Show
|
<Show
|
||||||
@@ -1483,6 +1506,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onCompositionStart={() => setComposing(true)}
|
||||||
|
onCompositionEnd={() => setComposing(false)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
classList={{
|
classList={{
|
||||||
"select-text": true,
|
"select-text": true,
|
||||||
@@ -1492,7 +1518,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
"font-mono!": store.mode === "shell",
|
"font-mono!": store.mode === "shell",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
|
<Show when={!prompt.dirty()}>
|
||||||
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
||||||
{store.mode === "shell"
|
{store.mode === "shell"
|
||||||
? "Enter shell command..."
|
? "Enter shell command..."
|
||||||
@@ -1566,14 +1592,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||||
classList={{
|
classList={{
|
||||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||||
"text-text-base": !permission.isAutoAccepting(params.id!),
|
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
||||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
|
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="chevron-double-right"
|
name="chevron-double-right"
|
||||||
size="small"
|
size="small"
|
||||||
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }}
|
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
@@ -1625,7 +1651,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
|
disabled={!prompt.dirty() && !working()}
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="h-6 w-4.5"
|
class="h-6 w-4.5"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
import { createMemo, createResource, Show } from "solid-js"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
@@ -20,6 +20,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
|
|||||||
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
||||||
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
||||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { same } from "@/utils/same"
|
||||||
|
|
||||||
export function SessionHeader() {
|
export function SessionHeader() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
@@ -34,8 +35,14 @@ export function SessionHeader() {
|
|||||||
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
|
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
|
||||||
|
|
||||||
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
|
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
|
||||||
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
|
||||||
|
const parentSession = createMemo(() => {
|
||||||
|
const current = currentSession()
|
||||||
|
if (!current?.parentID) return undefined
|
||||||
|
return sync.data.session.find((s) => s.id === current.parentID)
|
||||||
|
})
|
||||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
|
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
|
||||||
|
|
||||||
function navigateToProject(directory: string) {
|
function navigateToProject(directory: string) {
|
||||||
navigate(`/${base64Encode(directory)}`)
|
navigate(`/${base64Encode(directory)}`)
|
||||||
@@ -43,6 +50,8 @@ export function SessionHeader() {
|
|||||||
|
|
||||||
function navigateToSession(session: Session | undefined) {
|
function navigateToSession(session: Session | undefined) {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
// Only navigate if we're actually changing to a different session
|
||||||
|
if (session.id === params.id) return
|
||||||
navigate(`/${params.dir}/session/${session.id}`)
|
navigate(`/${params.dir}/session/${session.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +69,7 @@ export function SessionHeader() {
|
|||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<div class="hidden xl:flex items-center gap-2">
|
<div class="hidden xl:flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
options={layout.projects.list().map((project) => project.worktree)}
|
options={worktrees()}
|
||||||
current={sync.project?.worktree ?? projectDirectory()}
|
current={sync.project?.worktree ?? projectDirectory()}
|
||||||
label={(x) => getFilename(x)}
|
label={(x) => getFilename(x)}
|
||||||
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
|
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
|
||||||
@@ -77,18 +86,56 @@ export function SessionHeader() {
|
|||||||
</Select>
|
</Select>
|
||||||
<div class="text-text-weaker">/</div>
|
<div class="text-text-weaker">/</div>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Show
|
||||||
options={sessions()}
|
when={parentSession()}
|
||||||
current={currentSession()}
|
fallback={
|
||||||
placeholder="New session"
|
<>
|
||||||
label={(x) => x.title}
|
<Select
|
||||||
value={(x) => x.id}
|
options={sessions()}
|
||||||
onSelect={navigateToSession}
|
current={currentSession()}
|
||||||
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
|
placeholder="New session"
|
||||||
variant="ghost"
|
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 class="flex items-center gap-2 min-w-0">
|
||||||
|
<Select
|
||||||
|
options={sessions()}
|
||||||
|
current={parentSession()}
|
||||||
|
placeholder="Back to parent session"
|
||||||
|
label={(x) => x.title}
|
||||||
|
value={(x) => x.id}
|
||||||
|
onSelect={(session) => {
|
||||||
|
// Only navigate if selecting a different session than current parent
|
||||||
|
const currentParent = parentSession()
|
||||||
|
if (session && currentParent && session.id !== currentParent.id) {
|
||||||
|
navigateToSession(session)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
<div class="text-text-weaker">/</div>
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<Tooltip value="Back to parent session">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0"
|
||||||
|
onClick={() => navigateToSession(parentSession())}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size="small" class="text-icon-base" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={currentSession()}>
|
<Show when={currentSession() && !parentSession()}>
|
||||||
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
|
<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" />
|
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
@@ -197,8 +244,13 @@ export function SessionHeader() {
|
|||||||
}
|
}
|
||||||
return shareURL
|
return shareURL
|
||||||
},
|
},
|
||||||
|
{ initialValue: "" },
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Show when={url.latest}>
|
||||||
|
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
|
||||||
|
</Show>
|
||||||
)
|
)
|
||||||
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
|
|
||||||
})}
|
})}
|
||||||
</Popover>
|
</Popover>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -23,11 +23,18 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
if (options().includes(selection)) return selection
|
if (options().includes(selection)) return selection
|
||||||
return MAIN_WORKTREE
|
return MAIN_WORKTREE
|
||||||
})
|
})
|
||||||
|
const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory)
|
||||||
|
const isWorktree = createMemo(() => {
|
||||||
|
const project = sync.project
|
||||||
|
if (!project) return false
|
||||||
|
return sync.data.path.directory !== project.worktree
|
||||||
|
})
|
||||||
|
|
||||||
const label = (value: string) => {
|
const label = (value: string) => {
|
||||||
if (value === MAIN_WORKTREE) {
|
if (value === MAIN_WORKTREE) {
|
||||||
|
if (isWorktree()) return "Main branch"
|
||||||
const branch = sync.data.vcs?.branch
|
const branch = sync.data.vcs?.branch
|
||||||
if (branch) return `Current branch (${branch})`
|
if (branch) return `Main branch (${branch})`
|
||||||
return "Main branch"
|
return "Main branch"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,13 +44,16 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="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"
|
||||||
|
style={{ "padding-bottom": "calc(var(--prompt-height, 11.25rem) + 64px)" }}
|
||||||
|
>
|
||||||
<div class="text-20-medium text-text-weaker">New session</div>
|
<div class="text-20-medium text-text-weaker">New session</div>
|
||||||
<div class="flex justify-center items-center gap-3">
|
<div class="flex justify-center items-center gap-3">
|
||||||
<Icon name="folder" size="small" />
|
<Icon name="folder" size="small" />
|
||||||
<div class="text-12-medium text-text-weak">
|
<div class="text-12-medium text-text-weak">
|
||||||
{getDirectory(sync.data.path.directory)}
|
{getDirectory(projectRoot())}
|
||||||
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center items-center gap-1">
|
<div class="flex justify-center items-center gap-1">
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
hideCloseButton
|
hideCloseButton
|
||||||
|
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||||
>
|
>
|
||||||
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
|
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||||
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { SerializeAddon } from "@/addons/serialize"
|
import { SerializeAddon } from "@/addons/serialize"
|
||||||
import { LocalPTY } from "@/context/terminal"
|
import { LocalPTY } from "@/context/terminal"
|
||||||
import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
|
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
|
||||||
|
|
||||||
export interface TerminalProps extends ComponentProps<"div"> {
|
export interface TerminalProps extends ComponentProps<"div"> {
|
||||||
pty: LocalPTY
|
pty: LocalPTY
|
||||||
@@ -16,6 +16,7 @@ type TerminalColors = {
|
|||||||
background: string
|
background: string
|
||||||
foreground: string
|
foreground: string
|
||||||
cursor: string
|
cursor: string
|
||||||
|
selectionBackground: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||||
@@ -23,11 +24,13 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
|||||||
background: "#fcfcfc",
|
background: "#fcfcfc",
|
||||||
foreground: "#211e1e",
|
foreground: "#211e1e",
|
||||||
cursor: "#211e1e",
|
cursor: "#211e1e",
|
||||||
|
selectionBackground: withAlpha("#211e1e", 0.2),
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
background: "#191515",
|
background: "#191515",
|
||||||
foreground: "#d4d4d4",
|
foreground: "#d4d4d4",
|
||||||
cursor: "#d4d4d4",
|
cursor: "#d4d4d4",
|
||||||
|
selectionBackground: withAlpha("#d4d4d4", 0.25),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,12 +39,14 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
|
||||||
let ws: WebSocket
|
let ws: WebSocket | undefined
|
||||||
let term: Term
|
let term: Term | undefined
|
||||||
let ghostty: Ghostty
|
let ghostty: Ghostty
|
||||||
let serializeAddon: SerializeAddon
|
let serializeAddon: SerializeAddon
|
||||||
let fitAddon: FitAddon
|
let fitAddon: FitAddon
|
||||||
let handleResize: () => void
|
let handleResize: () => void
|
||||||
|
let reconnect: number | undefined
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
const getTerminalColors = (): TerminalColors => {
|
const getTerminalColors = (): TerminalColors => {
|
||||||
const mode = theme.mode()
|
const mode = theme.mode()
|
||||||
@@ -51,12 +56,16 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
||||||
if (!variant?.seeds) return fallback
|
if (!variant?.seeds) return fallback
|
||||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||||
const text = resolved["text-base"] ?? fallback.foreground
|
const text = resolved["text-stronger"] ?? fallback.foreground
|
||||||
const background = resolved["background-stronger"] ?? fallback.background
|
const background = resolved["background-stronger"] ?? fallback.background
|
||||||
|
const alpha = mode === "dark" ? 0.25 : 0.2
|
||||||
|
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
|
||||||
|
const selectionBackground = withAlpha(base, alpha)
|
||||||
return {
|
return {
|
||||||
background,
|
background,
|
||||||
foreground: text,
|
foreground: text,
|
||||||
cursor: text,
|
cursor: text,
|
||||||
|
selectionBackground,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,27 +80,11 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
setOption("theme", colors)
|
setOption("theme", colors)
|
||||||
})
|
})
|
||||||
|
|
||||||
const focusTerminal = () => term?.focus()
|
const focusTerminal = () => {
|
||||||
const copySelection = () => {
|
const t = term
|
||||||
if (!term || !term.hasSelection()) return false
|
if (!t) return
|
||||||
const selection = term.getSelection()
|
t.focus()
|
||||||
if (!selection) return false
|
setTimeout(() => t.textarea?.focus(), 0)
|
||||||
const clipboard = navigator.clipboard
|
|
||||||
if (clipboard?.writeText) {
|
|
||||||
clipboard.writeText(selection).catch(() => {})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (!document.body) return false
|
|
||||||
const textarea = document.createElement("textarea")
|
|
||||||
textarea.value = selection
|
|
||||||
textarea.setAttribute("readonly", "")
|
|
||||||
textarea.style.position = "fixed"
|
|
||||||
textarea.style.opacity = "0"
|
|
||||||
document.body.appendChild(textarea)
|
|
||||||
textarea.select()
|
|
||||||
const copied = document.execCommand("copy")
|
|
||||||
document.body.removeChild(textarea)
|
|
||||||
return copied
|
|
||||||
}
|
}
|
||||||
const handlePointerDown = () => {
|
const handlePointerDown = () => {
|
||||||
const activeElement = document.activeElement
|
const activeElement = document.activeElement
|
||||||
@@ -102,10 +95,15 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
ghostty = await Ghostty.load()
|
const mod = await import("ghostty-web")
|
||||||
|
ghostty = await mod.Ghostty.load()
|
||||||
|
|
||||||
ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
const socket = new WebSocket(
|
||||||
term = new Term({
|
sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
|
||||||
|
)
|
||||||
|
ws = socket
|
||||||
|
|
||||||
|
const t = new mod.Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "IBM Plex Mono, monospace",
|
fontFamily: "IBM Plex Mono, monospace",
|
||||||
@@ -114,50 +112,83 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
scrollback: 10_000,
|
scrollback: 10_000,
|
||||||
ghostty,
|
ghostty,
|
||||||
})
|
})
|
||||||
term.attachCustomKeyEventHandler((event) => {
|
term = t
|
||||||
const key = event.key.toLowerCase()
|
|
||||||
if (key === "c") {
|
const copy = () => {
|
||||||
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
|
const selection = t.getSelection()
|
||||||
const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey
|
if (!selection) return false
|
||||||
if ((macCopy || linuxCopy) && copySelection()) {
|
|
||||||
event.preventDefault()
|
const body = document.body
|
||||||
return true
|
if (body) {
|
||||||
}
|
const textarea = document.createElement("textarea")
|
||||||
|
textarea.value = selection
|
||||||
|
textarea.setAttribute("readonly", "")
|
||||||
|
textarea.style.position = "fixed"
|
||||||
|
textarea.style.opacity = "0"
|
||||||
|
body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
const copied = document.execCommand("copy")
|
||||||
|
body.removeChild(textarea)
|
||||||
|
if (copied) return true
|
||||||
}
|
}
|
||||||
// allow for ctrl-` to toggle terminal in parent
|
|
||||||
if (event.ctrlKey && key === "`") {
|
const clipboard = navigator.clipboard
|
||||||
event.preventDefault()
|
if (clipboard?.writeText) {
|
||||||
|
clipboard.writeText(selection).catch(() => {})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
t.attachCustomKeyEventHandler((event) => {
|
||||||
|
const key = event.key.toLowerCase()
|
||||||
|
|
||||||
|
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
|
||||||
|
copy()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
|
||||||
|
if (!t.hasSelection()) return true
|
||||||
|
copy()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow for ctrl-` to toggle terminal in parent
|
||||||
|
if (event.ctrlKey && key === "`") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
fitAddon = new FitAddon()
|
fitAddon = new mod.FitAddon()
|
||||||
serializeAddon = new SerializeAddon()
|
serializeAddon = new SerializeAddon()
|
||||||
term.loadAddon(serializeAddon)
|
t.loadAddon(serializeAddon)
|
||||||
term.loadAddon(fitAddon)
|
t.loadAddon(fitAddon)
|
||||||
|
|
||||||
term.open(container)
|
t.open(container)
|
||||||
container.addEventListener("pointerdown", handlePointerDown)
|
container.addEventListener("pointerdown", handlePointerDown)
|
||||||
focusTerminal()
|
focusTerminal()
|
||||||
|
|
||||||
if (local.pty.buffer) {
|
if (local.pty.buffer) {
|
||||||
if (local.pty.rows && local.pty.cols) {
|
if (local.pty.rows && local.pty.cols) {
|
||||||
term.resize(local.pty.cols, local.pty.rows)
|
t.resize(local.pty.cols, local.pty.rows)
|
||||||
}
|
}
|
||||||
term.reset()
|
t.write(local.pty.buffer, () => {
|
||||||
term.write(local.pty.buffer)
|
if (local.pty.scrollY) {
|
||||||
if (local.pty.scrollY) {
|
t.scrollToLine(local.pty.scrollY)
|
||||||
term.scrollToLine(local.pty.scrollY)
|
}
|
||||||
}
|
fitAddon.fit()
|
||||||
fitAddon.fit()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fitAddon.observeResize()
|
fitAddon.observeResize()
|
||||||
handleResize = () => fitAddon.fit()
|
handleResize = () => fitAddon.fit()
|
||||||
window.addEventListener("resize", handleResize)
|
window.addEventListener("resize", handleResize)
|
||||||
term.onResize(async (size) => {
|
t.onResize(async (size) => {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
await sdk.client.pty
|
await sdk.client.pty
|
||||||
.update({
|
.update({
|
||||||
ptyID: local.pty.id,
|
ptyID: local.pty.id,
|
||||||
@@ -169,39 +200,39 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
term.onData((data) => {
|
t.onData((data) => {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
ws.send(data)
|
socket.send(data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
term.onKey((key) => {
|
t.onKey((key) => {
|
||||||
if (key.key == "Enter") {
|
if (key.key == "Enter") {
|
||||||
props.onSubmit?.()
|
props.onSubmit?.()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// term.onScroll((ydisp) => {
|
// t.onScroll((ydisp) => {
|
||||||
// console.log("Scroll position:", ydisp)
|
// console.log("Scroll position:", ydisp)
|
||||||
// })
|
// })
|
||||||
ws.addEventListener("open", () => {
|
socket.addEventListener("open", () => {
|
||||||
console.log("WebSocket connected")
|
console.log("WebSocket connected")
|
||||||
sdk.client.pty
|
sdk.client.pty
|
||||||
.update({
|
.update({
|
||||||
ptyID: local.pty.id,
|
ptyID: local.pty.id,
|
||||||
size: {
|
size: {
|
||||||
cols: term.cols,
|
cols: t.cols,
|
||||||
rows: term.rows,
|
rows: t.rows,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
})
|
})
|
||||||
ws.addEventListener("message", (event) => {
|
socket.addEventListener("message", (event) => {
|
||||||
term.write(event.data)
|
t.write(event.data)
|
||||||
})
|
})
|
||||||
ws.addEventListener("error", (error) => {
|
socket.addEventListener("error", (error) => {
|
||||||
console.error("WebSocket error:", error)
|
console.error("WebSocket error:", error)
|
||||||
props.onConnectError?.(error)
|
props.onConnectError?.(error)
|
||||||
})
|
})
|
||||||
ws.addEventListener("close", () => {
|
socket.addEventListener("close", () => {
|
||||||
console.log("WebSocket disconnected")
|
console.log("WebSocket disconnected")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -211,18 +242,21 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
window.removeEventListener("resize", handleResize)
|
window.removeEventListener("resize", handleResize)
|
||||||
}
|
}
|
||||||
container.removeEventListener("pointerdown", handlePointerDown)
|
container.removeEventListener("pointerdown", handlePointerDown)
|
||||||
if (serializeAddon && props.onCleanup) {
|
|
||||||
|
const t = term
|
||||||
|
if (serializeAddon && props.onCleanup && t) {
|
||||||
const buffer = serializeAddon.serialize()
|
const buffer = serializeAddon.serialize()
|
||||||
props.onCleanup({
|
props.onCleanup({
|
||||||
...local.pty,
|
...local.pty,
|
||||||
buffer,
|
buffer,
|
||||||
rows: term.rows,
|
rows: t.rows,
|
||||||
cols: term.cols,
|
cols: t.cols,
|
||||||
scrollY: term.getViewportY(),
|
scrollY: t.getViewportY(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ws?.close()
|
ws?.close()
|
||||||
term?.dispose()
|
t?.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -177,8 +177,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
const all = registrations().flatMap((x) => x())
|
const seen = new Set<string>()
|
||||||
|
const all: CommandOption[] = []
|
||||||
|
|
||||||
|
for (const reg of registrations()) {
|
||||||
|
for (const opt of reg()) {
|
||||||
|
if (seen.has(opt.id)) continue
|
||||||
|
seen.add(opt.id)
|
||||||
|
all.push(opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const suggested = all.filter((x) => x.suggested && !x.disabled)
|
const suggested = all.filter((x) => x.suggested && !x.disabled)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...suggested.map((x) => ({
|
...suggested.map((x) => ({
|
||||||
...x,
|
...x,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||||
@@ -7,7 +7,7 @@ import { useParams } from "@solidjs/router"
|
|||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
import { persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
|
||||||
export type FileSelection = {
|
export type FileSelection = {
|
||||||
startLine: number
|
startLine: number
|
||||||
@@ -82,8 +82,106 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
|
const MAX_FILE_VIEW_SESSIONS = 20
|
||||||
|
const MAX_VIEW_FILES = 500
|
||||||
|
|
||||||
|
type ViewSession = ReturnType<typeof createViewSession>
|
||||||
|
|
||||||
|
type ViewCacheEntry = {
|
||||||
|
value: ViewSession
|
||||||
|
dispose: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
function createViewSession(dir: string, id: string | undefined) {
|
||||||
|
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
||||||
|
|
||||||
|
const [view, setView, _, ready] = persisted(
|
||||||
|
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
|
||||||
|
createStore<{
|
||||||
|
file: Record<string, FileViewState>
|
||||||
|
}>({
|
||||||
|
file: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta = { pruned: false }
|
||||||
|
|
||||||
|
const pruneView = (keep?: string) => {
|
||||||
|
const keys = Object.keys(view.file)
|
||||||
|
if (keys.length <= MAX_VIEW_FILES) return
|
||||||
|
|
||||||
|
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
|
||||||
|
if (drop.length === 0) return
|
||||||
|
|
||||||
|
setView(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const key of drop) {
|
||||||
|
delete draft.file[key]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (meta.pruned) return
|
||||||
|
meta.pruned = true
|
||||||
|
pruneView()
|
||||||
|
})
|
||||||
|
|
||||||
|
const scrollTop = (path: string) => view.file[path]?.scrollTop
|
||||||
|
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
|
||||||
|
const selectedLines = (path: string) => view.file[path]?.selectedLines
|
||||||
|
|
||||||
|
const setScrollTop = (path: string, top: number) => {
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollTop === top) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollTop: top,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setScrollLeft = (path: string, left: number) => {
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollLeft === left) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollLeft: left,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
|
||||||
|
const next = range ? normalizeSelectedLines(range) : null
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.selectedLines === next) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
selectedLines: next,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
scrollTop,
|
||||||
|
scrollLeft,
|
||||||
|
selectedLines,
|
||||||
|
setScrollTop,
|
||||||
|
setScrollLeft,
|
||||||
|
setSelectedLines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
name: "File",
|
name: "File",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -134,16 +232,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
file: {},
|
file: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
|
const viewCache = new Map<string, ViewCacheEntry>()
|
||||||
|
|
||||||
const [view, setView, _, ready] = persisted(
|
const disposeViews = () => {
|
||||||
viewKey(),
|
for (const entry of viewCache.values()) {
|
||||||
createStore<{
|
entry.dispose()
|
||||||
file: Record<string, FileViewState>
|
}
|
||||||
}>({
|
viewCache.clear()
|
||||||
file: {},
|
}
|
||||||
}),
|
|
||||||
)
|
const pruneViews = () => {
|
||||||
|
while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
|
||||||
|
const first = viewCache.keys().next().value
|
||||||
|
if (!first) return
|
||||||
|
const entry = viewCache.get(first)
|
||||||
|
entry?.dispose()
|
||||||
|
viewCache.delete(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadView = (dir: string, id: string | undefined) => {
|
||||||
|
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||||
|
const existing = viewCache.get(key)
|
||||||
|
if (existing) {
|
||||||
|
viewCache.delete(key)
|
||||||
|
viewCache.set(key, existing)
|
||||||
|
return existing.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createRoot((dispose) => ({
|
||||||
|
value: createViewSession(dir, id),
|
||||||
|
dispose,
|
||||||
|
}))
|
||||||
|
|
||||||
|
viewCache.set(key, entry)
|
||||||
|
pruneViews()
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = createMemo(() => loadView(params.dir!, params.id))
|
||||||
|
|
||||||
function ensure(path: string) {
|
function ensure(path: string) {
|
||||||
if (!path) return
|
if (!path) return
|
||||||
@@ -220,48 +347,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const get = (input: string) => store.file[normalize(input)]
|
const get = (input: string) => store.file[normalize(input)]
|
||||||
|
|
||||||
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
|
const scrollTop = (input: string) => view().scrollTop(normalize(input))
|
||||||
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
|
const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
|
||||||
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
|
const selectedLines = (input: string) => view().selectedLines(normalize(input))
|
||||||
|
|
||||||
const setScrollTop = (input: string, top: number) => {
|
const setScrollTop = (input: string, top: number) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
setView("file", path, (current) => {
|
view().setScrollTop(path, top)
|
||||||
if (current?.scrollTop === top) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
scrollTop: top,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setScrollLeft = (input: string, left: number) => {
|
const setScrollLeft = (input: string, left: number) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
setView("file", path, (current) => {
|
view().setScrollLeft(path, left)
|
||||||
if (current?.scrollLeft === left) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
scrollLeft: left,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
const next = range ? normalizeSelectedLines(range) : null
|
view().setSelectedLines(path, range)
|
||||||
setView("file", path, (current) => {
|
|
||||||
if (current?.selectedLines === next) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
selectedLines: next,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => stop())
|
onCleanup(() => {
|
||||||
|
stop()
|
||||||
|
disposeViews()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => view().ready(),
|
||||||
normalize,
|
normalize,
|
||||||
tab,
|
tab,
|
||||||
pathFromTab,
|
pathFromTab,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { onCleanup } from "solid-js"
|
import { batch, onCleanup } from "solid-js"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
|
|
||||||
@@ -19,14 +19,79 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
[key: string]: Event
|
[key: string]: Event
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
type Queued = { directory: string; payload: Event }
|
||||||
|
|
||||||
|
let queue: Array<Queued | undefined> = []
|
||||||
|
const coalesced = new Map<string, number>()
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let last = 0
|
||||||
|
|
||||||
|
const key = (directory: string, payload: Event) => {
|
||||||
|
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||||
|
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||||
|
if (payload.type === "message.part.updated") {
|
||||||
|
const part = payload.properties.part
|
||||||
|
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
|
||||||
|
const events = queue
|
||||||
|
queue = []
|
||||||
|
coalesced.clear()
|
||||||
|
if (events.length === 0) return
|
||||||
|
|
||||||
|
last = Date.now()
|
||||||
|
batch(() => {
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event) continue
|
||||||
|
emitter.emit(event.directory, event.payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (timer) return
|
||||||
|
const elapsed = Date.now() - last
|
||||||
|
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const events = await eventSdk.global.event()
|
const events = await eventSdk.global.event()
|
||||||
|
let yielded = Date.now()
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
emitter.emit(event.directory ?? "global", event.payload)
|
const directory = event.directory ?? "global"
|
||||||
}
|
const payload = event.payload
|
||||||
})().catch(() => undefined)
|
const k = key(directory, payload)
|
||||||
|
if (k) {
|
||||||
|
const i = coalesced.get(k)
|
||||||
|
if (i !== undefined) {
|
||||||
|
queue[i] = undefined
|
||||||
|
}
|
||||||
|
coalesced.set(k, queue.length)
|
||||||
|
}
|
||||||
|
queue.push({ directory, payload })
|
||||||
|
schedule()
|
||||||
|
|
||||||
onCleanup(() => abort.abort())
|
if (Date.now() - yielded < 8) continue
|
||||||
|
yielded = Date.now()
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.finally(stop)
|
||||||
|
.catch(() => undefined)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
abort.abort()
|
||||||
|
stop()
|
||||||
|
})
|
||||||
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
|
|||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { ErrorPage, type InitError } from "../pages/error"
|
import { ErrorPage, type InitError } from "../pages/error"
|
||||||
import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
|
import { batch, createContext, useContext, onCleanup, onMount, type ParentProps, Switch, Match } from "solid-js"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ function createGlobalSync() {
|
|||||||
.catch((e) => setGlobalStore("error", e))
|
.catch((e) => setGlobalStore("error", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
globalSDK.event.listen((e) => {
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const event = e.details
|
const event = e.details
|
||||||
|
|
||||||
@@ -404,6 +404,7 @@ function createGlobalSync() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onCleanup(unsub)
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const health = await globalSDK.client.global
|
const health = await globalSDK.client.global
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { createRoot } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
|
||||||
|
import { createScrollPersistence } from "./layout-scroll"
|
||||||
|
|
||||||
|
describe("createScrollPersistence", () => {
|
||||||
|
test("debounces persisted scroll writes", async () => {
|
||||||
|
const key = "layout-scroll.test"
|
||||||
|
const data = new Map<string, string>()
|
||||||
|
const writes: string[] = []
|
||||||
|
const stats = { flushes: 0 }
|
||||||
|
|
||||||
|
const storage = {
|
||||||
|
getItem: (k: string) => data.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => {
|
||||||
|
data.set(k, v)
|
||||||
|
if (k === key) writes.push(v)
|
||||||
|
},
|
||||||
|
removeItem: (k: string) => {
|
||||||
|
data.delete(k)
|
||||||
|
},
|
||||||
|
} as SyncStorage
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const [raw, setRaw] = createStore({
|
||||||
|
sessionView: {} as Record<string, { scroll: Record<string, { x: number; y: number }> }>,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage })
|
||||||
|
|
||||||
|
const scroll = createScrollPersistence({
|
||||||
|
debounceMs: 30,
|
||||||
|
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
|
||||||
|
onFlush: (sessionKey, next) => {
|
||||||
|
stats.flushes += 1
|
||||||
|
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", sessionKey, { scroll: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
writes.length = 0
|
||||||
|
|
||||||
|
for (const i of Array.from({ length: 100 }, (_, n) => n)) {
|
||||||
|
scroll.setScroll("session", "review", { x: 0, y: i })
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 120))
|
||||||
|
|
||||||
|
expect(stats.flushes).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(writes.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(writes.length).toBeLessThanOrEqual(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
void run()
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
.finally(() => {
|
||||||
|
scroll.dispose()
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
|
||||||
|
export type SessionScroll = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScrollMap = Record<string, SessionScroll>
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
debounceMs?: number
|
||||||
|
getSnapshot: (sessionKey: string) => ScrollMap | undefined
|
||||||
|
onFlush: (sessionKey: string, scroll: ScrollMap) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScrollPersistence(opts: Options) {
|
||||||
|
const wait = opts.debounceMs ?? 200
|
||||||
|
const [cache, setCache] = createStore<Record<string, ScrollMap>>({})
|
||||||
|
const dirty = new Set<string>()
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
function clone(input?: ScrollMap) {
|
||||||
|
const out: ScrollMap = {}
|
||||||
|
if (!input) return out
|
||||||
|
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
const pos = input[key]
|
||||||
|
if (!pos) continue
|
||||||
|
out[key] = { x: pos.x, y: pos.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed(sessionKey: string) {
|
||||||
|
if (cache[sessionKey]) return
|
||||||
|
setCache(sessionKey, clone(opts.getSnapshot(sessionKey)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function scroll(sessionKey: string, tab: string) {
|
||||||
|
seed(sessionKey)
|
||||||
|
return cache[sessionKey]?.[tab] ?? opts.getSnapshot(sessionKey)?.[tab]
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(sessionKey: string) {
|
||||||
|
const prev = timers.get(sessionKey)
|
||||||
|
if (prev) clearTimeout(prev)
|
||||||
|
timers.set(
|
||||||
|
sessionKey,
|
||||||
|
setTimeout(() => flush(sessionKey), wait),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScroll(sessionKey: string, tab: string, pos: SessionScroll) {
|
||||||
|
seed(sessionKey)
|
||||||
|
|
||||||
|
const prev = cache[sessionKey]?.[tab]
|
||||||
|
if (prev?.x === pos.x && prev?.y === pos.y) return
|
||||||
|
|
||||||
|
setCache(sessionKey, tab, { x: pos.x, y: pos.y })
|
||||||
|
dirty.add(sessionKey)
|
||||||
|
schedule(sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush(sessionKey: string) {
|
||||||
|
const timer = timers.get(sessionKey)
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timers.delete(sessionKey)
|
||||||
|
|
||||||
|
if (!dirty.has(sessionKey)) return
|
||||||
|
dirty.delete(sessionKey)
|
||||||
|
|
||||||
|
opts.onFlush(sessionKey, clone(cache[sessionKey]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushAll() {
|
||||||
|
const keys = Array.from(dirty)
|
||||||
|
if (keys.length === 0) return
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
flush(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(keys: string[]) {
|
||||||
|
if (keys.length === 0) return
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const timer = timers.get(key)
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timers.delete(key)
|
||||||
|
dirty.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCache(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
delete draft[key]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
drop(Array.from(timers.keys()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cache,
|
||||||
|
drop,
|
||||||
|
flush,
|
||||||
|
flushAll,
|
||||||
|
scroll,
|
||||||
|
seed,
|
||||||
|
setScroll,
|
||||||
|
dispose,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { batch, createMemo, onMount } from "solid-js"
|
import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
import { Project } from "@opencode-ai/sdk/v2"
|
import { Project } from "@opencode-ai/sdk/v2"
|
||||||
import { persisted } from "@/utils/persist"
|
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||||
|
import { same } from "@/utils/same"
|
||||||
|
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||||
@@ -23,23 +25,11 @@ 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 = {
|
type SessionView = {
|
||||||
scroll: Record<string, SessionScroll>
|
scroll: Record<string, SessionScroll>
|
||||||
reviewOpen?: string[]
|
reviewOpen?: string[]
|
||||||
@@ -56,7 +46,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.v6",
|
Persist.global("layout", ["layout.v6"]),
|
||||||
createStore({
|
createStore({
|
||||||
sidebar: {
|
sidebar: {
|
||||||
opened: false,
|
opened: false,
|
||||||
@@ -81,6 +71,121 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const MAX_SESSION_KEYS = 50
|
||||||
|
const meta = { active: undefined as string | undefined, pruned: false }
|
||||||
|
const used = new Map<string, number>()
|
||||||
|
|
||||||
|
const SESSION_STATE_KEYS = [
|
||||||
|
{ key: "prompt", legacy: "prompt", version: "v2" },
|
||||||
|
{ key: "terminal", legacy: "terminal", version: "v1" },
|
||||||
|
{ key: "file-view", legacy: "file", version: "v1" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const dropSessionState = (keys: string[]) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const parts = key.split("/")
|
||||||
|
const dir = parts[0]
|
||||||
|
const session = parts[1]
|
||||||
|
if (!dir) continue
|
||||||
|
|
||||||
|
for (const entry of SESSION_STATE_KEYS) {
|
||||||
|
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
|
||||||
|
void removePersisted(target)
|
||||||
|
|
||||||
|
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
|
||||||
|
void removePersisted({ key: legacyKey })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prune(keep?: string) {
|
||||||
|
if (!keep) return
|
||||||
|
|
||||||
|
const keys = new Set<string>()
|
||||||
|
for (const key of Object.keys(store.sessionView)) keys.add(key)
|
||||||
|
for (const key of Object.keys(store.sessionTabs)) keys.add(key)
|
||||||
|
if (keys.size <= MAX_SESSION_KEYS) return
|
||||||
|
|
||||||
|
const score = (key: string) => {
|
||||||
|
if (key === keep) return Number.MAX_SAFE_INTEGER
|
||||||
|
return used.get(key) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
|
||||||
|
const drop = ordered.slice(MAX_SESSION_KEYS)
|
||||||
|
if (drop.length === 0) return
|
||||||
|
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const key of drop) {
|
||||||
|
delete draft.sessionView[key]
|
||||||
|
delete draft.sessionTabs[key]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
scroll.drop(drop)
|
||||||
|
dropSessionState(drop)
|
||||||
|
|
||||||
|
for (const key of drop) {
|
||||||
|
used.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function touch(sessionKey: string) {
|
||||||
|
meta.active = sessionKey
|
||||||
|
used.set(sessionKey, Date.now())
|
||||||
|
|
||||||
|
if (!ready()) return
|
||||||
|
if (meta.pruned) return
|
||||||
|
|
||||||
|
meta.pruned = true
|
||||||
|
prune(sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scroll = createScrollPersistence({
|
||||||
|
debounceMs: 250,
|
||||||
|
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
|
||||||
|
onFlush: (sessionKey, next) => {
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
const keep = meta.active ?? sessionKey
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", sessionKey, { scroll: next })
|
||||||
|
prune(keep)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
|
||||||
|
prune(keep)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (meta.pruned) return
|
||||||
|
const active = meta.active
|
||||||
|
if (!active) return
|
||||||
|
meta.pruned = true
|
||||||
|
prune(active)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const flush = () => batch(() => scroll.flushAll())
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState !== "hidden") return
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", flush)
|
||||||
|
document.addEventListener("visibilitychange", handleVisibility)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("pagehide", flush)
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibility)
|
||||||
|
scroll.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const usedColors = new Set<AvatarColorKey>()
|
const usedColors = new Set<AvatarColorKey>()
|
||||||
|
|
||||||
function pickAvailableColor(): AvatarColorKey {
|
function pickAvailableColor(): AvatarColorKey {
|
||||||
@@ -97,8 +202,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...project,
|
|
||||||
...(metadata ?? {}),
|
...(metadata ?? {}),
|
||||||
|
...project,
|
||||||
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
|
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -115,6 +220,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
return project
|
return project
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roots = createMemo(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const project of globalSync.data.project) {
|
||||||
|
const sandboxes = project.sandboxes ?? []
|
||||||
|
for (const sandbox of sandboxes) {
|
||||||
|
map.set(sandbox, project.worktree)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const map = roots()
|
||||||
|
if (map.size === 0) return
|
||||||
|
|
||||||
|
const projects = server.projects.list()
|
||||||
|
const seen = new Set(projects.map((project) => project.worktree))
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
for (const project of projects) {
|
||||||
|
const root = map.get(project.worktree)
|
||||||
|
if (!root) continue
|
||||||
|
|
||||||
|
server.projects.close(project.worktree)
|
||||||
|
|
||||||
|
if (!seen.has(root)) {
|
||||||
|
server.projects.open(root)
|
||||||
|
seen.add(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.expanded) server.projects.expand(root)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
|
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
|
||||||
const list = createMemo(() => enriched().flatMap(colorize))
|
const list = createMemo(() => enriched().flatMap(colorize))
|
||||||
|
|
||||||
@@ -131,11 +271,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
projects: {
|
projects: {
|
||||||
list,
|
list,
|
||||||
open(directory: string) {
|
open(directory: string) {
|
||||||
if (server.projects.list().find((x) => x.worktree === directory)) {
|
const root = roots().get(directory) ?? directory
|
||||||
return
|
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||||
}
|
globalSync.project.loadSessions(root)
|
||||||
globalSync.project.loadSessions(directory)
|
server.projects.open(root)
|
||||||
server.projects.open(directory)
|
|
||||||
},
|
},
|
||||||
close(directory: string) {
|
close(directory: string) {
|
||||||
server.projects.close(directory)
|
server.projects.close(directory)
|
||||||
@@ -225,21 +364,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
view(sessionKey: string) {
|
view(sessionKey: string) {
|
||||||
|
touch(sessionKey)
|
||||||
|
scroll.seed(sessionKey)
|
||||||
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
||||||
return {
|
return {
|
||||||
scroll(tab: string) {
|
scroll(tab: string) {
|
||||||
return s().scroll?.[tab]
|
return scroll.scroll(sessionKey, tab)
|
||||||
},
|
},
|
||||||
setScroll(tab: string, pos: SessionScroll) {
|
setScroll(tab: string, pos: SessionScroll) {
|
||||||
const current = store.sessionView[sessionKey]
|
scroll.setScroll(sessionKey, tab, pos)
|
||||||
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: {
|
review: {
|
||||||
open: createMemo(() => s().reviewOpen),
|
open: createMemo(() => s().reviewOpen),
|
||||||
@@ -257,6 +390,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
tabs(sessionKey: string) {
|
tabs(sessionKey: string) {
|
||||||
|
touch(sessionKey)
|
||||||
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
|
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
|
||||||
return {
|
return {
|
||||||
tabs,
|
tabs,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo, onCleanup } from "solid-js"
|
||||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||||
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
|
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
@@ -8,7 +8,7 @@ import { useSync } from "./sync"
|
|||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
|
||||||
export type LocalFile = FileNode &
|
export type LocalFile = FileNode &
|
||||||
@@ -111,7 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const model = (() => {
|
const model = (() => {
|
||||||
const [store, setStore, _, modelReady] = persisted(
|
const [store, setStore, _, modelReady] = persisted(
|
||||||
"model.v1",
|
Persist.global("model", ["model.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
|
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
|
||||||
recent: ModelKey[]
|
recent: ModelKey[]
|
||||||
@@ -276,7 +276,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
visible(model: ModelKey) {
|
visible(model: ModelKey) {
|
||||||
const key = `${model.providerID}:${model.modelID}`
|
const key = `${model.providerID}:${model.modelID}`
|
||||||
const visibility = userVisibilityMap().get(key)
|
const visibility = userVisibilityMap().get(key)
|
||||||
return visibility !== "hide" && (latestSet().has(key) || visibility === "show")
|
if (visibility === "hide") return false
|
||||||
|
if (visibility === "show") return true
|
||||||
|
if (latestSet().has(key)) return true
|
||||||
|
// For models without valid release_date (e.g. custom models), show by default
|
||||||
|
const m = find(model)
|
||||||
|
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
|
||||||
|
return false
|
||||||
},
|
},
|
||||||
setVisibility(model: ModelKey, visible: boolean) {
|
setVisibility(model: ModelKey, visible: boolean) {
|
||||||
updateVisibility(model, visible ? "show" : "hide")
|
updateVisibility(model, visible ? "show" : "hide")
|
||||||
@@ -465,7 +471,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const searchFilesAndDirectories = (query: string) =>
|
const searchFilesAndDirectories = (query: string) =>
|
||||||
sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
|
sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
|
||||||
|
|
||||||
sdk.event.listen((e) => {
|
const unsub = sdk.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "file.watcher.updated":
|
case "file.watcher.updated":
|
||||||
@@ -475,6 +481,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onCleanup(unsub)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
node: async (path: string) => {
|
node: async (path: string) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createEffect, onCleanup } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
@@ -9,7 +10,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2"
|
|||||||
import { makeAudioPlayer } from "@solid-primitives/audio"
|
import { makeAudioPlayer } from "@solid-primitives/audio"
|
||||||
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
|
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||||
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
|
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
|
||||||
import { persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
|
||||||
type NotificationBase = {
|
type NotificationBase = {
|
||||||
directory?: string
|
directory?: string
|
||||||
@@ -30,6 +31,16 @@ type ErrorNotification = NotificationBase & {
|
|||||||
|
|
||||||
export type Notification = TurnCompleteNotification | ErrorNotification
|
export type Notification = TurnCompleteNotification | ErrorNotification
|
||||||
|
|
||||||
|
const MAX_NOTIFICATIONS = 500
|
||||||
|
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
|
||||||
|
|
||||||
|
function pruneNotifications(list: Notification[]) {
|
||||||
|
const cutoff = Date.now() - NOTIFICATION_TTL_MS
|
||||||
|
const pruned = list.filter((n) => n.time >= cutoff)
|
||||||
|
if (pruned.length <= MAX_NOTIFICATIONS) return pruned
|
||||||
|
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||||
name: "Notification",
|
name: "Notification",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -48,13 +59,26 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
"notification.v1",
|
Persist.global("notification", ["notification.v1"]),
|
||||||
createStore({
|
createStore({
|
||||||
list: [] as Notification[],
|
list: [] as Notification[],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
globalSDK.event.listen((e) => {
|
const meta = { pruned: false }
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (meta.pruned) return
|
||||||
|
meta.pruned = true
|
||||||
|
setStore("list", pruneNotifications(store.list))
|
||||||
|
})
|
||||||
|
|
||||||
|
const append = (notification: Notification) => {
|
||||||
|
setStore("list", (list) => pruneNotifications([...list, notification]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const event = e.details
|
const event = e.details
|
||||||
const base = {
|
const base = {
|
||||||
@@ -72,7 +96,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
try {
|
try {
|
||||||
idlePlayer?.play()
|
idlePlayer?.play()
|
||||||
} catch {}
|
} catch {}
|
||||||
setStore("list", store.list.length, {
|
append({
|
||||||
...base,
|
...base,
|
||||||
type: "turn-complete",
|
type: "turn-complete",
|
||||||
session: sessionID,
|
session: sessionID,
|
||||||
@@ -91,7 +115,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
errorPlayer?.play()
|
errorPlayer?.play()
|
||||||
} catch {}
|
} catch {}
|
||||||
const error = "error" in event.properties ? event.properties.error : undefined
|
const error = "error" in event.properties ? event.properties.error : undefined
|
||||||
setStore("list", store.list.length, {
|
append({
|
||||||
...base,
|
...base,
|
||||||
type: "error",
|
type: "error",
|
||||||
session: sessionID ?? "global",
|
session: sessionID ?? "global",
|
||||||
@@ -104,6 +128,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onCleanup(unsub)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||||
import { persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||||
|
|
||||||
type PermissionRespondFn = (input: {
|
type PermissionRespondFn = (input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -19,6 +19,32 @@ function shouldAutoAccept(perm: PermissionRequest) {
|
|||||||
return perm.permission === "edit"
|
return perm.permission === "edit"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNonAllowRule(rule: unknown) {
|
||||||
|
if (!rule) return false
|
||||||
|
if (typeof rule === "string") return rule !== "allow"
|
||||||
|
if (typeof rule !== "object") return false
|
||||||
|
if (Array.isArray(rule)) return false
|
||||||
|
|
||||||
|
for (const action of Object.values(rule)) {
|
||||||
|
if (action !== "allow") return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAutoAcceptPermissionConfig(permission: unknown) {
|
||||||
|
if (!permission) return false
|
||||||
|
if (typeof permission === "string") return permission !== "allow"
|
||||||
|
if (typeof permission !== "object") return false
|
||||||
|
if (Array.isArray(permission)) return false
|
||||||
|
|
||||||
|
const config = permission as Record<string, unknown>
|
||||||
|
if (isNonAllowRule(config.edit)) return true
|
||||||
|
if (isNonAllowRule(config.write)) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||||
name: "Permission",
|
name: "Permission",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -27,13 +53,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
|
|
||||||
const permissionsEnabled = createMemo(() => {
|
const permissionsEnabled = createMemo(() => {
|
||||||
if (!params.dir || !base64Decode(params.dir)) return false
|
const directory = params.dir ? base64Decode(params.dir) : undefined
|
||||||
const [store] = globalSync.child(base64Decode(params.dir))
|
if (!directory) return false
|
||||||
return store.config.permission !== undefined
|
const [store] = globalSync.child(directory)
|
||||||
|
return hasAutoAcceptPermissionConfig(store.config.permission)
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
"permission.v3",
|
Persist.global("permission", ["permission.v3"]),
|
||||||
createStore({
|
createStore({
|
||||||
autoAcceptEdits: {} as Record<string, boolean>,
|
autoAcceptEdits: {} as Record<string, boolean>,
|
||||||
}),
|
}),
|
||||||
@@ -58,8 +85,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAutoAccepting(sessionID: string) {
|
function acceptKey(sessionID: string, directory?: string) {
|
||||||
return store.autoAcceptEdits[sessionID] ?? false
|
if (!directory) return sessionID
|
||||||
|
return `${base64Encode(directory)}/${sessionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||||
|
const key = acceptKey(sessionID, directory)
|
||||||
|
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = globalSDK.event.listen((e) => {
|
const unsubscribe = globalSDK.event.listen((e) => {
|
||||||
@@ -67,7 +100,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
if (event?.type !== "permission.asked") return
|
if (event?.type !== "permission.asked") return
|
||||||
|
|
||||||
const perm = event.properties
|
const perm = event.properties
|
||||||
if (!isAutoAccepting(perm.sessionID)) return
|
if (!isAutoAccepting(perm.sessionID, e.name)) return
|
||||||
if (!shouldAutoAccept(perm)) return
|
if (!shouldAutoAccept(perm)) return
|
||||||
|
|
||||||
respondOnce(perm, e.name)
|
respondOnce(perm, e.name)
|
||||||
@@ -75,7 +108,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
onCleanup(unsubscribe)
|
onCleanup(unsubscribe)
|
||||||
|
|
||||||
function enable(sessionID: string, directory: string) {
|
function enable(sessionID: string, directory: string) {
|
||||||
setStore("autoAcceptEdits", sessionID, true)
|
const key = acceptKey(sessionID, directory)
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAcceptEdits[key] = true
|
||||||
|
delete draft.autoAcceptEdits[sessionID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
globalSDK.client.permission
|
globalSDK.client.permission
|
||||||
.list({ directory })
|
.list({ directory })
|
||||||
@@ -90,31 +129,37 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function disable(sessionID: string) {
|
function disable(sessionID: string, directory?: string) {
|
||||||
setStore("autoAcceptEdits", sessionID, false)
|
const key = directory ? acceptKey(sessionID, directory) : undefined
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
if (key) delete draft.autoAcceptEdits[key]
|
||||||
|
delete draft.autoAcceptEdits[sessionID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
respond,
|
respond,
|
||||||
autoResponds(permission: PermissionRequest) {
|
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||||
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
|
return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
|
||||||
},
|
},
|
||||||
isAutoAccepting,
|
isAutoAccepting,
|
||||||
toggleAutoAccept(sessionID: string, directory: string) {
|
toggleAutoAccept(sessionID: string, directory: string) {
|
||||||
if (isAutoAccepting(sessionID)) {
|
if (isAutoAccepting(sessionID, directory)) {
|
||||||
disable(sessionID)
|
disable(sessionID, directory)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
enable(sessionID, directory)
|
enable(sessionID, directory)
|
||||||
},
|
},
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
if (isAutoAccepting(sessionID)) return
|
if (isAutoAccepting(sessionID, directory)) return
|
||||||
enable(sessionID, directory)
|
enable(sessionID, directory)
|
||||||
},
|
},
|
||||||
disableAutoAccept(sessionID: string) {
|
disableAutoAccept(sessionID: string, directory?: string) {
|
||||||
disable(sessionID)
|
disable(sessionID, directory)
|
||||||
},
|
},
|
||||||
permissionsEnabled,
|
permissionsEnabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
|
||||||
interface PartBase {
|
interface PartBase {
|
||||||
content: string
|
content: string
|
||||||
@@ -99,74 +99,146 @@ function clonePrompt(prompt: Prompt): Prompt {
|
|||||||
return prompt.map(clonePart)
|
return prompt.map(clonePart)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
|
const MAX_PROMPT_SESSIONS = 20
|
||||||
|
|
||||||
|
type PromptSession = ReturnType<typeof createPromptSession>
|
||||||
|
|
||||||
|
type PromptCacheEntry = {
|
||||||
|
value: PromptSession
|
||||||
|
dispose: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPromptSession(dir: string, id: string | undefined) {
|
||||||
|
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
|
||||||
|
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
Persist.scoped(dir, id, "prompt", [legacy]),
|
||||||
|
createStore<{
|
||||||
|
prompt: Prompt
|
||||||
|
cursor?: number
|
||||||
|
context: {
|
||||||
|
activeTab: boolean
|
||||||
|
items: (ContextItem & { key: string })[]
|
||||||
|
}
|
||||||
|
}>({
|
||||||
|
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||||
|
cursor: undefined,
|
||||||
|
context: {
|
||||||
|
activeTab: true,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function keyForItem(item: ContextItem) {
|
||||||
|
if (item.type !== "file") return item.type
|
||||||
|
const start = item.selection?.startLine
|
||||||
|
const end = item.selection?.endLine
|
||||||
|
return `${item.type}:${item.path}:${start}:${end}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
current: createMemo(() => store.prompt),
|
||||||
|
cursor: createMemo(() => store.cursor),
|
||||||
|
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||||
|
context: {
|
||||||
|
activeTab: createMemo(() => store.context.activeTab),
|
||||||
|
items: createMemo(() => store.context.items),
|
||||||
|
addActive() {
|
||||||
|
setStore("context", "activeTab", true)
|
||||||
|
},
|
||||||
|
removeActive() {
|
||||||
|
setStore("context", "activeTab", false)
|
||||||
|
},
|
||||||
|
add(item: ContextItem) {
|
||||||
|
const key = keyForItem(item)
|
||||||
|
if (store.context.items.find((x) => x.key === key)) return
|
||||||
|
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||||
|
},
|
||||||
|
remove(key: string) {
|
||||||
|
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
set(prompt: Prompt, cursorPosition?: number) {
|
||||||
|
const next = clonePrompt(prompt)
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", next)
|
||||||
|
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||||
|
setStore("cursor", 0)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||||
name: "Prompt",
|
name: "Prompt",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
|
const cache = new Map<string, PromptCacheEntry>()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const disposeAll = () => {
|
||||||
name(),
|
for (const entry of cache.values()) {
|
||||||
createStore<{
|
entry.dispose()
|
||||||
prompt: Prompt
|
}
|
||||||
cursor?: number
|
cache.clear()
|
||||||
context: {
|
|
||||||
activeTab: boolean
|
|
||||||
items: (ContextItem & { key: string })[]
|
|
||||||
}
|
|
||||||
}>({
|
|
||||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
|
||||||
cursor: undefined,
|
|
||||||
context: {
|
|
||||||
activeTab: true,
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function keyForItem(item: ContextItem) {
|
|
||||||
if (item.type !== "file") return item.type
|
|
||||||
const start = item.selection?.startLine
|
|
||||||
const end = item.selection?.endLine
|
|
||||||
return `${item.type}:${item.path}:${start}:${end}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCleanup(disposeAll)
|
||||||
|
|
||||||
|
const prune = () => {
|
||||||
|
while (cache.size > MAX_PROMPT_SESSIONS) {
|
||||||
|
const first = cache.keys().next().value
|
||||||
|
if (!first) return
|
||||||
|
const entry = cache.get(first)
|
||||||
|
entry?.dispose()
|
||||||
|
cache.delete(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = (dir: string, id: string | undefined) => {
|
||||||
|
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||||
|
const existing = cache.get(key)
|
||||||
|
if (existing) {
|
||||||
|
cache.delete(key)
|
||||||
|
cache.set(key, existing)
|
||||||
|
return existing.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createRoot((dispose) => ({
|
||||||
|
value: createPromptSession(dir, id),
|
||||||
|
dispose,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cache.set(key, entry)
|
||||||
|
prune()
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createMemo(() => load(params.dir!, params.id))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => session().ready(),
|
||||||
current: createMemo(() => store.prompt),
|
current: () => session().current(),
|
||||||
cursor: createMemo(() => store.cursor),
|
cursor: () => session().cursor(),
|
||||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
dirty: () => session().dirty(),
|
||||||
context: {
|
context: {
|
||||||
activeTab: createMemo(() => store.context.activeTab),
|
activeTab: () => session().context.activeTab(),
|
||||||
items: createMemo(() => store.context.items),
|
items: () => session().context.items(),
|
||||||
addActive() {
|
addActive: () => session().context.addActive(),
|
||||||
setStore("context", "activeTab", true)
|
removeActive: () => session().context.removeActive(),
|
||||||
},
|
add: (item: ContextItem) => session().context.add(item),
|
||||||
removeActive() {
|
remove: (key: string) => session().context.remove(key),
|
||||||
setStore("context", "activeTab", false)
|
|
||||||
},
|
|
||||||
add(item: ContextItem) {
|
|
||||||
const key = keyForItem(item)
|
|
||||||
if (store.context.items.find((x) => x.key === key)) return
|
|
||||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
|
||||||
},
|
|
||||||
remove(key: string) {
|
|
||||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
set(prompt: Prompt, cursorPosition?: number) {
|
|
||||||
const next = clonePrompt(prompt)
|
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", next)
|
|
||||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
reset() {
|
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
|
||||||
setStore("cursor", 0)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
|
||||||
|
reset: () => session().reset(),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
|
import { onCleanup } from "solid-js"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
|
|
||||||
@@ -20,9 +21,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
globalSDK.event.on(props.directory, async (event) => {
|
const unsub = globalSDK.event.on(props.directory, (event) => {
|
||||||
emitter.emit(event.type, event)
|
emitter.emit(event.type, event)
|
||||||
})
|
})
|
||||||
|
onCleanup(unsub)
|
||||||
|
|
||||||
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
|
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
|
||||||
type StoredProject = { worktree: string; expanded: boolean }
|
type StoredProject = { worktree: string; expanded: boolean }
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
"server.v3",
|
Persist.global("server", ["server.v3"]),
|
||||||
createStore({
|
createStore({
|
||||||
list: [] as string[],
|
list: [] as string[],
|
||||||
projects: {} as Record<string, StoredProject[]>,
|
projects: {} as Record<string, StoredProject[]>,
|
||||||
@@ -91,27 +91,49 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const isReady = createMemo(() => ready() && !!active())
|
const isReady = createMemo(() => ready() && !!active())
|
||||||
|
|
||||||
const [healthy, { refetch }] = createResource(
|
const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
|
||||||
() => active() || undefined,
|
|
||||||
async (url) => {
|
|
||||||
if (!url) return
|
|
||||||
|
|
||||||
const sdk = createOpencodeClient({
|
const check = (url: string) => {
|
||||||
baseUrl: url,
|
const sdk = createOpencodeClient({
|
||||||
fetch: platform.fetch,
|
baseUrl: url,
|
||||||
signal: AbortSignal.timeout(3000),
|
fetch: platform.fetch,
|
||||||
})
|
signal: AbortSignal.timeout(3000),
|
||||||
return sdk.global
|
})
|
||||||
.health()
|
return sdk.global
|
||||||
.then((x) => x.data?.healthy === true)
|
.health()
|
||||||
.catch(() => false)
|
.then((x) => x.data?.healthy === true)
|
||||||
},
|
.catch(() => false)
|
||||||
)
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!active()) return
|
const url = active()
|
||||||
const interval = setInterval(() => refetch(), 10_000)
|
if (!url) return
|
||||||
onCleanup(() => clearInterval(interval))
|
|
||||||
|
setHealthy(undefined)
|
||||||
|
|
||||||
|
let alive = true
|
||||||
|
let busy = false
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
if (busy) return
|
||||||
|
busy = true
|
||||||
|
void check(url)
|
||||||
|
.then((next) => {
|
||||||
|
if (!alive) return
|
||||||
|
setHealthy(next)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
busy = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
const interval = setInterval(run, 10_000)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
alive = false
|
||||||
|
clearInterval(interval)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const origin = createMemo(() => projectsKey(active()))
|
const origin = createMemo(() => projectsKey(active()))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
@@ -14,6 +14,76 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const [store, setStore] = globalSync.child(sdk.directory)
|
const [store, setStore] = globalSync.child(sdk.directory)
|
||||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||||
|
const chunk = 200
|
||||||
|
const inflight = new Map<string, Promise<void>>()
|
||||||
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
|
const [meta, setMeta] = createStore({
|
||||||
|
limit: {} as Record<string, number>,
|
||||||
|
complete: {} as Record<string, boolean>,
|
||||||
|
loading: {} as Record<string, boolean>,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getSession = (sessionID: string) => {
|
||||||
|
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||||
|
if (match.found) return store.session[match.index]
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitFor = (count: number) => {
|
||||||
|
if (count <= chunk) return chunk
|
||||||
|
return Math.ceil(count / chunk) * chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateMessages = (sessionID: string) => {
|
||||||
|
if (meta.limit[sessionID] !== undefined) return
|
||||||
|
|
||||||
|
const messages = store.message[sessionID]
|
||||||
|
if (!messages) return
|
||||||
|
|
||||||
|
const limit = limitFor(messages.length)
|
||||||
|
setMeta("limit", sessionID, limit)
|
||||||
|
setMeta("complete", sessionID, messages.length < limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMessages = async (sessionID: string, limit: number) => {
|
||||||
|
if (meta.loading[sessionID]) return
|
||||||
|
|
||||||
|
setMeta("loading", sessionID, true)
|
||||||
|
await retry(() => sdk.client.session.messages({ sessionID, limit }))
|
||||||
|
.then((messages) => {
|
||||||
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
|
const next = items
|
||||||
|
.map((x) => x.info)
|
||||||
|
.filter((m) => !!m?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setStore("message", sessionID, reconcile(next, { key: "id" }))
|
||||||
|
|
||||||
|
for (const message of items) {
|
||||||
|
setStore(
|
||||||
|
"part",
|
||||||
|
message.info.id,
|
||||||
|
reconcile(
|
||||||
|
message.parts
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setMeta("limit", sessionID, limit)
|
||||||
|
setMeta("complete", sessionID, next.length < limit)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setMeta("loading", sessionID, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: store,
|
data: store,
|
||||||
@@ -30,11 +100,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
get(sessionID: string) {
|
get: getSession,
|
||||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
|
||||||
if (match.found) return store.session[match.index]
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
addOptimisticMessage(input: {
|
addOptimisticMessage(input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID: string
|
messageID: string
|
||||||
@@ -66,58 +132,98 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
async sync(sessionID: string, _isRetry = false) {
|
async sync(sessionID: string) {
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const hasSession = getSession(sessionID) !== undefined
|
||||||
retry(() => sdk.client.session.get({ sessionID })),
|
hydrateMessages(sessionID)
|
||||||
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
|
|
||||||
retry(() => sdk.client.session.todo({ sessionID })),
|
|
||||||
retry(() => sdk.client.session.diff({ sessionID })),
|
|
||||||
])
|
|
||||||
|
|
||||||
batch(() => {
|
const hasMessages = store.message[sessionID] !== undefined
|
||||||
setStore(
|
if (hasSession && hasMessages) return
|
||||||
"session",
|
|
||||||
produce((draft) => {
|
|
||||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
|
||||||
if (match.found) {
|
|
||||||
draft[match.index] = session.data!
|
|
||||||
return
|
|
||||||
}
|
|
||||||
draft.splice(match.index, 0, session.data!)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
const pending = inflight.get(sessionID)
|
||||||
setStore(
|
if (pending) return pending
|
||||||
"message",
|
|
||||||
sessionID,
|
|
||||||
reconcile(
|
|
||||||
(messages.data ?? [])
|
|
||||||
.map((x) => x.info)
|
|
||||||
.filter((m) => !!m?.id)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const message of messages.data ?? []) {
|
const limit = meta.limit[sessionID] ?? chunk
|
||||||
if (!message?.info?.id) continue
|
|
||||||
setStore(
|
|
||||||
"part",
|
|
||||||
message.info.id,
|
|
||||||
reconcile(
|
|
||||||
message.parts
|
|
||||||
.filter((p) => !!p?.id)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
const sessionReq = hasSession
|
||||||
})
|
? Promise.resolve()
|
||||||
|
: retry(() => sdk.client.session.get({ sessionID })).then((session) => {
|
||||||
|
const data = session.data
|
||||||
|
if (!data) return
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
produce((draft) => {
|
||||||
|
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||||
|
if (match.found) {
|
||||||
|
draft[match.index] = data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.splice(match.index, 0, data)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
|
||||||
|
|
||||||
|
const promise = Promise.all([sessionReq, messagesReq])
|
||||||
|
.then(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflight.set(sessionID, promise)
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
async diff(sessionID: string) {
|
||||||
|
if (store.session_diff[sessionID] !== undefined) return
|
||||||
|
|
||||||
|
const pending = inflightDiff.get(sessionID)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const promise = retry(() => sdk.client.session.diff({ sessionID }))
|
||||||
|
.then((diff) => {
|
||||||
|
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightDiff.delete(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflightDiff.set(sessionID, promise)
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
async todo(sessionID: string) {
|
||||||
|
if (store.todo[sessionID] !== undefined) return
|
||||||
|
|
||||||
|
const pending = inflightTodo.get(sessionID)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const promise = retry(() => sdk.client.session.todo({ sessionID }))
|
||||||
|
.then((todo) => {
|
||||||
|
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightTodo.delete(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflightTodo.set(sessionID, promise)
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
more(sessionID: string) {
|
||||||
|
if (store.message[sessionID] === undefined) return false
|
||||||
|
if (meta.limit[sessionID] === undefined) return false
|
||||||
|
if (meta.complete[sessionID]) return false
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
loading(sessionID: string) {
|
||||||
|
return meta.loading[sessionID] ?? false
|
||||||
|
},
|
||||||
|
async loadMore(sessionID: string, count = chunk) {
|
||||||
|
if (meta.loading[sessionID]) return
|
||||||
|
if (meta.complete[sessionID]) return
|
||||||
|
|
||||||
|
const current = meta.limit[sessionID] ?? chunk
|
||||||
|
await loadMessages(sessionID, current + count)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
setStore("limit", (x) => x + count)
|
setStore("limit", (x) => x + count)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
|
||||||
export type LocalPTY = {
|
export type LocalPTY = {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,108 +14,175 @@ export type LocalPTY = {
|
|||||||
scrollY?: number
|
scrollY?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
|
const MAX_TERMINAL_SESSIONS = 20
|
||||||
|
|
||||||
|
type TerminalSession = ReturnType<typeof createTerminalSession>
|
||||||
|
|
||||||
|
type TerminalCacheEntry = {
|
||||||
|
value: TerminalSession
|
||||||
|
dispose: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
|
||||||
|
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
|
||||||
|
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
Persist.scoped(dir, id, "terminal", [legacy]),
|
||||||
|
createStore<{
|
||||||
|
active?: string
|
||||||
|
all: LocalPTY[]
|
||||||
|
}>({
|
||||||
|
all: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
all: createMemo(() => Object.values(store.all)),
|
||||||
|
active: createMemo(() => store.active),
|
||||||
|
new() {
|
||||||
|
sdk.client.pty
|
||||||
|
.create({ title: `Terminal ${store.all.length + 1}` })
|
||||||
|
.then((pty) => {
|
||||||
|
const id = pty.data?.id
|
||||||
|
if (!id) return
|
||||||
|
setStore("all", [
|
||||||
|
...store.all,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
title: pty.data?.title ?? "Terminal",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setStore("active", id)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to create terminal", e)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
|
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
||||||
|
sdk.client.pty
|
||||||
|
.update({
|
||||||
|
ptyID: pty.id,
|
||||||
|
title: pty.title,
|
||||||
|
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to update terminal", e)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async clone(id: string) {
|
||||||
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
|
const pty = store.all[index]
|
||||||
|
if (!pty) return
|
||||||
|
const clone = await sdk.client.pty
|
||||||
|
.create({
|
||||||
|
title: pty.title,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to clone terminal", e)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!clone?.data) return
|
||||||
|
setStore("all", index, {
|
||||||
|
...pty,
|
||||||
|
...clone.data,
|
||||||
|
})
|
||||||
|
if (store.active === pty.id) {
|
||||||
|
setStore("active", clone.data.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
open(id: string) {
|
||||||
|
setStore("active", id)
|
||||||
|
},
|
||||||
|
async close(id: string) {
|
||||||
|
batch(() => {
|
||||||
|
setStore(
|
||||||
|
"all",
|
||||||
|
store.all.filter((x) => x.id !== id),
|
||||||
|
)
|
||||||
|
if (store.active === id) {
|
||||||
|
const index = store.all.findIndex((f) => f.id === id)
|
||||||
|
const previous = store.all[Math.max(0, index - 1)]
|
||||||
|
setStore("active", previous?.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
|
||||||
|
console.error("Failed to close terminal", e)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
move(id: string, to: number) {
|
||||||
|
const index = store.all.findIndex((f) => f.id === id)
|
||||||
|
if (index === -1) return
|
||||||
|
setStore(
|
||||||
|
"all",
|
||||||
|
produce((all) => {
|
||||||
|
all.splice(to, 0, all.splice(index, 1)[0])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
|
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
|
||||||
name: "Terminal",
|
name: "Terminal",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
|
const cache = new Map<string, TerminalCacheEntry>()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const disposeAll = () => {
|
||||||
name(),
|
for (const entry of cache.values()) {
|
||||||
createStore<{
|
entry.dispose()
|
||||||
active?: string
|
}
|
||||||
all: LocalPTY[]
|
cache.clear()
|
||||||
}>({
|
}
|
||||||
all: [],
|
|
||||||
}),
|
onCleanup(disposeAll)
|
||||||
)
|
|
||||||
|
const prune = () => {
|
||||||
|
while (cache.size > MAX_TERMINAL_SESSIONS) {
|
||||||
|
const first = cache.keys().next().value
|
||||||
|
if (!first) return
|
||||||
|
const entry = cache.get(first)
|
||||||
|
entry?.dispose()
|
||||||
|
cache.delete(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = (dir: string, id: string | undefined) => {
|
||||||
|
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||||
|
const existing = cache.get(key)
|
||||||
|
if (existing) {
|
||||||
|
cache.delete(key)
|
||||||
|
cache.set(key, existing)
|
||||||
|
return existing.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createRoot((dispose) => ({
|
||||||
|
value: createTerminalSession(sdk, dir, id),
|
||||||
|
dispose,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cache.set(key, entry)
|
||||||
|
prune()
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createMemo(() => load(params.dir!, params.id))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => session().ready(),
|
||||||
all: createMemo(() => Object.values(store.all)),
|
all: () => session().all(),
|
||||||
active: createMemo(() => store.active),
|
active: () => session().active(),
|
||||||
new() {
|
new: () => session().new(),
|
||||||
sdk.client.pty
|
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
|
||||||
.create({ title: `Terminal ${store.all.length + 1}` })
|
clone: (id: string) => session().clone(id),
|
||||||
.then((pty) => {
|
open: (id: string) => session().open(id),
|
||||||
const id = pty.data?.id
|
close: (id: string) => session().close(id),
|
||||||
if (!id) return
|
move: (id: string, to: number) => session().move(id, to),
|
||||||
setStore("all", [
|
|
||||||
...store.all,
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
title: pty.data?.title ?? "Terminal",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setStore("active", id)
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to create terminal", e)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
|
||||||
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
|
||||||
sdk.client.pty
|
|
||||||
.update({
|
|
||||||
ptyID: pty.id,
|
|
||||||
title: pty.title,
|
|
||||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to update terminal", e)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async clone(id: string) {
|
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
|
||||||
const pty = store.all[index]
|
|
||||||
if (!pty) return
|
|
||||||
const clone = await sdk.client.pty
|
|
||||||
.create({
|
|
||||||
title: pty.title,
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to clone terminal", e)
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
if (!clone?.data) return
|
|
||||||
setStore("all", index, {
|
|
||||||
...pty,
|
|
||||||
...clone.data,
|
|
||||||
})
|
|
||||||
if (store.active === pty.id) {
|
|
||||||
setStore("active", clone.data.id)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
open(id: string) {
|
|
||||||
setStore("active", id)
|
|
||||||
},
|
|
||||||
async close(id: string) {
|
|
||||||
batch(() => {
|
|
||||||
setStore(
|
|
||||||
"all",
|
|
||||||
store.all.filter((x) => x.id !== id),
|
|
||||||
)
|
|
||||||
if (store.active === id) {
|
|
||||||
const index = store.all.findIndex((f) => f.id === id)
|
|
||||||
const previous = store.all[Math.max(0, index - 1)]
|
|
||||||
setStore("active", previous?.id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
|
|
||||||
console.error("Failed to close terminal", e)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
move(id: string, to: number) {
|
|
||||||
const index = store.all.findIndex((f) => f.id === id)
|
|
||||||
if (index === -1) return
|
|
||||||
setStore(
|
|
||||||
"all",
|
|
||||||
produce((all) => {
|
|
||||||
all.splice(to, 0, all.splice(index, 1)[0])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @refresh reload
|
// @refresh reload
|
||||||
import { render } from "solid-js/web"
|
import { render } from "solid-js/web"
|
||||||
import { App } from "@/app"
|
import { AppBaseProviders, AppInterface } from "@/app"
|
||||||
import { Platform, PlatformProvider } from "@/context/platform"
|
import { Platform, PlatformProvider } from "@/context/platform"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
|
|
||||||
@@ -55,7 +55,9 @@ const platform: Platform = {
|
|||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
<App />
|
<AppBaseProviders>
|
||||||
|
<AppInterface />
|
||||||
|
</AppBaseProviders>
|
||||||
</PlatformProvider>
|
</PlatformProvider>
|
||||||
),
|
),
|
||||||
root!,
|
root!,
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { PlatformProvider, type Platform } from "./context/platform"
|
export { PlatformProvider, type Platform } from "./context/platform"
|
||||||
export { App } from "./app"
|
export { AppBaseProviders, AppInterface } from "./app"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useNavigate, 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"
|
||||||
@@ -10,6 +10,7 @@ import { iife } from "@opencode-ai/util/iife"
|
|||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const directory = createMemo(() => {
|
const directory = createMemo(() => {
|
||||||
return base64Decode(params.dir!)
|
return base64Decode(params.dir!)
|
||||||
})
|
})
|
||||||
@@ -26,8 +27,17 @@ export default function Layout(props: ParentProps) {
|
|||||||
response: "once" | "always" | "reject"
|
response: "once" | "always" | "reject"
|
||||||
}) => sdk.client.permission.respond(input)
|
}) => sdk.client.permission.respond(input)
|
||||||
|
|
||||||
|
const navigateToSession = (sessionID: string) => {
|
||||||
|
navigate(`/${params.dir}/session/${sessionID}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
|
<DataProvider
|
||||||
|
data={sync.data}
|
||||||
|
directory={directory()}
|
||||||
|
onPermissionRespond={respond}
|
||||||
|
onNavigateToSession={navigateToSession}
|
||||||
|
>
|
||||||
<LocalProvider>{props.children}</LocalProvider>
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
batch,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
@@ -31,7 +32,7 @@ import { getFilename } from "@opencode-ai/util/path"
|
|||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import {
|
import {
|
||||||
DragDropProvider,
|
DragDropProvider,
|
||||||
DragDropSensors,
|
DragDropSensors,
|
||||||
@@ -47,6 +48,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
|
|||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||||
@@ -55,6 +57,7 @@ import { DialogEditProject } from "@/components/dialog-edit-project"
|
|||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||||
|
import { navStart } from "@/utils/perf"
|
||||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
|
||||||
@@ -170,7 +173,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (e.details?.type !== "permission.asked") return
|
if (e.details?.type !== "permission.asked") return
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const perm = e.details.properties
|
const perm = e.details.properties
|
||||||
if (permission.autoResponds(perm)) return
|
if (permission.autoResponds(perm, directory)) return
|
||||||
|
|
||||||
const [store] = globalSync.child(directory)
|
const [store] = globalSync.child(directory)
|
||||||
const session = store.session.find((s) => s.id === perm.sessionID)
|
const session = store.session.find((s) => s.id === perm.sessionID)
|
||||||
@@ -266,24 +269,170 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectSessions(directory: string) {
|
const currentProject = createMemo(() => {
|
||||||
if (!directory) return []
|
const directory = params.dir ? base64Decode(params.dir) : undefined
|
||||||
const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions)
|
if (!directory) return
|
||||||
return (sessions ?? []).filter((s) => !s.parentID)
|
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
||||||
|
})
|
||||||
|
|
||||||
|
function projectSessions(project: LocalProject | undefined) {
|
||||||
|
if (!project) return []
|
||||||
|
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||||
|
const stores = dirs.map((dir) => globalSync.child(dir)[0])
|
||||||
|
const sessions = stores
|
||||||
|
.flatMap((store) => store.session.filter((session) => session.directory === store.path.directory))
|
||||||
|
.toSorted(sortSessions)
|
||||||
|
return sessions.filter((s) => !s.parentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSessions = createMemo(() => {
|
const currentSessions = createMemo(() => projectSessions(currentProject()))
|
||||||
if (!params.dir) return []
|
|
||||||
const directory = base64Decode(params.dir)
|
type PrefetchQueue = {
|
||||||
return projectSessions(directory)
|
inflight: Set<string>
|
||||||
|
pending: string[]
|
||||||
|
pendingSet: Set<string>
|
||||||
|
running: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefetchChunk = 200
|
||||||
|
const prefetchConcurrency = 1
|
||||||
|
const prefetchPendingLimit = 6
|
||||||
|
const prefetchToken = { value: 0 }
|
||||||
|
const prefetchQueues = new Map<string, PrefetchQueue>()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
params.dir
|
||||||
|
globalSDK.url
|
||||||
|
|
||||||
|
prefetchToken.value += 1
|
||||||
|
for (const q of prefetchQueues.values()) {
|
||||||
|
q.pending.length = 0
|
||||||
|
q.pendingSet.clear()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const queueFor = (directory: string) => {
|
||||||
|
const existing = prefetchQueues.get(directory)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const created: PrefetchQueue = {
|
||||||
|
inflight: new Set(),
|
||||||
|
pending: [],
|
||||||
|
pendingSet: new Set(),
|
||||||
|
running: 0,
|
||||||
|
}
|
||||||
|
prefetchQueues.set(directory, created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefetchMessages = (directory: string, sessionID: string, token: number) => {
|
||||||
|
const [, setStore] = globalSync.child(directory)
|
||||||
|
|
||||||
|
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||||
|
.then((messages) => {
|
||||||
|
if (prefetchToken.value !== token) return
|
||||||
|
|
||||||
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
|
const next = items
|
||||||
|
.map((x) => x.info)
|
||||||
|
.filter((m) => !!m?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setStore("message", sessionID, reconcile(next, { key: "id" }))
|
||||||
|
|
||||||
|
for (const message of items) {
|
||||||
|
setStore(
|
||||||
|
"part",
|
||||||
|
message.info.id,
|
||||||
|
reconcile(
|
||||||
|
message.parts
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pumpPrefetch = (directory: string) => {
|
||||||
|
const q = queueFor(directory)
|
||||||
|
if (q.running >= prefetchConcurrency) return
|
||||||
|
|
||||||
|
const sessionID = q.pending.shift()
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
q.pendingSet.delete(sessionID)
|
||||||
|
q.inflight.add(sessionID)
|
||||||
|
q.running += 1
|
||||||
|
|
||||||
|
const token = prefetchToken.value
|
||||||
|
|
||||||
|
void prefetchMessages(directory, sessionID, token).finally(() => {
|
||||||
|
q.running -= 1
|
||||||
|
q.inflight.delete(sessionID)
|
||||||
|
pumpPrefetch(directory)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
|
||||||
|
const directory = session.directory
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
const [store] = globalSync.child(directory)
|
||||||
|
if (store.message[session.id] !== undefined) return
|
||||||
|
|
||||||
|
const q = queueFor(directory)
|
||||||
|
if (q.inflight.has(session.id)) return
|
||||||
|
if (q.pendingSet.has(session.id)) return
|
||||||
|
|
||||||
|
if (priority === "high") q.pending.unshift(session.id)
|
||||||
|
if (priority !== "high") q.pending.push(session.id)
|
||||||
|
q.pendingSet.add(session.id)
|
||||||
|
|
||||||
|
while (q.pending.length > prefetchPendingLimit) {
|
||||||
|
const dropped = q.pending.pop()
|
||||||
|
if (!dropped) continue
|
||||||
|
q.pendingSet.delete(dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
pumpPrefetch(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const sessions = currentSessions()
|
||||||
|
const id = params.id
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
const first = sessions[0]
|
||||||
|
if (first) prefetchSession(first)
|
||||||
|
|
||||||
|
const second = sessions[1]
|
||||||
|
if (second) prefetchSession(second)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = sessions.findIndex((s) => s.id === id)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
const next = sessions[index + 1]
|
||||||
|
if (next) prefetchSession(next)
|
||||||
|
|
||||||
|
const prev = sessions[index - 1]
|
||||||
|
if (prev) prefetchSession(prev)
|
||||||
})
|
})
|
||||||
|
|
||||||
function navigateSessionByOffset(offset: number) {
|
function navigateSessionByOffset(offset: number) {
|
||||||
const projects = layout.projects.list()
|
const projects = layout.projects.list()
|
||||||
if (projects.length === 0) return
|
if (projects.length === 0) return
|
||||||
|
|
||||||
const currentDirectory = params.dir ? base64Decode(params.dir) : undefined
|
const project = currentProject()
|
||||||
const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1
|
const projectIndex = project ? projects.findIndex((p) => p.worktree === project.worktree) : -1
|
||||||
|
|
||||||
if (projectIndex === -1) {
|
if (projectIndex === -1) {
|
||||||
const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
|
const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
|
||||||
@@ -303,6 +452,27 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
if (targetIndex >= 0 && targetIndex < sessions.length) {
|
if (targetIndex >= 0 && targetIndex < sessions.length) {
|
||||||
const session = sessions[targetIndex]
|
const session = sessions[targetIndex]
|
||||||
|
const next = sessions[targetIndex + 1]
|
||||||
|
const prev = sessions[targetIndex - 1]
|
||||||
|
|
||||||
|
if (offset > 0) {
|
||||||
|
if (next) prefetchSession(next, "high")
|
||||||
|
if (prev) prefetchSession(prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset < 0) {
|
||||||
|
if (prev) prefetchSession(prev, "high")
|
||||||
|
if (next) prefetchSession(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
navStart({
|
||||||
|
dir: base64Encode(session.directory),
|
||||||
|
from: params.id,
|
||||||
|
to: session.id,
|
||||||
|
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
|
||||||
|
})
|
||||||
|
}
|
||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
queueMicrotask(() => scrollToSession(session.id))
|
queueMicrotask(() => scrollToSession(session.id))
|
||||||
return
|
return
|
||||||
@@ -312,14 +482,34 @@ export default function Layout(props: ParentProps) {
|
|||||||
const nextProject = projects[nextProjectIndex]
|
const nextProject = projects[nextProjectIndex]
|
||||||
if (!nextProject) return
|
if (!nextProject) return
|
||||||
|
|
||||||
const nextProjectSessions = projectSessions(nextProject.worktree)
|
const nextProjectSessions = projectSessions(nextProject)
|
||||||
if (nextProjectSessions.length === 0) {
|
if (nextProjectSessions.length === 0) {
|
||||||
navigateToProject(nextProject.worktree)
|
navigateToProject(nextProject.worktree)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
|
const index = offset > 0 ? 0 : nextProjectSessions.length - 1
|
||||||
navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
|
const targetSession = nextProjectSessions[index]
|
||||||
|
const nextSession = nextProjectSessions[index + 1]
|
||||||
|
const prevSession = nextProjectSessions[index - 1]
|
||||||
|
|
||||||
|
if (offset > 0) {
|
||||||
|
if (nextSession) prefetchSession(nextSession, "high")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset < 0) {
|
||||||
|
if (prevSession) prefetchSession(prevSession, "high")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
navStart({
|
||||||
|
dir: base64Encode(targetSession.directory),
|
||||||
|
from: params.id,
|
||||||
|
to: targetSession.id,
|
||||||
|
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
navigateToSession(targetSession)
|
||||||
queueMicrotask(() => scrollToSession(targetSession.id))
|
queueMicrotask(() => scrollToSession(targetSession.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +655,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
function navigateToSession(session: Session | undefined) {
|
function navigateToSession(session: Session | undefined) {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
navigate(`/${params.dir}/session/${session?.id}`)
|
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
|
||||||
layout.mobileSidebar.hide()
|
layout.mobileSidebar.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +704,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
const id = params.id
|
const id = params.id
|
||||||
setStore("lastSession", directory, id)
|
setStore("lastSession", directory, id)
|
||||||
notification.session.markViewed(id)
|
notification.session.markViewed(id)
|
||||||
untrack(() => layout.projects.expand(directory))
|
const project = currentProject()
|
||||||
|
untrack(() => layout.projects.expand(project?.worktree ?? directory))
|
||||||
requestAnimationFrame(() => scrollToSession(id))
|
requestAnimationFrame(() => scrollToSession(id))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -644,13 +835,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
||||||
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
||||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||||
|
const [sessionStore] = globalSync.child(props.session.directory)
|
||||||
const hasPermissions = createMemo(() => {
|
const hasPermissions = createMemo(() => {
|
||||||
const store = globalSync.child(props.project.worktree)[0]
|
const permissions = sessionStore.permission?.[props.session.id] ?? []
|
||||||
const permissions = store.permission?.[props.session.id] ?? []
|
|
||||||
if (permissions.length > 0) return true
|
if (permissions.length > 0) return true
|
||||||
const childSessions = store.session.filter((s) => s.parentID === props.session.id)
|
const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id)
|
||||||
for (const child of childSessions) {
|
for (const child of childSessions) {
|
||||||
const childPermissions = store.permission?.[child.id] ?? []
|
const childPermissions = sessionStore.permission?.[child.id] ?? []
|
||||||
if (childPermissions.length > 0) return true
|
if (childPermissions.length > 0) return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -658,7 +849,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const isWorking = createMemo(() => {
|
const isWorking = createMemo(() => {
|
||||||
if (props.session.id === params.id) return false
|
if (props.session.id === params.id) return false
|
||||||
if (hasPermissions()) return false
|
if (hasPermissions()) return false
|
||||||
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
|
const status = sessionStore.session_status[props.session.id]
|
||||||
return status?.type === "busy" || status?.type === "retry"
|
return status?.type === "busy" || status?.type === "retry"
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
@@ -672,6 +863,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
<A
|
<A
|
||||||
href={`${props.slug}/session/${props.session.id}`}
|
href={`${props.slug}/session/${props.session.id}`}
|
||||||
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
|
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
|
||||||
|
onMouseEnter={() => prefetchSession(props.session, "high")}
|
||||||
|
onFocus={() => prefetchSession(props.session, "high")}
|
||||||
>
|
>
|
||||||
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
||||||
<span
|
<span
|
||||||
@@ -759,6 +952,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
const isExpanded = createMemo(() =>
|
const isExpanded = createMemo(() =>
|
||||||
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
|
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
|
||||||
)
|
)
|
||||||
|
const isActive = createMemo(() => {
|
||||||
|
const current = params.dir ? base64Decode(params.dir) : ""
|
||||||
|
return props.project.worktree === current || props.project.sandboxes?.includes(current)
|
||||||
|
})
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (props.mobile) {
|
if (props.mobile) {
|
||||||
if (open) mobileProjects.expand(props.project.worktree)
|
if (open) mobileProjects.expand(props.project.worktree)
|
||||||
@@ -777,7 +974,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
<Button
|
<Button
|
||||||
as={"div"}
|
as={"div"}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg"
|
classList={{
|
||||||
|
"group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg": true,
|
||||||
|
"bg-surface-raised-base-hover": isActive() && !isExpanded(),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
|
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
|
||||||
<ProjectAvatar
|
<ProjectAvatar
|
||||||
@@ -861,7 +1061,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Tooltip placement="right" value={props.project.worktree}>
|
<Tooltip placement="right" value={getFilename(props.project.worktree)}>
|
||||||
<ProjectVisual project={props.project} />
|
<ProjectVisual project={props.project} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
+589
-207
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
|||||||
|
type Nav = {
|
||||||
|
id: string
|
||||||
|
dir?: string
|
||||||
|
from?: string
|
||||||
|
to: string
|
||||||
|
trigger?: string
|
||||||
|
start: number
|
||||||
|
marks: Record<string, number>
|
||||||
|
logged: boolean
|
||||||
|
timer?: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
const dev = import.meta.env.DEV
|
||||||
|
|
||||||
|
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
|
||||||
|
|
||||||
|
const now = () => performance.now()
|
||||||
|
|
||||||
|
const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)
|
||||||
|
|
||||||
|
const navs = new Map<string, Nav>()
|
||||||
|
const pending = new Map<string, string>()
|
||||||
|
const active = new Map<string, string>()
|
||||||
|
|
||||||
|
const required = [
|
||||||
|
"session:params",
|
||||||
|
"session:data-ready",
|
||||||
|
"session:first-turn-mounted",
|
||||||
|
"storage:prompt-ready",
|
||||||
|
"storage:terminal-ready",
|
||||||
|
"storage:file-view-ready",
|
||||||
|
]
|
||||||
|
|
||||||
|
function flush(id: string, reason: "complete" | "timeout") {
|
||||||
|
if (!dev) return
|
||||||
|
const nav = navs.get(id)
|
||||||
|
if (!nav) return
|
||||||
|
if (nav.logged) return
|
||||||
|
|
||||||
|
nav.logged = true
|
||||||
|
if (nav.timer) clearTimeout(nav.timer)
|
||||||
|
|
||||||
|
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
|
||||||
|
const base = nav.marks[baseName] ?? nav.start
|
||||||
|
|
||||||
|
const ms = Object.fromEntries(
|
||||||
|
Object.entries(nav.marks)
|
||||||
|
.slice()
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"perf.session-nav " +
|
||||||
|
JSON.stringify({
|
||||||
|
type: "perf.session-nav.v0",
|
||||||
|
id: nav.id,
|
||||||
|
dir: nav.dir,
|
||||||
|
from: nav.from,
|
||||||
|
to: nav.to,
|
||||||
|
trigger: nav.trigger,
|
||||||
|
base: baseName,
|
||||||
|
reason,
|
||||||
|
ms,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
navs.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeFlush(id: string) {
|
||||||
|
if (!dev) return
|
||||||
|
const nav = navs.get(id)
|
||||||
|
if (!nav) return
|
||||||
|
if (nav.logged) return
|
||||||
|
if (!required.every((name) => nav.marks[name] !== undefined)) return
|
||||||
|
flush(id, "complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
|
||||||
|
const existing = navs.get(id)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const nav: Nav = {
|
||||||
|
...data,
|
||||||
|
marks: {},
|
||||||
|
logged: false,
|
||||||
|
}
|
||||||
|
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
|
||||||
|
navs.set(id, nav)
|
||||||
|
return nav
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
|
||||||
|
if (!dev) return
|
||||||
|
|
||||||
|
const id = uid()
|
||||||
|
const start = now()
|
||||||
|
const nav = ensure(id, { ...input, id, start })
|
||||||
|
nav.marks["navigate:start"] = start
|
||||||
|
|
||||||
|
pending.set(key(input.dir, input.to), id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navParams(input: { dir?: string; from?: string; to: string }) {
|
||||||
|
if (!dev) return
|
||||||
|
|
||||||
|
const k = key(input.dir, input.to)
|
||||||
|
const pendingId = pending.get(k)
|
||||||
|
if (pendingId) pending.delete(k)
|
||||||
|
const id = pendingId ?? uid()
|
||||||
|
|
||||||
|
const start = now()
|
||||||
|
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
|
||||||
|
nav.marks["session:params"] = start
|
||||||
|
|
||||||
|
active.set(k, id)
|
||||||
|
maybeFlush(id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navMark(input: { dir?: string; to: string; name: string }) {
|
||||||
|
if (!dev) return
|
||||||
|
|
||||||
|
const id = active.get(key(input.dir, input.to))
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const nav = navs.get(id)
|
||||||
|
if (!nav) return
|
||||||
|
if (nav.marks[input.name] !== undefined) return
|
||||||
|
|
||||||
|
nav.marks[input.name] = now()
|
||||||
|
maybeFlush(id)
|
||||||
|
}
|
||||||
@@ -1,17 +1,235 @@
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { makePersisted } from "@solid-primitives/storage"
|
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||||
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
import { createResource, type Accessor } from "solid-js"
|
import { createResource, type Accessor } from "solid-js"
|
||||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||||
|
|
||||||
type InitType = Promise<string> | string | null
|
type InitType = Promise<string> | string | null
|
||||||
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
|
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
|
||||||
|
|
||||||
export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> {
|
type PersistTarget = {
|
||||||
|
storage?: string
|
||||||
|
key: string
|
||||||
|
legacy?: string[]
|
||||||
|
migrate?: (value: unknown) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_STORAGE = "default.dat"
|
||||||
|
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||||
|
|
||||||
|
function snapshot(value: unknown) {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function merge(defaults: unknown, value: unknown): unknown {
|
||||||
|
if (value === undefined) return defaults
|
||||||
|
if (value === null) return value
|
||||||
|
|
||||||
|
if (Array.isArray(defaults)) {
|
||||||
|
if (Array.isArray(value)) return value
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(defaults)) {
|
||||||
|
if (!isRecord(value)) return defaults
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = { ...defaults }
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
if (key in defaults) {
|
||||||
|
result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
|
||||||
|
} else {
|
||||||
|
result[key] = (value as Record<string, unknown>)[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(value: string) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as unknown
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceStorage(dir: string) {
|
||||||
|
const head = dir.slice(0, 12) || "workspace"
|
||||||
|
const sum = checksum(dir) ?? "0"
|
||||||
|
return `opencode.workspace.${head}.${sum}.dat`
|
||||||
|
}
|
||||||
|
|
||||||
|
function localStorageWithPrefix(prefix: string): SyncStorage {
|
||||||
|
const base = `${prefix}:`
|
||||||
|
return {
|
||||||
|
getItem: (key) => localStorage.getItem(base + key),
|
||||||
|
setItem: (key, value) => localStorage.setItem(base + key, value),
|
||||||
|
removeItem: (key) => localStorage.removeItem(base + key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Persist = {
|
||||||
|
global(key: string, legacy?: string[]): PersistTarget {
|
||||||
|
return { storage: GLOBAL_STORAGE, key, legacy }
|
||||||
|
},
|
||||||
|
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
|
||||||
|
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
|
||||||
|
},
|
||||||
|
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
|
||||||
|
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
|
||||||
|
},
|
||||||
|
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
|
||||||
|
if (session) return Persist.session(dir, session, key, legacy)
|
||||||
|
return Persist.workspace(dir, key, legacy)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePersisted(target: { storage?: string; key: string }) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
|
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return platform.storage?.(target.storage)?.removeItem(target.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.storage) {
|
||||||
|
localStorage.removeItem(target.key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorageWithPrefix(target.storage).removeItem(target.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persisted<T>(
|
||||||
|
target: string | PersistTarget,
|
||||||
|
store: [Store<T>, SetStoreFunction<T>],
|
||||||
|
): PersistedWithReady<T> {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const config: PersistTarget = typeof target === "string" ? { key: target } : target
|
||||||
|
|
||||||
|
const defaults = snapshot(store[0])
|
||||||
|
const legacy = config.legacy ?? []
|
||||||
|
|
||||||
|
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||||
|
|
||||||
|
const currentStorage = (() => {
|
||||||
|
if (isDesktop) return platform.storage?.(config.storage)
|
||||||
|
if (!config.storage) return localStorage
|
||||||
|
return localStorageWithPrefix(config.storage)
|
||||||
|
})()
|
||||||
|
|
||||||
|
const legacyStorage = (() => {
|
||||||
|
if (!isDesktop) return localStorage
|
||||||
|
if (!config.storage) return platform.storage?.()
|
||||||
|
return platform.storage?.(LEGACY_STORAGE)
|
||||||
|
})()
|
||||||
|
|
||||||
|
const storage = (() => {
|
||||||
|
if (!isDesktop) {
|
||||||
|
const current = currentStorage as SyncStorage
|
||||||
|
const legacyStore = legacyStorage as SyncStorage
|
||||||
|
|
||||||
|
const api: SyncStorage = {
|
||||||
|
getItem: (key) => {
|
||||||
|
const raw = current.getItem(key)
|
||||||
|
if (raw !== null) {
|
||||||
|
const parsed = parse(raw)
|
||||||
|
if (parsed === undefined) return raw
|
||||||
|
|
||||||
|
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||||
|
const merged = merge(defaults, migrated)
|
||||||
|
const next = JSON.stringify(merged)
|
||||||
|
if (raw !== next) current.setItem(key, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const legacyKey of legacy) {
|
||||||
|
const legacyRaw = legacyStore.getItem(legacyKey)
|
||||||
|
if (legacyRaw === null) continue
|
||||||
|
|
||||||
|
current.setItem(key, legacyRaw)
|
||||||
|
legacyStore.removeItem(legacyKey)
|
||||||
|
|
||||||
|
const parsed = parse(legacyRaw)
|
||||||
|
if (parsed === undefined) return legacyRaw
|
||||||
|
|
||||||
|
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||||
|
const merged = merge(defaults, migrated)
|
||||||
|
const next = JSON.stringify(merged)
|
||||||
|
if (legacyRaw !== next) current.setItem(key, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
setItem: (key, value) => {
|
||||||
|
current.setItem(key, value)
|
||||||
|
},
|
||||||
|
removeItem: (key) => {
|
||||||
|
current.removeItem(key)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = currentStorage as AsyncStorage
|
||||||
|
const legacyStore = legacyStorage as AsyncStorage | undefined
|
||||||
|
|
||||||
|
const api: AsyncStorage = {
|
||||||
|
getItem: async (key) => {
|
||||||
|
const raw = await current.getItem(key)
|
||||||
|
if (raw !== null) {
|
||||||
|
const parsed = parse(raw)
|
||||||
|
if (parsed === undefined) return raw
|
||||||
|
|
||||||
|
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||||
|
const merged = merge(defaults, migrated)
|
||||||
|
const next = JSON.stringify(merged)
|
||||||
|
if (raw !== next) await current.setItem(key, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!legacyStore) return null
|
||||||
|
|
||||||
|
for (const legacyKey of legacy) {
|
||||||
|
const legacyRaw = await legacyStore.getItem(legacyKey)
|
||||||
|
if (legacyRaw === null) continue
|
||||||
|
|
||||||
|
await current.setItem(key, legacyRaw)
|
||||||
|
await legacyStore.removeItem(legacyKey)
|
||||||
|
|
||||||
|
const parsed = parse(legacyRaw)
|
||||||
|
if (parsed === undefined) return legacyRaw
|
||||||
|
|
||||||
|
const migrated = config.migrate ? config.migrate(parsed) : parsed
|
||||||
|
const merged = merge(defaults, migrated)
|
||||||
|
const next = JSON.stringify(merged)
|
||||||
|
if (legacyRaw !== next) await current.setItem(key, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
setItem: async (key, value) => {
|
||||||
|
await current.setItem(key, value)
|
||||||
|
},
|
||||||
|
removeItem: async (key) => {
|
||||||
|
await current.removeItem(key)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return api
|
||||||
|
})()
|
||||||
|
|
||||||
|
const [state, setState, init] = makePersisted(store, { name: config.key, storage })
|
||||||
|
|
||||||
// Create a resource that resolves when the store is initialized
|
|
||||||
// This integrates with Suspense and provides a ready signal
|
|
||||||
const isAsync = init instanceof Promise
|
const isAsync = init instanceof Promise
|
||||||
const [ready] = createResource(
|
const [ready] = createResource(
|
||||||
() => init,
|
() => init,
|
||||||
|
|||||||
@@ -1,47 +1,202 @@
|
|||||||
import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
|
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
|
||||||
import type { Prompt, FileAttachmentPart } from "@/context/prompt"
|
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||||
|
|
||||||
|
type Inline =
|
||||||
|
| {
|
||||||
|
type: "file"
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
value: string
|
||||||
|
path: string
|
||||||
|
selection?: {
|
||||||
|
startLine: number
|
||||||
|
endLine: number
|
||||||
|
startChar: number
|
||||||
|
endChar: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "agent"
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
value: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
|
||||||
|
const queryIndex = url.indexOf("?")
|
||||||
|
if (queryIndex === -1) return undefined
|
||||||
|
const params = new URLSearchParams(url.slice(queryIndex + 1))
|
||||||
|
const startLine = Number(params.get("start"))
|
||||||
|
const endLine = Number(params.get("end"))
|
||||||
|
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
|
||||||
|
return {
|
||||||
|
startLine,
|
||||||
|
endLine,
|
||||||
|
startChar: 0,
|
||||||
|
endChar: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textPartValue(parts: Part[]) {
|
||||||
|
const candidates = parts
|
||||||
|
.filter((part): part is TextPart => part.type === "text")
|
||||||
|
.filter((part) => !part.synthetic && !part.ignored)
|
||||||
|
return candidates.reduce((best: TextPart | undefined, part) => {
|
||||||
|
if (!best) return part
|
||||||
|
if (part.text.length > best.text.length) return part
|
||||||
|
return best
|
||||||
|
}, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract prompt content from message parts for restoring into the prompt input.
|
* Extract prompt content from message parts for restoring into the prompt input.
|
||||||
* This is used by undo to restore the original user prompt.
|
* This is used by undo to restore the original user prompt.
|
||||||
*/
|
*/
|
||||||
export function extractPromptFromParts(parts: Part[]): Prompt {
|
export function extractPromptFromParts(parts: Part[], opts?: { directory?: string }): Prompt {
|
||||||
const result: Prompt = []
|
const textPart = textPartValue(parts)
|
||||||
let position = 0
|
const text = textPart?.text ?? ""
|
||||||
|
const directory = opts?.directory
|
||||||
|
|
||||||
|
const toRelative = (path: string) => {
|
||||||
|
if (!directory) return path
|
||||||
|
|
||||||
|
const prefix = directory.endsWith("/") ? directory : directory + "/"
|
||||||
|
if (path.startsWith(prefix)) return path.slice(prefix.length)
|
||||||
|
|
||||||
|
if (path.startsWith(directory)) {
|
||||||
|
const next = path.slice(directory.length)
|
||||||
|
if (next.startsWith("/")) return next.slice(1)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
const inline: Inline[] = []
|
||||||
|
const images: ImageAttachmentPart[] = []
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.type === "text") {
|
if (part.type === "file") {
|
||||||
const textPart = part as TextPart
|
|
||||||
if (!textPart.synthetic && textPart.text) {
|
|
||||||
result.push({
|
|
||||||
type: "text",
|
|
||||||
content: textPart.text,
|
|
||||||
start: position,
|
|
||||||
end: position + textPart.text.length,
|
|
||||||
})
|
|
||||||
position += textPart.text.length
|
|
||||||
}
|
|
||||||
} else if (part.type === "file") {
|
|
||||||
const filePart = part as FilePart
|
const filePart = part as FilePart
|
||||||
if (filePart.source?.type === "file") {
|
const sourceText = filePart.source?.text
|
||||||
const path = filePart.source.path
|
if (sourceText) {
|
||||||
const content = "@" + path
|
const value = sourceText.value
|
||||||
const attachment: FileAttachmentPart = {
|
const start = sourceText.start
|
||||||
type: "file",
|
const end = sourceText.end
|
||||||
path,
|
let path = value
|
||||||
content,
|
if (value.startsWith("@")) path = value.slice(1)
|
||||||
start: position,
|
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
|
||||||
end: position + content.length,
|
path = filePart.source.path
|
||||||
}
|
}
|
||||||
result.push(attachment)
|
inline.push({
|
||||||
position += content.length
|
type: "file",
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
value,
|
||||||
|
path: toRelative(path),
|
||||||
|
selection: selectionFromFileUrl(filePart.url),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePart.url.startsWith("data:")) {
|
||||||
|
images.push({
|
||||||
|
type: "image",
|
||||||
|
id: filePart.id,
|
||||||
|
filename: filePart.filename ?? "attachment",
|
||||||
|
mime: filePart.mime,
|
||||||
|
dataUrl: filePart.url,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (part.type === "agent") {
|
||||||
|
const agentPart = part as MessageAgentPart
|
||||||
|
const source = agentPart.source
|
||||||
|
if (!source) continue
|
||||||
|
inline.push({
|
||||||
|
type: "agent",
|
||||||
|
start: source.start,
|
||||||
|
end: source.end,
|
||||||
|
value: source.value,
|
||||||
|
name: agentPart.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline.sort((a, b) => {
|
||||||
|
if (a.start !== b.start) return a.start - b.start
|
||||||
|
return a.end - b.end
|
||||||
|
})
|
||||||
|
|
||||||
|
const result: Prompt = []
|
||||||
|
let position = 0
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
const pushText = (content: string) => {
|
||||||
|
if (!content) return
|
||||||
|
result.push({
|
||||||
|
type: "text",
|
||||||
|
content,
|
||||||
|
start: position,
|
||||||
|
end: position + content.length,
|
||||||
|
})
|
||||||
|
position += content.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
|
||||||
|
const content = item.value
|
||||||
|
const attachment: FileAttachmentPart = {
|
||||||
|
type: "file",
|
||||||
|
path: item.path,
|
||||||
|
content,
|
||||||
|
start: position,
|
||||||
|
end: position + content.length,
|
||||||
|
selection: item.selection,
|
||||||
|
}
|
||||||
|
result.push(attachment)
|
||||||
|
position += content.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
|
||||||
|
const content = item.value
|
||||||
|
const mention: AgentPart = {
|
||||||
|
type: "agent",
|
||||||
|
name: item.name,
|
||||||
|
content,
|
||||||
|
start: position,
|
||||||
|
end: position + content.length,
|
||||||
|
}
|
||||||
|
result.push(mention)
|
||||||
|
position += content.length
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of inline) {
|
||||||
|
if (item.start < 0 || item.end < item.start) continue
|
||||||
|
|
||||||
|
const expected = item.value
|
||||||
|
if (!expected) continue
|
||||||
|
|
||||||
|
const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
|
||||||
|
const start = mismatch ? text.indexOf(expected, cursor) : item.start
|
||||||
|
if (start === -1) continue
|
||||||
|
const end = mismatch ? start + expected.length : item.end
|
||||||
|
|
||||||
|
pushText(text.slice(cursor, start))
|
||||||
|
|
||||||
|
if (item.type === "file") pushFile(item)
|
||||||
|
if (item.type === "agent") pushAgent(item)
|
||||||
|
|
||||||
|
cursor = end
|
||||||
|
}
|
||||||
|
|
||||||
|
pushText(text.slice(cursor))
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
result.push({ type: "text", content: "", start: 0, end: 0 })
|
result.push({ type: "text", content: "", start: 0, end: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
if (images.length === 0) return result
|
||||||
|
return [...result, ...images]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export 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])
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const config = {
|
|||||||
github: {
|
github: {
|
||||||
repoUrl: "https://github.com/anomalyco/opencode",
|
repoUrl: "https://github.com/anomalyco/opencode",
|
||||||
starsFormatted: {
|
starsFormatted: {
|
||||||
compact: "45K",
|
compact: "50K",
|
||||||
full: "45,000",
|
full: "50,000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
|
|||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
|
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
@@ -146,6 +147,242 @@ export async function POST(input: APIEvent) {
|
|||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
|
||||||
|
const invoiceID = body.data.object.id as string
|
||||||
|
const amountInCents = body.data.object.amount_paid
|
||||||
|
const customerID = body.data.object.customer as string
|
||||||
|
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
|
||||||
|
|
||||||
|
if (!customerID) throw new Error("Customer ID not found")
|
||||||
|
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||||
|
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||||
|
|
||||||
|
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||||
|
expand: ["payments"],
|
||||||
|
})
|
||||||
|
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||||
|
if (!paymentID) throw new Error("Payment ID not found")
|
||||||
|
|
||||||
|
const workspaceID = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ workspaceID: BillingTable.workspaceID })
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
|
.then((rows) => rows[0]?.workspaceID),
|
||||||
|
)
|
||||||
|
if (!workspaceID) throw new Error("Workspace ID not found for customer")
|
||||||
|
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx.insert(PaymentTable).values({
|
||||||
|
workspaceID,
|
||||||
|
id: Identifier.create("payment"),
|
||||||
|
amount: centsToMicroCents(amountInCents),
|
||||||
|
paymentID,
|
||||||
|
invoiceID,
|
||||||
|
customerID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (body.type === "customer.subscription.created") {
|
||||||
|
const data = {
|
||||||
|
id: "evt_1Smq802SrMQ2Fneksse5FMNV",
|
||||||
|
object: "event",
|
||||||
|
api_version: "2025-07-30.basil",
|
||||||
|
created: 1767766916,
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||||
|
object: "subscription",
|
||||||
|
application: null,
|
||||||
|
application_fee_percent: null,
|
||||||
|
automatic_tax: {
|
||||||
|
disabled_reason: null,
|
||||||
|
enabled: false,
|
||||||
|
liability: null,
|
||||||
|
},
|
||||||
|
billing_cycle_anchor: 1770445200,
|
||||||
|
billing_cycle_anchor_config: null,
|
||||||
|
billing_mode: {
|
||||||
|
flexible: {
|
||||||
|
proration_discounts: "included",
|
||||||
|
},
|
||||||
|
type: "flexible",
|
||||||
|
updated_at: 1770445200,
|
||||||
|
},
|
||||||
|
billing_thresholds: null,
|
||||||
|
cancel_at: null,
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
canceled_at: null,
|
||||||
|
cancellation_details: {
|
||||||
|
comment: null,
|
||||||
|
feedback: null,
|
||||||
|
reason: null,
|
||||||
|
},
|
||||||
|
collection_method: "charge_automatically",
|
||||||
|
created: 1770445200,
|
||||||
|
currency: "usd",
|
||||||
|
customer: "cus_TkKmZZvysJ2wej",
|
||||||
|
customer_account: null,
|
||||||
|
days_until_due: null,
|
||||||
|
default_payment_method: null,
|
||||||
|
default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
|
||||||
|
default_tax_rates: [],
|
||||||
|
description: null,
|
||||||
|
discounts: [],
|
||||||
|
ended_at: null,
|
||||||
|
invoice_settings: {
|
||||||
|
account_tax_ids: null,
|
||||||
|
issuer: {
|
||||||
|
type: "self",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
object: "list",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "si_TkKnBKXFX76t0O",
|
||||||
|
object: "subscription_item",
|
||||||
|
billing_thresholds: null,
|
||||||
|
created: 1770445200,
|
||||||
|
current_period_end: 1772864400,
|
||||||
|
current_period_start: 1770445200,
|
||||||
|
discounts: [],
|
||||||
|
metadata: {},
|
||||||
|
plan: {
|
||||||
|
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||||
|
object: "plan",
|
||||||
|
active: true,
|
||||||
|
amount: 20000,
|
||||||
|
amount_decimal: "20000",
|
||||||
|
billing_scheme: "per_unit",
|
||||||
|
created: 1767725082,
|
||||||
|
currency: "usd",
|
||||||
|
interval: "month",
|
||||||
|
interval_count: 1,
|
||||||
|
livemode: false,
|
||||||
|
metadata: {},
|
||||||
|
meter: null,
|
||||||
|
nickname: null,
|
||||||
|
product: "prod_Tk9LjWT1n0DgYm",
|
||||||
|
tiers_mode: null,
|
||||||
|
transform_usage: null,
|
||||||
|
trial_period_days: null,
|
||||||
|
usage_type: "licensed",
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||||
|
object: "price",
|
||||||
|
active: true,
|
||||||
|
billing_scheme: "per_unit",
|
||||||
|
created: 1767725082,
|
||||||
|
currency: "usd",
|
||||||
|
custom_unit_amount: null,
|
||||||
|
livemode: false,
|
||||||
|
lookup_key: null,
|
||||||
|
metadata: {},
|
||||||
|
nickname: null,
|
||||||
|
product: "prod_Tk9LjWT1n0DgYm",
|
||||||
|
recurring: {
|
||||||
|
interval: "month",
|
||||||
|
interval_count: 1,
|
||||||
|
meter: null,
|
||||||
|
trial_period_days: null,
|
||||||
|
usage_type: "licensed",
|
||||||
|
},
|
||||||
|
tax_behavior: "unspecified",
|
||||||
|
tiers_mode: null,
|
||||||
|
transform_quantity: null,
|
||||||
|
type: "recurring",
|
||||||
|
unit_amount: 20000,
|
||||||
|
unit_amount_decimal: "20000",
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||||
|
tax_rates: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_more: false,
|
||||||
|
total_count: 1,
|
||||||
|
url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
|
||||||
|
},
|
||||||
|
latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
|
||||||
|
livemode: false,
|
||||||
|
metadata: {},
|
||||||
|
next_pending_invoice_item_invoice: null,
|
||||||
|
on_behalf_of: null,
|
||||||
|
pause_collection: null,
|
||||||
|
payment_settings: {
|
||||||
|
payment_method_options: null,
|
||||||
|
payment_method_types: null,
|
||||||
|
save_default_payment_method: "off",
|
||||||
|
},
|
||||||
|
pending_invoice_item_interval: null,
|
||||||
|
pending_setup_intent: null,
|
||||||
|
pending_update: null,
|
||||||
|
plan: {
|
||||||
|
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
|
||||||
|
object: "plan",
|
||||||
|
active: true,
|
||||||
|
amount: 20000,
|
||||||
|
amount_decimal: "20000",
|
||||||
|
billing_scheme: "per_unit",
|
||||||
|
created: 1767725082,
|
||||||
|
currency: "usd",
|
||||||
|
interval: "month",
|
||||||
|
interval_count: 1,
|
||||||
|
livemode: false,
|
||||||
|
metadata: {},
|
||||||
|
meter: null,
|
||||||
|
nickname: null,
|
||||||
|
product: "prod_Tk9LjWT1n0DgYm",
|
||||||
|
tiers_mode: null,
|
||||||
|
transform_usage: null,
|
||||||
|
trial_period_days: null,
|
||||||
|
usage_type: "licensed",
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
schedule: null,
|
||||||
|
start_date: 1770445200,
|
||||||
|
status: "active",
|
||||||
|
test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
|
||||||
|
transfer_data: null,
|
||||||
|
trial_end: null,
|
||||||
|
trial_settings: {
|
||||||
|
end_behavior: {
|
||||||
|
missing_payment_method: "create_invoice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trial_start: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
livemode: false,
|
||||||
|
pending_webhooks: 0,
|
||||||
|
request: {
|
||||||
|
id: "req_6YO9stvB155WJD",
|
||||||
|
idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
|
||||||
|
},
|
||||||
|
type: "customer.subscription.created",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.type === "customer.subscription.deleted") {
|
||||||
|
const subscriptionID = body.data.object.id
|
||||||
|
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||||
|
|
||||||
|
const workspaceID = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ workspaceID: BillingTable.workspaceID })
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.subscriptionID, subscriptionID))
|
||||||
|
.then((rows) => rows[0]?.workspaceID),
|
||||||
|
)
|
||||||
|
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||||
|
|
||||||
|
await Database.transaction(async (tx) => {
|
||||||
|
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
|
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
|
||||||
|
})
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
.then((message) => {
|
.then((message) => {
|
||||||
return Response.json({ message: message ?? "done" }, { status: 200 })
|
return Response.json({ message: message ?? "done" }, { status: 200 })
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.root {
|
||||||
|
[data-slot="title-row"] {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { action, useParams, useAction, useSubmission, json } from "@solidjs/router"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
|
import { withActor } from "~/context/auth.withActor"
|
||||||
|
import { queryBillingInfo } from "../../common"
|
||||||
|
import styles from "./black-section.module.css"
|
||||||
|
|
||||||
|
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||||
|
"use server"
|
||||||
|
return json(
|
||||||
|
await withActor(
|
||||||
|
() =>
|
||||||
|
Billing.generateSessionUrl({ returnUrl })
|
||||||
|
.then((data) => ({ error: undefined, data }))
|
||||||
|
.catch((e) => ({
|
||||||
|
error: e.message as string,
|
||||||
|
data: undefined,
|
||||||
|
})),
|
||||||
|
workspaceID,
|
||||||
|
),
|
||||||
|
{ revalidate: queryBillingInfo.key },
|
||||||
|
)
|
||||||
|
}, "sessionUrl")
|
||||||
|
|
||||||
|
export function BlackSection() {
|
||||||
|
const params = useParams()
|
||||||
|
const sessionAction = useAction(createSessionUrl)
|
||||||
|
const sessionSubmission = useSubmission(createSessionUrl)
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
sessionRedirecting: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onClickSession() {
|
||||||
|
const result = await sessionAction(params.id!, window.location.href)
|
||||||
|
if (result.data) {
|
||||||
|
setStore("sessionRedirecting", true)
|
||||||
|
window.location.href = result.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class={styles.root}>
|
||||||
|
<div data-slot="section-title">
|
||||||
|
<h2>Subscription</h2>
|
||||||
|
<div data-slot="title-row">
|
||||||
|
<p>You are subscribed to OpenCode Black for $200 per month.</p>
|
||||||
|
<button
|
||||||
|
data-color="primary"
|
||||||
|
disabled={sessionSubmission.pending || store.sessionRedirecting}
|
||||||
|
onClick={onClickSession}
|
||||||
|
>
|
||||||
|
{sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage Subscription"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,19 +2,23 @@ import { MonthlyLimitSection } from "./monthly-limit-section"
|
|||||||
import { BillingSection } from "./billing-section"
|
import { BillingSection } from "./billing-section"
|
||||||
import { ReloadSection } from "./reload-section"
|
import { ReloadSection } from "./reload-section"
|
||||||
import { PaymentSection } from "./payment-section"
|
import { PaymentSection } from "./payment-section"
|
||||||
|
import { BlackSection } from "./black-section"
|
||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import { createAsync, useParams } from "@solidjs/router"
|
import { createAsync, useParams } from "@solidjs/router"
|
||||||
import { queryBillingInfo, querySessionInfo } from "../../common"
|
import { queryBillingInfo, querySessionInfo } from "../../common"
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const userInfo = createAsync(() => querySessionInfo(params.id!))
|
const sessionInfo = createAsync(() => querySessionInfo(params.id!))
|
||||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-page="workspace-[id]">
|
<div data-page="workspace-[id]">
|
||||||
<div data-slot="sections">
|
<div data-slot="sections">
|
||||||
<Show when={userInfo()?.isAdmin}>
|
<Show when={sessionInfo()?.isAdmin}>
|
||||||
|
<Show when={billingInfo()?.subscriptionID}>
|
||||||
|
<BlackSection />
|
||||||
|
</Show>
|
||||||
<BillingSection />
|
<BillingSection />
|
||||||
<Show when={billingInfo()?.customerID}>
|
<Show when={billingInfo()?.customerID}>
|
||||||
<ReloadSection />
|
<ReloadSection />
|
||||||
|
|||||||
@@ -45,6 +45,19 @@
|
|||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-slot="payment-receipt"] {
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr {
|
tbody tr {
|
||||||
@@ -61,13 +74,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
&:nth-child(2) /* Payment ID */ {
|
&:nth-child(2)
|
||||||
|
|
||||||
|
/* Payment ID */ {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
&:nth-child(2) /* Payment ID */ {
|
&:nth-child(2)
|
||||||
|
|
||||||
|
/* Payment ID */ {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export function PaymentSection() {
|
|||||||
<For each={payments()!}>
|
<For each={payments()!}>
|
||||||
{(payment) => {
|
{(payment) => {
|
||||||
const date = new Date(payment.timeCreated)
|
const date = new Date(payment.timeCreated)
|
||||||
|
const isCredit = !payment.paymentID
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
||||||
@@ -85,19 +86,24 @@ export function PaymentSection() {
|
|||||||
<td data-slot="payment-id">{payment.id}</td>
|
<td data-slot="payment-id">{payment.id}</td>
|
||||||
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
||||||
${((payment.amount ?? 0) / 100000000).toFixed(2)}
|
${((payment.amount ?? 0) / 100000000).toFixed(2)}
|
||||||
|
{isCredit ? " (credit)" : ""}
|
||||||
</td>
|
</td>
|
||||||
<td data-slot="payment-receipt">
|
<td data-slot="payment-receipt">
|
||||||
<button
|
{isCredit ? (
|
||||||
onClick={async () => {
|
<span>-</span>
|
||||||
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
|
) : (
|
||||||
if (receiptUrl) {
|
<button
|
||||||
window.open(receiptUrl, "_blank")
|
onClick={async () => {
|
||||||
}
|
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
|
||||||
}}
|
if (receiptUrl) {
|
||||||
data-slot="receipt-button"
|
window.open(receiptUrl, "_blank")
|
||||||
>
|
}
|
||||||
View
|
}}
|
||||||
</button>
|
data-slot="receipt-button"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -169,7 +169,9 @@ export function UsageSection() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
|
<td data-slot="usage-cost">
|
||||||
|
${usage.enrichment?.plan === "sub" ? "0.0000" : ((usage.cost ?? 0) / 100000000).toFixed(4)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
export class AuthError extends Error {}
|
export class AuthError extends Error {}
|
||||||
export class CreditsError extends Error {}
|
export class CreditsError extends Error {}
|
||||||
export class MonthlyLimitError extends Error {}
|
export class MonthlyLimitError extends Error {}
|
||||||
|
export class SubscriptionError extends Error {
|
||||||
|
retryAfter?: number
|
||||||
|
constructor(message: string, retryAfter?: number) {
|
||||||
|
super(message)
|
||||||
|
this.retryAfter = retryAfter
|
||||||
|
}
|
||||||
|
}
|
||||||
export class UserLimitError extends Error {}
|
export class UserLimitError extends Error {}
|
||||||
export class ModelError extends Error {}
|
export class ModelError extends Error {}
|
||||||
export class RateLimitError extends Error {}
|
export class RateLimitError extends Error {}
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
|
|||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||||
|
import { BlackData } from "@opencode-ai/console-core/black.js"
|
||||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||||
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
||||||
import { logger } from "./logger"
|
import { logger } from "./logger"
|
||||||
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
|
import {
|
||||||
|
AuthError,
|
||||||
|
CreditsError,
|
||||||
|
MonthlyLimitError,
|
||||||
|
SubscriptionError,
|
||||||
|
UserLimitError,
|
||||||
|
ModelError,
|
||||||
|
RateLimitError,
|
||||||
|
} from "./error"
|
||||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
||||||
import { anthropicHelper } from "./provider/anthropic"
|
import { anthropicHelper } from "./provider/anthropic"
|
||||||
import { googleHelper } from "./provider/google"
|
import { googleHelper } from "./provider/google"
|
||||||
@@ -69,13 +78,13 @@ export async function handler(
|
|||||||
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
||||||
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
|
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
|
||||||
const isTrial = await trialLimiter?.isTrial()
|
const isTrial = await trialLimiter?.isTrial()
|
||||||
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
|
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip)
|
||||||
await rateLimiter?.check()
|
await rateLimiter?.check()
|
||||||
const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId)
|
const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId)
|
||||||
const stickyProvider = await stickyTracker?.get()
|
const stickyProvider = await stickyTracker?.get()
|
||||||
|
const authInfo = await authenticate(modelInfo)
|
||||||
|
|
||||||
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
||||||
const authInfo = await authenticate(modelInfo)
|
|
||||||
const providerInfo = selectProvider(
|
const providerInfo = selectProvider(
|
||||||
zenData,
|
zenData,
|
||||||
authInfo,
|
authInfo,
|
||||||
@@ -135,10 +144,10 @@ export async function handler(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { providerInfo, authInfo, reqBody, res, startTimestamp }
|
return { providerInfo, reqBody, res, startTimestamp }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { providerInfo, authInfo, reqBody, res, startTimestamp } = await retriableRequest()
|
const { providerInfo, reqBody, res, startTimestamp } = await retriableRequest()
|
||||||
|
|
||||||
// Store model request
|
// Store model request
|
||||||
dataDumper?.provideModel(providerInfo.storeModel)
|
dataDumper?.provideModel(providerInfo.storeModel)
|
||||||
@@ -172,8 +181,8 @@ export async function handler(
|
|||||||
const tokensInfo = providerInfo.normalizeUsage(json.usage)
|
const tokensInfo = providerInfo.normalizeUsage(json.usage)
|
||||||
await trialLimiter?.track(tokensInfo)
|
await trialLimiter?.track(tokensInfo)
|
||||||
await rateLimiter?.track()
|
await rateLimiter?.track()
|
||||||
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||||
await reload(authInfo)
|
await reload(authInfo, costInfo)
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: resStatus,
|
status: resStatus,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
@@ -206,8 +215,8 @@ export async function handler(
|
|||||||
if (usage) {
|
if (usage) {
|
||||||
const tokensInfo = providerInfo.normalizeUsage(usage)
|
const tokensInfo = providerInfo.normalizeUsage(usage)
|
||||||
await trialLimiter?.track(tokensInfo)
|
await trialLimiter?.track(tokensInfo)
|
||||||
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||||
await reload(authInfo)
|
await reload(authInfo, costInfo)
|
||||||
}
|
}
|
||||||
c.close()
|
c.close()
|
||||||
return
|
return
|
||||||
@@ -279,14 +288,19 @@ export async function handler(
|
|||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (error instanceof RateLimitError)
|
if (error instanceof RateLimitError || error instanceof SubscriptionError) {
|
||||||
|
const headers = new Headers()
|
||||||
|
if (error instanceof SubscriptionError && error.retryAfter) {
|
||||||
|
headers.set("retry-after", String(error.retryAfter))
|
||||||
|
}
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "error",
|
type: "error",
|
||||||
error: { type: error.constructor.name, message: error.message },
|
error: { type: error.constructor.name, message: error.message },
|
||||||
}),
|
}),
|
||||||
{ status: 429 },
|
{ status: 429, headers },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -392,6 +406,7 @@ export async function handler(
|
|||||||
monthlyUsage: BillingTable.monthlyUsage,
|
monthlyUsage: BillingTable.monthlyUsage,
|
||||||
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
||||||
reloadTrigger: BillingTable.reloadTrigger,
|
reloadTrigger: BillingTable.reloadTrigger,
|
||||||
|
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
id: UserTable.id,
|
id: UserTable.id,
|
||||||
@@ -399,6 +414,13 @@ export async function handler(
|
|||||||
monthlyUsage: UserTable.monthlyUsage,
|
monthlyUsage: UserTable.monthlyUsage,
|
||||||
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
||||||
},
|
},
|
||||||
|
subscription: {
|
||||||
|
timeSubscribed: UserTable.timeSubscribed,
|
||||||
|
subIntervalUsage: UserTable.subIntervalUsage,
|
||||||
|
subMonthlyUsage: UserTable.subMonthlyUsage,
|
||||||
|
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
|
||||||
|
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
|
||||||
|
},
|
||||||
provider: {
|
provider: {
|
||||||
credentials: ProviderTable.credentials,
|
credentials: ProviderTable.credentials,
|
||||||
},
|
},
|
||||||
@@ -426,6 +448,7 @@ export async function handler(
|
|||||||
logger.metric({
|
logger.metric({
|
||||||
api_key: data.apiKey,
|
api_key: data.apiKey,
|
||||||
workspace: data.workspaceID,
|
workspace: data.workspaceID,
|
||||||
|
isSubscription: data.subscription.timeSubscribed ? true : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -433,6 +456,7 @@ export async function handler(
|
|||||||
workspaceID: data.workspaceID,
|
workspaceID: data.workspaceID,
|
||||||
billing: data.billing,
|
billing: data.billing,
|
||||||
user: data.user,
|
user: data.user,
|
||||||
|
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
|
||||||
provider: data.provider,
|
provider: data.provider,
|
||||||
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
||||||
isDisabled: !!data.timeDisabled,
|
isDisabled: !!data.timeDisabled,
|
||||||
@@ -445,6 +469,64 @@ export async function handler(
|
|||||||
if (authInfo.isFree) return
|
if (authInfo.isFree) return
|
||||||
if (modelInfo.allowAnonymous) return
|
if (modelInfo.allowAnonymous) return
|
||||||
|
|
||||||
|
// Validate subscription billing
|
||||||
|
if (authInfo.subscription) {
|
||||||
|
const black = BlackData.get()
|
||||||
|
const sub = authInfo.subscription
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const formatRetryTime = (seconds: number) => {
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.ceil((seconds % 3600) / 60)
|
||||||
|
if (hours >= 1) return `${hours}hr ${minutes}min`
|
||||||
|
return `${minutes}min`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check monthly limit (based on subscription billing cycle)
|
||||||
|
if (
|
||||||
|
sub.subMonthlyUsage &&
|
||||||
|
sub.timeSubMonthlyUsageUpdated &&
|
||||||
|
sub.subMonthlyUsage >= centsToMicroCents(black.monthlyLimit * 100)
|
||||||
|
) {
|
||||||
|
const subscribeDay = sub.timeSubscribed!.getUTCDate()
|
||||||
|
const cycleStart = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
|
||||||
|
subscribeDay,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const cycleEnd = new Date(Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay))
|
||||||
|
if (sub.timeSubMonthlyUsageUpdated >= cycleStart && sub.timeSubMonthlyUsageUpdated < cycleEnd) {
|
||||||
|
const retryAfter = Math.ceil((cycleEnd.getTime() - now.getTime()) / 1000)
|
||||||
|
throw new SubscriptionError(
|
||||||
|
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||||
|
retryAfter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check interval limit
|
||||||
|
const intervalMs = black.intervalLength * 3600 * 1000
|
||||||
|
if (sub.subIntervalUsage && sub.timeSubIntervalUsageUpdated) {
|
||||||
|
const currentInterval = Math.floor(now.getTime() / intervalMs)
|
||||||
|
const usageInterval = Math.floor(sub.timeSubIntervalUsageUpdated.getTime() / intervalMs)
|
||||||
|
if (currentInterval === usageInterval && sub.subIntervalUsage >= centsToMicroCents(black.intervalLimit * 100)) {
|
||||||
|
const nextInterval = (currentInterval + 1) * intervalMs
|
||||||
|
const retryAfter = Math.ceil((nextInterval - now.getTime()) / 1000)
|
||||||
|
throw new SubscriptionError(
|
||||||
|
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||||
|
retryAfter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate pay as you go billing
|
||||||
const billing = authInfo.billing
|
const billing = authInfo.billing
|
||||||
if (!billing.paymentMethodID)
|
if (!billing.paymentMethodID)
|
||||||
throw new CreditsError(
|
throw new CreditsError(
|
||||||
@@ -462,29 +544,25 @@ export async function handler(
|
|||||||
billing.monthlyLimit &&
|
billing.monthlyLimit &&
|
||||||
billing.monthlyUsage &&
|
billing.monthlyUsage &&
|
||||||
billing.timeMonthlyUsageUpdated &&
|
billing.timeMonthlyUsageUpdated &&
|
||||||
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
|
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) &&
|
||||||
) {
|
currentYear === billing.timeMonthlyUsageUpdated.getUTCFullYear() &&
|
||||||
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
|
currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
|
||||||
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
|
)
|
||||||
if (currentYear === dateYear && currentMonth === dateMonth)
|
throw new MonthlyLimitError(
|
||||||
throw new MonthlyLimitError(
|
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
||||||
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
authInfo.user.monthlyLimit &&
|
authInfo.user.monthlyLimit &&
|
||||||
authInfo.user.monthlyUsage &&
|
authInfo.user.monthlyUsage &&
|
||||||
authInfo.user.timeMonthlyUsageUpdated &&
|
authInfo.user.timeMonthlyUsageUpdated &&
|
||||||
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100)
|
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100) &&
|
||||||
) {
|
currentYear === authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() &&
|
||||||
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear()
|
currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
|
||||||
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
|
)
|
||||||
if (currentYear === dateYear && currentMonth === dateMonth)
|
throw new UserLimitError(
|
||||||
throw new UserLimitError(
|
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
|
||||||
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateModelSettings(authInfo: AuthInfo) {
|
function validateModelSettings(authInfo: AuthInfo) {
|
||||||
@@ -559,61 +637,110 @@ export async function handler(
|
|||||||
|
|
||||||
if (!authInfo) return
|
if (!authInfo) return
|
||||||
|
|
||||||
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
||||||
await Database.transaction(async (tx) => {
|
await Database.use((db) =>
|
||||||
await tx.insert(UsageTable).values({
|
Promise.all([
|
||||||
workspaceID: authInfo.workspaceID,
|
db.insert(UsageTable).values({
|
||||||
id: Identifier.create("usage"),
|
workspaceID: authInfo.workspaceID,
|
||||||
model: modelInfo.id,
|
id: Identifier.create("usage"),
|
||||||
provider: providerInfo.id,
|
model: modelInfo.id,
|
||||||
inputTokens,
|
provider: providerInfo.id,
|
||||||
outputTokens,
|
inputTokens,
|
||||||
reasoningTokens,
|
outputTokens,
|
||||||
cacheReadTokens,
|
reasoningTokens,
|
||||||
cacheWrite5mTokens,
|
cacheReadTokens,
|
||||||
cacheWrite1hTokens,
|
cacheWrite5mTokens,
|
||||||
cost,
|
cacheWrite1hTokens,
|
||||||
keyID: authInfo.apiKeyId,
|
cost,
|
||||||
})
|
keyID: authInfo.apiKeyId,
|
||||||
await tx
|
enrichment: authInfo.subscription ? { plan: "sub" } : undefined,
|
||||||
.update(BillingTable)
|
}),
|
||||||
.set({
|
db
|
||||||
balance: sql`${BillingTable.balance} - ${cost}`,
|
.update(KeyTable)
|
||||||
monthlyUsage: sql`
|
.set({ timeUsed: sql`now()` })
|
||||||
|
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||||
|
...(authInfo.subscription
|
||||||
|
? (() => {
|
||||||
|
const now = new Date()
|
||||||
|
const subscribeDay = authInfo.subscription.timeSubscribed!.getUTCDate()
|
||||||
|
const cycleStart = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
|
||||||
|
subscribeDay,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const cycleEnd = new Date(
|
||||||
|
Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
db
|
||||||
|
.update(UserTable)
|
||||||
|
.set({
|
||||||
|
subMonthlyUsage: sql`
|
||||||
|
CASE
|
||||||
|
WHEN ${UserTable.timeSubMonthlyUsageUpdated} >= ${cycleStart} AND ${UserTable.timeSubMonthlyUsageUpdated} < ${cycleEnd} THEN ${UserTable.subMonthlyUsage} + ${cost}
|
||||||
|
ELSE ${cost}
|
||||||
|
END
|
||||||
|
`,
|
||||||
|
timeSubMonthlyUsageUpdated: sql`now()`,
|
||||||
|
subIntervalUsage: sql`
|
||||||
|
CASE
|
||||||
|
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 3600)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 3600)) THEN ${UserTable.subIntervalUsage} + ${cost}
|
||||||
|
ELSE ${cost}
|
||||||
|
END
|
||||||
|
`,
|
||||||
|
timeSubIntervalUsageUpdated: sql`now()`,
|
||||||
|
})
|
||||||
|
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||||
|
]
|
||||||
|
})()
|
||||||
|
: [
|
||||||
|
db
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
balance: authInfo.isFree
|
||||||
|
? sql`${BillingTable.balance} - ${0}`
|
||||||
|
: sql`${BillingTable.balance} - ${cost}`,
|
||||||
|
monthlyUsage: sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
||||||
ELSE ${cost}
|
ELSE ${cost}
|
||||||
END
|
END
|
||||||
`,
|
`,
|
||||||
timeMonthlyUsageUpdated: sql`now()`,
|
timeMonthlyUsageUpdated: sql`now()`,
|
||||||
})
|
})
|
||||||
.where(eq(BillingTable.workspaceID, authInfo.workspaceID))
|
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
|
||||||
await tx
|
db
|
||||||
.update(UserTable)
|
.update(UserTable)
|
||||||
.set({
|
.set({
|
||||||
monthlyUsage: sql`
|
monthlyUsage: sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
|
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
|
||||||
ELSE ${cost}
|
ELSE ${cost}
|
||||||
END
|
END
|
||||||
`,
|
`,
|
||||||
timeMonthlyUsageUpdated: sql`now()`,
|
timeMonthlyUsageUpdated: sql`now()`,
|
||||||
})
|
})
|
||||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
|
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||||
})
|
]),
|
||||||
|
]),
|
||||||
await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.update(KeyTable)
|
|
||||||
.set({ timeUsed: sql`now()` })
|
|
||||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return { costInMicroCents: cost }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reload(authInfo: AuthInfo) {
|
async function reload(authInfo: AuthInfo, costInfo: Awaited<ReturnType<typeof trackUsage>>) {
|
||||||
if (!authInfo) return
|
if (!authInfo) return
|
||||||
if (authInfo.isFree) return
|
if (authInfo.isFree) return
|
||||||
if (authInfo.provider?.credentials) return
|
if (authInfo.provider?.credentials) return
|
||||||
|
if (authInfo.subscription) return
|
||||||
|
|
||||||
|
if (!costInfo) return
|
||||||
|
|
||||||
|
const reloadTrigger = centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100)
|
||||||
|
if (authInfo.billing.balance - costInfo.costInMicroCents >= reloadTrigger) return
|
||||||
|
if (authInfo.billing.timeReloadLockedTill && authInfo.billing.timeReloadLockedTill > new Date()) return
|
||||||
|
|
||||||
const lock = await Database.use((tx) =>
|
const lock = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
@@ -625,10 +752,7 @@ export async function handler(
|
|||||||
and(
|
and(
|
||||||
eq(BillingTable.workspaceID, authInfo.workspaceID),
|
eq(BillingTable.workspaceID, authInfo.workspaceID),
|
||||||
eq(BillingTable.reload, true),
|
eq(BillingTable.reload, true),
|
||||||
lt(
|
lt(BillingTable.balance, reloadTrigger),
|
||||||
BillingTable.balance,
|
|
||||||
centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100),
|
|
||||||
),
|
|
||||||
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,28 +1,34 @@
|
|||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
|
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||||
import { RateLimitError } from "./error"
|
import { RateLimitError } from "./error"
|
||||||
import { logger } from "./logger"
|
import { logger } from "./logger"
|
||||||
|
|
||||||
export function createRateLimiter(model: string, limit: number | undefined, ip: string) {
|
export function createRateLimiter(limit: number | undefined, rawIp: string) {
|
||||||
if (!limit) return
|
if (!limit) return
|
||||||
|
|
||||||
|
const ip = !rawIp.length ? "unknown" : rawIp
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const currKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now)}`
|
const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
|
||||||
const prevKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now - 3_600_000)}`
|
|
||||||
let currRate: number
|
|
||||||
let prevRate: number
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
track: async () => {
|
track: async () => {
|
||||||
await Resource.GatewayKv.put(currKey, currRate + 1, { expirationTtl: 3600 })
|
await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.insert(IpRateLimitTable)
|
||||||
|
.values({ ip, interval: intervals[0], count: 1 })
|
||||||
|
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
check: async () => {
|
check: async () => {
|
||||||
const values = await Resource.GatewayKv.get([currKey, prevKey])
|
const rows = await Database.use((tx) =>
|
||||||
const prevValue = values?.get(prevKey)
|
tx
|
||||||
const currValue = values?.get(currKey)
|
.select({ count: IpRateLimitTable.count })
|
||||||
prevRate = prevValue ? parseInt(prevValue) : 0
|
.from(IpRateLimitTable)
|
||||||
currRate = currValue ? parseInt(currValue) : 0
|
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, intervals))),
|
||||||
logger.debug(`rate limit ${model} prev/curr: ${prevRate}/${currRate}`)
|
)
|
||||||
if (prevRate + currRate >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
|
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
||||||
|
logger.debug(`rate limit total: ${total}`)
|
||||||
|
if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE `ip_rate_limit` (
|
||||||
|
`ip` varchar(45) NOT NULL,
|
||||||
|
`interval` varchar(10) NOT NULL,
|
||||||
|
`count` int NOT NULL,
|
||||||
|
CONSTRAINT `ip_rate_limit_ip_interval_pk` PRIMARY KEY(`ip`,`interval`)
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE `billing` ADD `subscription_id` varchar(28);--> statement-breakpoint
|
||||||
|
ALTER TABLE `usage` ADD `data` json;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `time_subscribed` timestamp(3);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `sub_recent_usage` bigint;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `sub_monthly_usage` bigint;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `sub_time_recent_usage_updated` timestamp(3);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `sub_time_monthly_usage_updated` timestamp(3);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `user` RENAME COLUMN `sub_recent_usage` TO `sub_interval_usage`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` RENAME COLUMN `sub_time_recent_usage_updated` TO `sub_time_interval_usage_updated`;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `usage` RENAME COLUMN `data` TO `enrichment`;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `billing` ADD CONSTRAINT `global_subscription_id` UNIQUE(`subscription_id`);
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -288,6 +288,41 @@
|
|||||||
"when": 1767584617316,
|
"when": 1767584617316,
|
||||||
"tag": "0040_broken_gamora",
|
"tag": "0040_broken_gamora",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 41,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767732559197,
|
||||||
|
"tag": "0041_odd_misty_knight",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 42,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767744077346,
|
||||||
|
"tag": "0042_flat_nightmare",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 43,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767752636118,
|
||||||
|
"tag": "0043_lame_calypso",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 44,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767759322451,
|
||||||
|
"tag": "0044_tiny_captain_midlands",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 45,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767765497502,
|
||||||
|
"tag": "0045_cuddly_diamondback",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
"promote-models-to-dev": "script/promote-models.ts dev",
|
"promote-models-to-dev": "script/promote-models.ts dev",
|
||||||
"promote-models-to-prod": "script/promote-models.ts production",
|
"promote-models-to-prod": "script/promote-models.ts production",
|
||||||
"pull-models-from-dev": "script/pull-models.ts dev",
|
"pull-models-from-dev": "script/pull-models.ts dev",
|
||||||
|
"update-black": "script/update-black.ts",
|
||||||
|
"promote-black-to-dev": "script/promote-black.ts dev",
|
||||||
|
"promote-black-to-prod": "script/promote-black.ts production",
|
||||||
"typecheck": "tsgo --noEmit"
|
"typecheck": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Billing } from "../src/billing.js"
|
||||||
|
|
||||||
|
// get input from command line
|
||||||
|
const workspaceID = process.argv[2]
|
||||||
|
const dollarAmount = process.argv[3]
|
||||||
|
|
||||||
|
if (!workspaceID || !dollarAmount) {
|
||||||
|
console.error("Usage: bun credit-workspace.ts <workspaceID> <dollarAmount>")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountInDollars = parseFloat(dollarAmount)
|
||||||
|
if (isNaN(amountInDollars) || amountInDollars <= 0) {
|
||||||
|
console.error("Error: dollarAmount must be a positive number")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Billing.grantCredit(workspaceID, amountInDollars)
|
||||||
|
|
||||||
|
console.log(`Added payment of $${amountInDollars.toFixed(2)} to workspace ${workspaceID}`)
|
||||||
@@ -1,26 +1,145 @@
|
|||||||
import { Database, eq } from "../src/drizzle/index.js"
|
import { Database, eq, sql, inArray } from "../src/drizzle/index.js"
|
||||||
import { AuthTable } from "../src/schema/auth.sql"
|
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||||
|
import { UserTable } from "../src/schema/user.sql.js"
|
||||||
|
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
|
||||||
|
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||||
|
|
||||||
// get input from command line
|
// get input from command line
|
||||||
const email = process.argv[2]
|
const identifier = process.argv[2]
|
||||||
if (!email) {
|
if (!identifier) {
|
||||||
console.error("Usage: bun lookup-user.ts <email>")
|
console.error("Usage: bun lookup-user.ts <email|workspaceID>")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const authData = await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.subject, email)))
|
if (identifier.startsWith("wrk_")) {
|
||||||
if (authData.length === 0) {
|
await printWorkspace(identifier)
|
||||||
console.error("User not found")
|
} else {
|
||||||
process.exit(1)
|
const authData = await Database.use(async (tx) =>
|
||||||
|
tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)),
|
||||||
|
)
|
||||||
|
if (authData.length === 0) {
|
||||||
|
console.error("Email not found")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (authData.length > 1) console.warn("Multiple users found for email", identifier)
|
||||||
|
|
||||||
|
// Get all auth records for email
|
||||||
|
const accountID = authData[0].accountID
|
||||||
|
await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID)))
|
||||||
|
|
||||||
|
// Get all workspaces for this account
|
||||||
|
const users = await printTable("Workspaces", (tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
userID: UserTable.id,
|
||||||
|
workspaceID: UserTable.workspaceID,
|
||||||
|
workspaceName: WorkspaceTable.name,
|
||||||
|
role: UserTable.role,
|
||||||
|
})
|
||||||
|
.from(UserTable)
|
||||||
|
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
|
||||||
|
.where(eq(UserTable.accountID, accountID)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get all payments for these workspaces
|
||||||
|
await Promise.all(users.map((u: { workspaceID: string }) => printWorkspace(u.workspaceID)))
|
||||||
}
|
}
|
||||||
|
|
||||||
await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID)))
|
async function printWorkspace(workspaceID: string) {
|
||||||
|
const workspace = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select()
|
||||||
|
.from(WorkspaceTable)
|
||||||
|
.where(eq(WorkspaceTable.id, workspaceID))
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
)
|
||||||
|
|
||||||
function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise<any[]>): Promise<any[]> {
|
printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
|
||||||
|
|
||||||
|
await printTable("Billing", (tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
balance: BillingTable.balance,
|
||||||
|
customerID: BillingTable.customerID,
|
||||||
|
})
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.workspaceID, workspace.id))
|
||||||
|
.then(
|
||||||
|
(rows) =>
|
||||||
|
rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
balance: `$${(row.balance / 100000000).toFixed(2)}`,
|
||||||
|
}))[0],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await printTable("Payments", (tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
amount: PaymentTable.amount,
|
||||||
|
paymentID: PaymentTable.paymentID,
|
||||||
|
invoiceID: PaymentTable.invoiceID,
|
||||||
|
timeCreated: PaymentTable.timeCreated,
|
||||||
|
timeRefunded: PaymentTable.timeRefunded,
|
||||||
|
})
|
||||||
|
.from(PaymentTable)
|
||||||
|
.where(eq(PaymentTable.workspaceID, workspace.id))
|
||||||
|
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
|
||||||
|
.limit(100)
|
||||||
|
.then((rows) =>
|
||||||
|
rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
amount: `$${(row.amount / 100000000).toFixed(2)}`,
|
||||||
|
paymentID: row.paymentID
|
||||||
|
? `https://dashboard.stripe.com/acct_1RszBH2StuRr0lbX/payments/${row.paymentID}`
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await printTable("Usage", (tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
model: UsageTable.model,
|
||||||
|
provider: UsageTable.provider,
|
||||||
|
inputTokens: UsageTable.inputTokens,
|
||||||
|
outputTokens: UsageTable.outputTokens,
|
||||||
|
reasoningTokens: UsageTable.reasoningTokens,
|
||||||
|
cacheReadTokens: UsageTable.cacheReadTokens,
|
||||||
|
cacheWrite5mTokens: UsageTable.cacheWrite5mTokens,
|
||||||
|
cacheWrite1hTokens: UsageTable.cacheWrite1hTokens,
|
||||||
|
cost: UsageTable.cost,
|
||||||
|
timeCreated: UsageTable.timeCreated,
|
||||||
|
})
|
||||||
|
.from(UsageTable)
|
||||||
|
.where(eq(UsageTable.workspaceID, workspace.id))
|
||||||
|
.orderBy(sql`${UsageTable.timeCreated} DESC`)
|
||||||
|
.limit(10)
|
||||||
|
.then((rows) =>
|
||||||
|
rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
cost: `$${(row.cost / 100000000).toFixed(2)}`,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHeader(title: string) {
|
||||||
|
console.log()
|
||||||
|
console.log("─".repeat(title.length))
|
||||||
|
console.log(`${title}`)
|
||||||
|
console.log("─".repeat(title.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise<any>): Promise<any> {
|
||||||
return Database.use(async (tx) => {
|
return Database.use(async (tx) => {
|
||||||
const data = await callback(tx)
|
const data = await callback(tx)
|
||||||
console.log(`== ${title} ==`)
|
console.log(`\n== ${title} ==`)
|
||||||
console.table(data)
|
if (data.length === 0) {
|
||||||
|
console.log("(no data)")
|
||||||
|
} else {
|
||||||
|
console.table(data)
|
||||||
|
}
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Billing } from "../src/billing.js"
|
||||||
|
import { Database, eq, and, sql } from "../src/drizzle/index.js"
|
||||||
|
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||||
|
import { UserTable } from "../src/schema/user.sql.js"
|
||||||
|
import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js"
|
||||||
|
import { Identifier } from "../src/identifier.js"
|
||||||
|
import { centsToMicroCents } from "../src/util/price.js"
|
||||||
|
|
||||||
|
const workspaceID = process.argv[2]
|
||||||
|
const email = process.argv[3]
|
||||||
|
|
||||||
|
if (!workspaceID || !email) {
|
||||||
|
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the Stripe customer by email
|
||||||
|
const customers = await Billing.stripe().customers.list({ email, limit: 1 })
|
||||||
|
const customer = customers.data[0]
|
||||||
|
if (!customer) {
|
||||||
|
console.error(`Error: No Stripe customer found for email ${email}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const customerID = customer.id
|
||||||
|
|
||||||
|
// Get the subscription id
|
||||||
|
const subscriptions = await Billing.stripe().subscriptions.list({ customer: customerID, limit: 1 })
|
||||||
|
const subscription = subscriptions.data[0]
|
||||||
|
if (!subscription) {
|
||||||
|
console.error(`Error: Customer ${customerID} does not have a subscription`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const subscriptionID = subscription.id
|
||||||
|
|
||||||
|
// Validate the subscription is $200
|
||||||
|
const amountInCents = subscription.items.data[0]?.price.unit_amount ?? 0
|
||||||
|
if (amountInCents !== 20000) {
|
||||||
|
console.error(`Error: Subscription amount is $${amountInCents / 100}, expected $200`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if subscription is already tied to another workspace
|
||||||
|
const existingSubscription = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ workspaceID: BillingTable.workspaceID })
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.subscriptionID, subscriptionID))
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
)
|
||||||
|
if (existingSubscription) {
|
||||||
|
console.error(
|
||||||
|
`Error: Subscription ${subscriptionID} is already tied to workspace ${existingSubscription.workspaceID}`,
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the workspace billing and check if it already has a customer id or subscription
|
||||||
|
const billing = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ customerID: BillingTable.customerID, subscriptionID: BillingTable.subscriptionID })
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
)
|
||||||
|
if (billing?.subscriptionID) {
|
||||||
|
console.error(`Error: Workspace ${workspaceID} already has a subscription: ${billing.subscriptionID}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (billing?.customerID) {
|
||||||
|
console.warn(
|
||||||
|
`Warning: Workspace ${workspaceID} already has a customer id: ${billing.customerID}, replacing with ${customerID}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the latest invoice and payment from the subscription
|
||||||
|
const invoices = await Billing.stripe().invoices.list({
|
||||||
|
subscription: subscriptionID,
|
||||||
|
limit: 1,
|
||||||
|
expand: ["data.payments"],
|
||||||
|
})
|
||||||
|
const invoice = invoices.data[0]
|
||||||
|
const invoiceID = invoice?.id
|
||||||
|
const paymentID = invoice?.payments?.data[0]?.payment.payment_intent as string | undefined
|
||||||
|
|
||||||
|
// Get the default payment method from the customer
|
||||||
|
const paymentMethodID = (customer.invoice_settings.default_payment_method ?? subscription.default_payment_method) as
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.retrieve(paymentMethodID) : null
|
||||||
|
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
|
||||||
|
const paymentMethodType = paymentMethod?.type ?? null
|
||||||
|
|
||||||
|
// Look up the user by email via AuthTable
|
||||||
|
const auth = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ accountID: AuthTable.accountID })
|
||||||
|
.from(AuthTable)
|
||||||
|
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
)
|
||||||
|
if (!auth) {
|
||||||
|
console.error(`Error: No user found with email ${email}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the user in the workspace
|
||||||
|
const user = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ id: UserTable.id })
|
||||||
|
.from(UserTable)
|
||||||
|
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, auth.accountID)))
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
)
|
||||||
|
if (!user) {
|
||||||
|
console.error(`Error: User with email ${email} is not a member of workspace ${workspaceID}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set workspaceID in Stripe customer metadata
|
||||||
|
await Billing.stripe().customers.update(customerID, {
|
||||||
|
metadata: {
|
||||||
|
workspaceID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Database.transaction(async (tx) => {
|
||||||
|
// Set customer id, subscription id, and payment method on workspace billing
|
||||||
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
customerID,
|
||||||
|
subscriptionID,
|
||||||
|
paymentMethodID,
|
||||||
|
paymentMethodLast4,
|
||||||
|
paymentMethodType,
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
|
// Set current time as timeSubscribed on user
|
||||||
|
await tx
|
||||||
|
.update(UserTable)
|
||||||
|
.set({
|
||||||
|
timeSubscribed: sql`now()`,
|
||||||
|
})
|
||||||
|
.where(eq(UserTable.id, user.id))
|
||||||
|
|
||||||
|
// Create a row in payments table
|
||||||
|
await tx.insert(PaymentTable).values({
|
||||||
|
workspaceID,
|
||||||
|
id: Identifier.create("payment"),
|
||||||
|
amount: centsToMicroCents(amountInCents),
|
||||||
|
customerID,
|
||||||
|
invoiceID,
|
||||||
|
paymentID,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Successfully onboarded workspace ${workspaceID}`)
|
||||||
|
console.log(` Customer ID: ${customerID}`)
|
||||||
|
console.log(` Subscription ID: ${subscriptionID}`)
|
||||||
|
console.log(
|
||||||
|
` Payment Method: ${paymentMethodID ?? "(none)"} (${paymentMethodType ?? "unknown"} ending in ${paymentMethodLast4 ?? "????"})`,
|
||||||
|
)
|
||||||
|
console.log(` User ID: ${user.id}`)
|
||||||
|
console.log(` Invoice ID: ${invoiceID ?? "(none)"}`)
|
||||||
|
console.log(` Payment ID: ${paymentID ?? "(none)"}`)
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { $ } from "bun"
|
||||||
|
import path from "path"
|
||||||
|
import { BlackData } from "../src/black"
|
||||||
|
|
||||||
|
const stage = process.argv[2]
|
||||||
|
if (!stage) throw new Error("Stage is required")
|
||||||
|
|
||||||
|
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
|
|
||||||
|
// read the secret
|
||||||
|
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||||
|
const lines = ret.split("\n")
|
||||||
|
const value = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1]
|
||||||
|
if (!value) throw new Error("ZEN_BLACK not found")
|
||||||
|
|
||||||
|
// validate value
|
||||||
|
BlackData.validate(JSON.parse(value))
|
||||||
|
|
||||||
|
// update the secret
|
||||||
|
await $`bun sst secret set ZEN_BLACK ${value} --stage ${stage}`
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { $ } from "bun"
|
||||||
|
import path from "path"
|
||||||
|
import os from "os"
|
||||||
|
import { BlackData } from "../src/black"
|
||||||
|
|
||||||
|
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
|
const secrets = await $`bun sst secret list`.cwd(root).text()
|
||||||
|
|
||||||
|
// read the line starting with "ZEN_BLACK"
|
||||||
|
const lines = secrets.split("\n")
|
||||||
|
const oldValue = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1]
|
||||||
|
if (!oldValue) throw new Error("ZEN_BLACK not found")
|
||||||
|
|
||||||
|
// store the prettified json to a temp file
|
||||||
|
const filename = `black-${Date.now()}.json`
|
||||||
|
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
||||||
|
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
|
||||||
|
console.log("tempFile", tempFile.name)
|
||||||
|
|
||||||
|
// open temp file in vim and read the file on close
|
||||||
|
await $`vim ${tempFile.name}`
|
||||||
|
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
||||||
|
BlackData.validate(JSON.parse(newValue))
|
||||||
|
|
||||||
|
// update the secret
|
||||||
|
await $`bun sst secret set ZEN_BLACK ${newValue}`
|
||||||
@@ -27,6 +27,7 @@ export namespace Billing {
|
|||||||
tx
|
tx
|
||||||
.select({
|
.select({
|
||||||
customerID: BillingTable.customerID,
|
customerID: BillingTable.customerID,
|
||||||
|
subscriptionID: BillingTable.subscriptionID,
|
||||||
paymentMethodID: BillingTable.paymentMethodID,
|
paymentMethodID: BillingTable.paymentMethodID,
|
||||||
paymentMethodType: BillingTable.paymentMethodType,
|
paymentMethodType: BillingTable.paymentMethodType,
|
||||||
paymentMethodLast4: BillingTable.paymentMethodLast4,
|
paymentMethodLast4: BillingTable.paymentMethodLast4,
|
||||||
@@ -157,6 +158,24 @@ export namespace Billing {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const grantCredit = async (workspaceID: string, dollarAmount: number) => {
|
||||||
|
const amountInMicroCents = centsToMicroCents(dollarAmount * 100)
|
||||||
|
await Database.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
balance: sql`${BillingTable.balance} + ${amountInMicroCents}`,
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
await tx.insert(PaymentTable).values({
|
||||||
|
workspaceID,
|
||||||
|
id: Identifier.create("payment"),
|
||||||
|
amount: amountInMicroCents,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return amountInMicroCents
|
||||||
|
}
|
||||||
|
|
||||||
export const setMonthlyLimit = fn(z.number(), async (input) => {
|
export const setMonthlyLimit = fn(z.number(), async (input) => {
|
||||||
return await Database.use((tx) =>
|
return await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { fn } from "./util/fn"
|
||||||
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
|
||||||
|
export namespace BlackData {
|
||||||
|
const Schema = z.object({
|
||||||
|
monthlyLimit: z.number().int(),
|
||||||
|
intervalLimit: z.number().int(),
|
||||||
|
intervalLength: z.number().int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validate = fn(Schema, (input) => {
|
||||||
|
return input
|
||||||
|
})
|
||||||
|
|
||||||
|
export const get = fn(z.void(), () => {
|
||||||
|
const json = JSON.parse(Resource.ZEN_BLACK.value)
|
||||||
|
return Schema.parse(json)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { bigint, boolean, index, int, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
import { bigint, boolean, index, int, json, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||||
import { workspaceIndexes } from "./workspace.sql"
|
import { workspaceIndexes } from "./workspace.sql"
|
||||||
|
|
||||||
@@ -21,8 +21,13 @@ export const BillingTable = mysqlTable(
|
|||||||
reloadError: varchar("reload_error", { length: 255 }),
|
reloadError: varchar("reload_error", { length: 255 }),
|
||||||
timeReloadError: utc("time_reload_error"),
|
timeReloadError: utc("time_reload_error"),
|
||||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||||
|
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||||
},
|
},
|
||||||
(table) => [...workspaceIndexes(table), uniqueIndex("global_customer_id").on(table.customerID)],
|
(table) => [
|
||||||
|
...workspaceIndexes(table),
|
||||||
|
uniqueIndex("global_customer_id").on(table.customerID),
|
||||||
|
uniqueIndex("global_subscription_id").on(table.subscriptionID),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
export const PaymentTable = mysqlTable(
|
export const PaymentTable = mysqlTable(
|
||||||
@@ -54,6 +59,9 @@ export const UsageTable = mysqlTable(
|
|||||||
cacheWrite1hTokens: int("cache_write_1h_tokens"),
|
cacheWrite1hTokens: int("cache_write_1h_tokens"),
|
||||||
cost: bigint("cost", { mode: "number" }).notNull(),
|
cost: bigint("cost", { mode: "number" }).notNull(),
|
||||||
keyID: ulid("key_id"),
|
keyID: ulid("key_id"),
|
||||||
|
enrichment: json("enrichment").$type<{
|
||||||
|
plan: "sub"
|
||||||
|
}>(),
|
||||||
},
|
},
|
||||||
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
|
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,3 +10,13 @@ export const IpTable = mysqlTable(
|
|||||||
},
|
},
|
||||||
(table) => [primaryKey({ columns: [table.ip] })],
|
(table) => [primaryKey({ columns: [table.ip] })],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const IpRateLimitTable = mysqlTable(
|
||||||
|
"ip_rate_limit",
|
||||||
|
{
|
||||||
|
ip: varchar("ip", { length: 45 }).notNull(),
|
||||||
|
interval: varchar("interval", { length: 10 }).notNull(),
|
||||||
|
count: int("count").notNull(),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.ip, table.interval] })],
|
||||||
|
)
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export const UserTable = mysqlTable(
|
|||||||
monthlyLimit: int("monthly_limit"),
|
monthlyLimit: int("monthly_limit"),
|
||||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||||
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
||||||
|
// subscription
|
||||||
|
timeSubscribed: utc("time_subscribed"),
|
||||||
|
subIntervalUsage: bigint("sub_interval_usage", { mode: "number" }),
|
||||||
|
subMonthlyUsage: bigint("sub_monthly_usage", { mode: "number" }),
|
||||||
|
timeSubIntervalUsageUpdated: utc("sub_time_interval_usage_updated"),
|
||||||
|
timeSubMonthlyUsageUpdated: utc("sub_time_monthly_usage_updated"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
...workspaceIndexes(table),
|
...workspaceIndexes(table),
|
||||||
|
|||||||
Vendored
+4
@@ -98,6 +98,10 @@ declare module "sst" {
|
|||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
|
"ZEN_BLACK": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"ZEN_MODELS1": {
|
"ZEN_MODELS1": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+4
@@ -98,6 +98,10 @@ declare module "sst" {
|
|||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
|
"ZEN_BLACK": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"ZEN_MODELS1": {
|
"ZEN_MODELS1": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"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",
|
||||||
|
|||||||
+4
@@ -98,6 +98,10 @@ declare module "sst" {
|
|||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
|
"ZEN_BLACK": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"ZEN_MODELS1": {
|
"ZEN_MODELS1": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
# Tauri + Vanilla TS
|
# OpenCode Desktop
|
||||||
|
|
||||||
This template should help get you started developing with Tauri in vanilla HTML, CSS and Typescript.
|
Native OpenCode desktop app, built with Tauri v2.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
## Development
|
||||||
|
|
||||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
From the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bun run --cwd packages/desktop tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the Vite dev server on http://localhost:1420 and opens the native window.
|
||||||
|
|
||||||
|
If you only want the web dev server (no native shell):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd packages/desktop dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
To create a production `dist/` and build the native app bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd packages/desktop tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.2",
|
"version": "1.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
|
|||||||
Generated
+35
@@ -1177,6 +1177,21 @@ dependencies = [
|
|||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1184,6 +1199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1251,6 +1267,7 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
@@ -2775,8 +2792,10 @@ dependencies = [
|
|||||||
name = "opencode-desktop"
|
name = "opencode-desktop"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures",
|
||||||
"gtk",
|
"gtk",
|
||||||
"listeners",
|
"listeners",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -2789,6 +2808,7 @@ dependencies = [
|
|||||||
"tauri-plugin-os",
|
"tauri-plugin-os",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
@@ -4636,6 +4656,21 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-single-instance"
|
||||||
|
version = "2.3.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-store"
|
name = "tauri-plugin-store"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
|
|||||||
@@ -29,12 +29,15 @@ tauri-plugin-window-state = "2"
|
|||||||
tauri-plugin-clipboard-manager = "2"
|
tauri-plugin-clipboard-manager = "2"
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = "1.48.0"
|
tokio = "1.48.0"
|
||||||
listeners = "0.3"
|
listeners = "0.3"
|
||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
|
futures = "0.3.31"
|
||||||
|
semver = "1.0.27"
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
gtk = "0.18.2"
|
gtk = "0.18.2"
|
||||||
|
|||||||
@@ -12,5 +12,19 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.automation.apple-events</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.addressbook</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.calendars</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.location</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.photos-library</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||||
|
const CLI_BINARY_NAME: &str = "opencode";
|
||||||
|
|
||||||
|
fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
||||||
|
std::env::var("HOME").ok().map(|home| {
|
||||||
|
std::path::PathBuf::from(home)
|
||||||
|
.join(CLI_INSTALL_DIR)
|
||||||
|
.join(CLI_BINARY_NAME)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_sidecar_path() -> std::path::PathBuf {
|
||||||
|
tauri::utils::platform::current_exe()
|
||||||
|
.expect("Failed to get current exe")
|
||||||
|
.parent()
|
||||||
|
.expect("Failed to get parent dir")
|
||||||
|
.join("opencode-cli")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_cli_installed() -> bool {
|
||||||
|
get_cli_install_path()
|
||||||
|
.map(|path| path.exists())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn install_cli() -> Result<String, String> {
|
||||||
|
if cfg!(not(unix)) {
|
||||||
|
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let sidecar = get_sidecar_path();
|
||||||
|
if !sidecar.exists() {
|
||||||
|
return Err("Sidecar binary not found".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_script = std::env::temp_dir().join("opencode-install.sh");
|
||||||
|
std::fs::write(&temp_script, INSTALL_SCRIPT)
|
||||||
|
.map_err(|e| format!("Failed to write install script: {}", e))?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.map_err(|e| format!("Failed to set script permissions: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = std::process::Command::new(&temp_script)
|
||||||
|
.arg("--binary")
|
||||||
|
.arg(&sidecar)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run install script: {}", e))?;
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&temp_script);
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("Install script failed: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
let install_path =
|
||||||
|
get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?;
|
||||||
|
|
||||||
|
Ok(install_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
println!("Skipping CLI sync for debug build");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_cli_installed() {
|
||||||
|
println!("No CLI installation found, skipping sync");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cli_path =
|
||||||
|
get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?;
|
||||||
|
|
||||||
|
let output = std::process::Command::new(&cli_path)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to get CLI version: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err("Failed to get CLI version".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let cli_version = semver::Version::parse(&cli_version_str)
|
||||||
|
.map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?;
|
||||||
|
|
||||||
|
let app_version = app.package_info().version.clone();
|
||||||
|
|
||||||
|
if cli_version >= app_version {
|
||||||
|
println!(
|
||||||
|
"CLI version {} is up to date (app version: {}), skipping sync",
|
||||||
|
cli_version, app_version
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"CLI version {} is older than app version {}, syncing",
|
||||||
|
cli_version, app_version
|
||||||
|
);
|
||||||
|
|
||||||
|
install_cli()?;
|
||||||
|
|
||||||
|
println!("Synced installed CLI");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
|
mod cli;
|
||||||
mod window_customizer;
|
mod window_customizer;
|
||||||
|
|
||||||
|
use cli::{get_sidecar_path, install_cli, sync_cli};
|
||||||
|
use futures::FutureExt;
|
||||||
use std::{
|
use std::{
|
||||||
collections::VecDeque,
|
collections::VecDeque,
|
||||||
net::{SocketAddr, TcpListener},
|
net::{SocketAddr, TcpListener},
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory};
|
use tauri::{
|
||||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
WebviewWindow,
|
||||||
|
};
|
||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
use tokio::net::TcpSocket;
|
use tokio::net::TcpSocket;
|
||||||
@@ -16,7 +20,26 @@ use tokio::net::TcpSocket;
|
|||||||
use crate::window_customizer::PinchZoomDisablePlugin;
|
use crate::window_customizer::PinchZoomDisablePlugin;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ServerState(Arc<Mutex<Option<CommandChild>>>);
|
struct ServerState {
|
||||||
|
child: Arc<Mutex<Option<CommandChild>>>,
|
||||||
|
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerState {
|
||||||
|
pub fn new(
|
||||||
|
child: Option<CommandChild>,
|
||||||
|
status: tokio::sync::oneshot::Receiver<Result<(), String>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
child: Arc::new(Mutex::new(child)),
|
||||||
|
status: status.shared(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_child(&self, child: Option<CommandChild>) {
|
||||||
|
*self.child.lock().unwrap() = child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct LogState(Arc<Mutex<VecDeque<String>>>);
|
struct LogState(Arc<Mutex<VecDeque<String>>>);
|
||||||
@@ -31,7 +54,7 @@ fn kill_sidecar(app: AppHandle) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let Some(server_state) = server_state
|
let Some(server_state) = server_state
|
||||||
.0
|
.child
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Failed to acquire mutex lock")
|
.expect("Failed to acquire mutex lock")
|
||||||
.take()
|
.take()
|
||||||
@@ -45,25 +68,6 @@ fn kill_sidecar(app: AppHandle) {
|
|||||||
println!("Killed server");
|
println!("Killed server");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn copy_logs_to_clipboard(app: AppHandle) -> Result<(), String> {
|
|
||||||
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
|
|
||||||
|
|
||||||
let logs = log_state
|
|
||||||
.0
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| "Failed to acquire log lock")?;
|
|
||||||
|
|
||||||
let log_text = logs.iter().cloned().collect::<Vec<_>>().join("");
|
|
||||||
|
|
||||||
app.clipboard()
|
|
||||||
.write_text(log_text)
|
|
||||||
.map_err(|e| format!("Failed to copy to clipboard: {}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn get_logs(app: AppHandle) -> Result<String, String> {
|
async fn get_logs(app: AppHandle) -> Result<String, String> {
|
||||||
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
|
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
|
||||||
|
|
||||||
@@ -75,6 +79,15 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
|
|||||||
Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
|
Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.status
|
||||||
|
.clone()
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Failed to get server status".to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
fn get_sidecar_port() -> u32 {
|
fn get_sidecar_port() -> u32 {
|
||||||
option_env!("OPENCODE_PORT")
|
option_env!("OPENCODE_PORT")
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
@@ -116,11 +129,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
|||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let (mut rx, child) = {
|
let (mut rx, child) = {
|
||||||
let sidecar_path = tauri::utils::platform::current_exe()
|
let sidecar = get_sidecar_path();
|
||||||
.expect("Failed to get current exe")
|
|
||||||
.parent()
|
|
||||||
.expect("Failed to get parent dir")
|
|
||||||
.join("opencode-cli");
|
|
||||||
let shell = get_user_shell();
|
let shell = get_user_shell();
|
||||||
app.shell()
|
app.shell()
|
||||||
.command(&shell)
|
.command(&shell)
|
||||||
@@ -130,7 +139,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
|||||||
.args([
|
.args([
|
||||||
"-il",
|
"-il",
|
||||||
"-c",
|
"-c",
|
||||||
&format!("{} serve --port={}", sidecar_path.display(), port),
|
&format!("\"{}\" serve --port={}", sidecar.display(), port),
|
||||||
])
|
])
|
||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to spawn opencode")
|
.expect("Failed to spawn opencode")
|
||||||
@@ -189,6 +198,13 @@ pub fn run() {
|
|||||||
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
|
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
|
||||||
|
|
||||||
let mut builder = tauri::Builder::default()
|
let mut builder = tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
|
// Focus existing window when another instance is launched
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.set_focus();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
}
|
||||||
|
}))
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_window_state::Builder::new().build())
|
.plugin(tauri_plugin_window_state::Builder::new().build())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
@@ -202,8 +218,8 @@ pub fn run() {
|
|||||||
.plugin(PinchZoomDisablePlugin)
|
.plugin(PinchZoomDisablePlugin)
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
kill_sidecar,
|
kill_sidecar,
|
||||||
copy_logs_to_clipboard,
|
install_cli,
|
||||||
get_logs
|
ensure_server_started
|
||||||
])
|
])
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let app = app.handle().clone();
|
let app = app.handle().clone();
|
||||||
@@ -211,83 +227,94 @@ pub fn run() {
|
|||||||
// Initialize log state
|
// Initialize log state
|
||||||
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
|
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
// Get port and create window immediately for faster perceived startup
|
||||||
let port = get_sidecar_port();
|
let port = get_sidecar_port();
|
||||||
|
|
||||||
let should_spawn_sidecar = !is_server_running(port).await;
|
let primary_monitor = app.primary_monitor().ok().flatten();
|
||||||
|
let size = primary_monitor
|
||||||
|
.map(|m| m.size().to_logical(m.scale_factor()))
|
||||||
|
.unwrap_or(LogicalSize::new(1920, 1080));
|
||||||
|
|
||||||
let child = if should_spawn_sidecar {
|
// Create window immediately with serverReady = false
|
||||||
let child = spawn_sidecar(&app, port);
|
let mut window_builder =
|
||||||
|
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
||||||
|
.title("OpenCode")
|
||||||
|
.inner_size(size.width as f64, size.height as f64)
|
||||||
|
.decorations(true)
|
||||||
|
.zoom_hotkeys_enabled(true)
|
||||||
|
.disable_drag_drop_handler()
|
||||||
|
.initialization_script(format!(
|
||||||
|
r#"
|
||||||
|
window.__OPENCODE__ ??= {{}};
|
||||||
|
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
||||||
|
window.__OPENCODE__.port = {port};
|
||||||
|
"#
|
||||||
|
));
|
||||||
|
|
||||||
let timestamp = Instant::now();
|
#[cfg(target_os = "macos")]
|
||||||
loop {
|
{
|
||||||
if timestamp.elapsed() > Duration::from_secs(7) {
|
window_builder = window_builder
|
||||||
let res = app.dialog()
|
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||||
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
|
.hidden_title(true);
|
||||||
.title("Startup Failed")
|
}
|
||||||
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
|
|
||||||
.blocking_show_with_result();
|
|
||||||
|
|
||||||
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
|
let window = window_builder.build().expect("Failed to create window");
|
||||||
match copy_logs_to_clipboard(app.clone()).await {
|
|
||||||
Ok(()) => println!("Logs copied to clipboard successfully"),
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
Err(e) => println!("Failed to copy logs to clipboard: {}", e),
|
app.manage(ServerState::new(None, rx));
|
||||||
}
|
|
||||||
|
{
|
||||||
|
let app = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let should_spawn_sidecar = !is_server_running(port).await;
|
||||||
|
|
||||||
|
let (child, res) = if should_spawn_sidecar {
|
||||||
|
let child = spawn_sidecar(&app, port);
|
||||||
|
|
||||||
|
let timestamp = Instant::now();
|
||||||
|
let res = loop {
|
||||||
|
if timestamp.elapsed() > Duration::from_secs(7) {
|
||||||
|
break Err(format!(
|
||||||
|
"Failed to spawn OpenCode Server. Logs:\n{}",
|
||||||
|
get_logs(app.clone()).await.unwrap()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
app.exit(1);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
||||||
|
|
||||||
if is_server_running(port).await {
|
|
||||||
// give the server a little bit more time to warm up
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
|
||||||
break;
|
if is_server_running(port).await {
|
||||||
}
|
// give the server a little bit more time to warm up
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
break Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Server ready after {:?}", timestamp.elapsed());
|
||||||
|
|
||||||
|
(Some(child), res)
|
||||||
|
} else {
|
||||||
|
(None, Ok(()))
|
||||||
|
};
|
||||||
|
|
||||||
|
app.state::<ServerState>().set_child(child);
|
||||||
|
|
||||||
|
if res.is_ok() {
|
||||||
|
let _ = window.eval("window.__OPENCODE__.serverReady = true;");
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Server ready after {:?}", timestamp.elapsed());
|
let _ = tx.send(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Some(child)
|
{
|
||||||
} else {
|
let app = app.clone();
|
||||||
None
|
tauri::async_runtime::spawn(async move {
|
||||||
};
|
if let Err(e) = sync_cli(app) {
|
||||||
|
eprintln!("Failed to sync CLI: {e}");
|
||||||
let primary_monitor = app.primary_monitor().ok().flatten();
|
}
|
||||||
let size = primary_monitor
|
});
|
||||||
.map(|m| m.size().to_logical(m.scale_factor()))
|
}
|
||||||
.unwrap_or(LogicalSize::new(1920, 1080));
|
|
||||||
|
|
||||||
let mut window_builder =
|
|
||||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
|
||||||
.title("OpenCode")
|
|
||||||
.inner_size(size.width as f64, size.height as f64)
|
|
||||||
.decorations(true)
|
|
||||||
.zoom_hotkeys_enabled(true)
|
|
||||||
.disable_drag_drop_handler()
|
|
||||||
.initialization_script(format!(
|
|
||||||
r#"
|
|
||||||
window.__OPENCODE__ ??= {{}};
|
|
||||||
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
|
||||||
window.__OPENCODE__.port = {port};
|
|
||||||
"#
|
|
||||||
));
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
window_builder = window_builder
|
|
||||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
|
||||||
.hidden_title(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
window_builder.build().expect("Failed to create window");
|
|
||||||
|
|
||||||
app.manage(ServerState(Arc::new(Mutex::new(child))));
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user