mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-10 19:04:17 +00:00
Compare commits
91 Commits
ask-questi
...
codex-auth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0fd15e9e4 | ||
|
|
4f2514d9f5 | ||
|
|
5d9801f206 | ||
|
|
a300cd6465 | ||
|
|
399699469b | ||
|
|
cacf9df24c | ||
|
|
2844086752 | ||
|
|
75df5040ea | ||
|
|
f2b2940298 | ||
|
|
22f51c6b47 | ||
|
|
de286b08f6 | ||
|
|
0f2124db32 | ||
|
|
e30a159264 | ||
|
|
a5edf3a311 | ||
|
|
7c2907cbb4 | ||
|
|
bce9dc040f | ||
|
|
44297ffe78 | ||
|
|
cbb3141130 | ||
|
|
18cf4df6c6 | ||
|
|
f3e8a275b8 | ||
|
|
7df36cf0fa | ||
|
|
e82b112759 | ||
|
|
030b14ac4e | ||
|
|
b1e381cff7 | ||
|
|
0433d4d064 | ||
|
|
d34fdac854 | ||
|
|
ec828619ca | ||
|
|
559013e124 | ||
|
|
8e3ab4afa7 | ||
|
|
13305966e5 | ||
|
|
a618fbe8cf | ||
|
|
07dc1f8ecc | ||
|
|
445c8631a2 | ||
|
|
c4eacd0ccf | ||
|
|
dd5ec26c8c | ||
|
|
8b062ed621 | ||
|
|
ab97a95032 | ||
|
|
a98d108d2e | ||
|
|
2e875b2d65 | ||
|
|
790baec41f | ||
|
|
52fbd16e08 | ||
|
|
cf97633d7d | ||
|
|
3fe2e89d55 | ||
|
|
eb5c113cff | ||
|
|
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 |
3
.github/pull_request_template.md
vendored
Normal file
3
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
### What does this PR do?
|
||||
|
||||
### How did you verify your code works?
|
||||
7
.github/workflows/nix-desktop.yml
vendored
7
.github/workflows/nix-desktop.yml
vendored
@@ -9,6 +9,13 @@ on:
|
||||
- "nix/**"
|
||||
- "packages/app/**"
|
||||
- "packages/desktop/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "nix/**"
|
||||
- "packages/app/**"
|
||||
- "packages/desktop/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
139
.github/workflows/pr-standards.yml
vendored
Normal file
139
.github/workflows/pr-standards.yml
vendored
Normal file
@@ -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');
|
||||
28
.github/workflows/publish.yml
vendored
28
.github/workflows/publish.yml
vendored
@@ -177,8 +177,22 @@ jobs:
|
||||
cargo tauri --version
|
||||
|
||||
- name: Build and upload artifacts
|
||||
timeout-minutes: 20
|
||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||
uses: Wandalen/wretry.action@v3
|
||||
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:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
|
||||
@@ -190,16 +204,6 @@ jobs:
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
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:
|
||||
needs:
|
||||
|
||||
@@ -149,11 +149,63 @@ With that said, you may want to try these methods, as they might work for you.
|
||||
|
||||
## Pull Request Expectations
|
||||
|
||||
- Try to keep pull requests small and focused.
|
||||
- Link relevant issue(s) in the description
|
||||
### Issue First Policy
|
||||
|
||||
**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
|
||||
- Avoid having verbose LLM generated PR descriptions
|
||||
- Before adding new functions or functionality, ensure that such behavior doesn't already exist elsewhere in the codebase.
|
||||
- Before adding new functionality, ensure it 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
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
||||
scoop bucket add extras; scoop install extras/opencode # Windows
|
||||
choco install opencode # Windows
|
||||
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)
|
||||
brew install opencode # macOS and Linux (official brew formula, updated less)
|
||||
paru -S opencode-bin # Arch Linux
|
||||
mise use -g opencode # Any OS
|
||||
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
||||
|
||||
2
STATS.md
2
STATS.md
@@ -194,3 +194,5 @@
|
||||
| 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) |
|
||||
| 2026-01-09 | 2,443,565 (+170,935) | 1,469,451 (+36,971) | 3,913,016 (+207,906) |
|
||||
|
||||
50
bun.lock
50
bun.lock
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -125,7 +125,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -173,7 +173,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -202,7 +202,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -231,7 +231,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -247,7 +247,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -286,8 +286,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.69",
|
||||
"@opentui/solid": "0.1.69",
|
||||
"@opentui/core": "0.1.72",
|
||||
"@opentui/solid": "0.1.72",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -350,7 +350,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -370,7 +370,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -381,7 +381,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -394,7 +394,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -433,7 +433,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -444,7 +444,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1201,21 +1201,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.69", "", { "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.69", "@opentui/core-darwin-x64": "0.1.69", "@opentui/core-linux-arm64": "0.1.69", "@opentui/core-linux-x64": "0.1.69", "@opentui/core-win32-arm64": "0.1.69", "@opentui/core-win32-x64": "0.1.69", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-BcEFnAuMq4vgfb+zxOP/l+NO1AS3fVHkYjn+E8Wpmaxr0AzWNTi2NPAMtQf+Wqufxo0NYh0gY4c9B6n8OxTjGw=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.72", "", { "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.72", "@opentui/core-darwin-x64": "0.1.72", "@opentui/core-linux-arm64": "0.1.72", "@opentui/core-linux-x64": "0.1.72", "@opentui/core-win32-arm64": "0.1.72", "@opentui/core-win32-x64": "0.1.72", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-l4WQzubBJ80Q0n77Lxuodjwwm8qj/sOa7IXxEAzzDDXY/7bsIhdSpVhRTt+KevBRlok5J+w/KMKYr8UzkA4/hA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.69", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d9RPAh84O2XIyMw+7+X0fEyi+4KH5sPk9AxLze8GHRBGOzkRunqagFCLBrN5VFs2e2nbhIYtjMszo7gcpWyh7g=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.72", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RoU48kOrhLZYDBiXaDu1LXS2bwRdlJlFle8eUQiqJjLRbMIY34J/srBuL0JnAS3qKW4J34NepUQa0l0/S43Q3w=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.69", "", { "os": "darwin", "cpu": "x64" }, "sha512-41K9zkL2IG0ahL+8Gd+e9ulMrnJF6lArPzG7grjWzo+FWEZwvw0WLCO1/Gn5K85G8Yx7gQXkZOUaw1BmHjxoRw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.72", "", { "os": "darwin", "cpu": "x64" }, "sha512-hHUQw8i2LWPToRW1rjAiRqmNf34iJPS9ve9CJDygvFs5JOqUxN5yrfLfKfE+1bQjfFDHnpqW1HUk96iLhkPj8Q=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.69", "", { "os": "linux", "cpu": "arm64" }, "sha512-IcUjwjuIpX3BBG1a9kjMqWrHYCFHAVfjh5nIRozWZZoqaczLzJb3nJeF2eg8aDeIoGhXvERWB1r1gmqPW8u3vQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.72", "", { "os": "linux", "cpu": "arm64" }, "sha512-63yml0OQ8tVa0JuDF9lBAWiChX6Q+iDO7lKv7c2n0352n/WyPr3iAgq4uSoH49HXuKeAXY/VwHGjvPzjXD/SDA=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.69", "", { "os": "linux", "cpu": "x64" }, "sha512-5S9vqEIq7q+MEdp4cT0HLegBWu0pWLcletHZL80bsLbJt9OT8en3sQmL5bvas9sIuyeBFru9bfCmrQ/gnVTTiA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.72", "", { "os": "linux", "cpu": "x64" }, "sha512-51veiQXNLvzDsFzsEvt71uK7WhiRe2DnvlJSGBSe6aRRHHxjCFYHzYi7t6bitJqtDTUj+EaMPbH81oZ6xy7tyg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.69", "", { "os": "win32", "cpu": "arm64" }, "sha512-eSKcGwbcnJJPtrTFJI7STZ7inSYeedHS0swwjZhh9SADAruEz08intamunOslffv5+mnlvRp7UBGK35cMjbv/w=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.72", "", { "os": "win32", "cpu": "arm64" }, "sha512-1Ep6OcaYTy1RlLOln+LNN7DL1iNyLwLjG2M8aO0pVJKFvxeD5P7rdRzY065E4uhkHeJIHuduUqxvUjD0dyuwbw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.69", "", { "os": "win32", "cpu": "x64" }, "sha512-OjG/0jqYXURqbbUwNgSPrBA6yuKF3OOFh8JSG7VvzoYHJFJRmwVWY0fztWv/hgGHe354ti37c7JDJBQ44HOCdA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.69", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.69", "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-ls589N8P9gvcNW8uF+Il4xisF5Uouk0RRmSaLdzmItNJSW5J9Y0nPtMELta6hBp0yIRAurWUO1wtkKXVF+eaxg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.72", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.72", "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-hytoLPboL/MTY/BQUnf/HlBuNXTVONney0X+PIQI82wT7kMx7+HHI2wnowpM3dyvA7l6NfORSud2cs9kIUBFBw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"nodeModules": "sha256-rNGq0yjL5ZHYVg+zyV4nFPug4gqhKhyOnfebaufyd34="
|
||||
"nodeModules": "sha256-+QM5BDFxzrm1HY5ealjCm7jIO1t/rpW1q4GGLViPMmA="
|
||||
}
|
||||
|
||||
@@ -14,36 +14,7 @@
|
||||
<meta property="og:image" content="/social-share.png" />
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
||||
<script id="oc-theme-preload-script">
|
||||
;(function () {
|
||||
var themeId = localStorage.getItem("opencode-theme-id")
|
||||
if (!themeId) return
|
||||
|
||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
var mode = isDark ? "dark" : "light"
|
||||
|
||||
document.documentElement.dataset.theme = themeId
|
||||
document.documentElement.dataset.colorScheme = mode
|
||||
|
||||
if (themeId === "oc-1") return
|
||||
|
||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||
if (css) {
|
||||
var style = document.createElement("style")
|
||||
style.id = "oc-theme-preload"
|
||||
style.textContent =
|
||||
":root{color-scheme:" +
|
||||
mode +
|
||||
";--text-mix-blend-mode:" +
|
||||
(isDark ? "plus-lighter" : "multiply") +
|
||||
";" +
|
||||
css +
|
||||
"}"
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
28
packages/app/public/oc-theme-preload.js
Normal file
28
packages/app/public/oc-theme-preload.js
Normal file
@@ -0,0 +1,28 @@
|
||||
;(function () {
|
||||
var themeId = localStorage.getItem("opencode-theme-id")
|
||||
if (!themeId) return
|
||||
|
||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
var mode = isDark ? "dark" : "light"
|
||||
|
||||
document.documentElement.dataset.theme = themeId
|
||||
document.documentElement.dataset.colorScheme = mode
|
||||
|
||||
if (themeId === "oc-1") return
|
||||
|
||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||
if (css) {
|
||||
var style = document.createElement("style")
|
||||
style.id = "oc-theme-preload"
|
||||
style.textContent =
|
||||
":root{color-scheme:" +
|
||||
mode +
|
||||
";--text-mix-blend-mode:" +
|
||||
(isDark ? "plus-lighter" : "multiply") +
|
||||
";" +
|
||||
css +
|
||||
"}"
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
})()
|
||||
@@ -38,9 +38,6 @@ declare global {
|
||||
}
|
||||
|
||||
const defaultServerUrl = iife(() => {
|
||||
const param = new URLSearchParams(document.location.search).get("url")
|
||||
if (param) return param
|
||||
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
|
||||
if (import.meta.env.DEV)
|
||||
@@ -108,18 +105,16 @@ export function AppInterface() {
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route
|
||||
path="/session/:id?"
|
||||
component={(p) => (
|
||||
<Show when={p.params.id ?? "new"} keyed>
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
component={() => (
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
@@ -7,15 +7,11 @@ import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
|
||||
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 }) {
|
||||
const dialog = useDialog()
|
||||
const globalSDK = useGlobalSDK()
|
||||
|
||||
@@ -15,6 +15,7 @@ export function DialogSelectFile() {
|
||||
const params = useParams()
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
return (
|
||||
<Dialog title="Select file">
|
||||
<List
|
||||
@@ -27,7 +28,7 @@ export function DialogSelectFile() {
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
layout.review.open()
|
||||
view().reviewPanel.open()
|
||||
}
|
||||
dialog.close()
|
||||
}}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const ModelSelectorPopover: Component<{
|
||||
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
||||
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
|
||||
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
|
||||
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
|
||||
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
|
||||
</Kobalte.Content>
|
||||
|
||||
@@ -20,6 +20,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
@@ -48,7 +49,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
|
||||
const openContext = () => {
|
||||
if (!params.id) return
|
||||
layout.review.open()
|
||||
view().reviewPanel.open()
|
||||
tabs().open("context")
|
||||
tabs().setActive("context")
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ export function SessionHeader() {
|
||||
})
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
|
||||
function navigateToProject(directory: string) {
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
@@ -171,20 +173,24 @@ export function SessionHeader() {
|
||||
title="Toggle review"
|
||||
keybind={command.keybind("review.toggle")}
|
||||
>
|
||||
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/review-toggle size-6 p-0"
|
||||
onClick={() => view().reviewPanel.toggle()}
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
name={layout.review.opened() ? "layout-right" : "layout-left"}
|
||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
|
||||
size="small"
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
|
||||
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
|
||||
size="small"
|
||||
class="hidden group-hover/review-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
|
||||
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
|
||||
size="small"
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
@@ -197,11 +203,11 @@ export function SessionHeader() {
|
||||
title="Toggle terminal"
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
|
||||
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={() => view().terminal.toggle()}>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
||||
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
||||
class="group-hover/terminal-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
@@ -211,7 +217,7 @@ export function SessionHeader() {
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
||||
class="hidden group-active/terminal-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
let serializeAddon: SerializeAddon
|
||||
let fitAddon: FitAddon
|
||||
let handleResize: () => void
|
||||
let handleTextareaFocus: () => void
|
||||
let handleTextareaBlur: () => void
|
||||
let reconnect: number | undefined
|
||||
let disposed = false
|
||||
|
||||
@@ -105,6 +107,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
const t = new mod.Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontSize: 14,
|
||||
fontFamily: "IBM Plex Mono, monospace",
|
||||
allowTransparency: true,
|
||||
@@ -170,6 +173,17 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
t.open(container)
|
||||
container.addEventListener("pointerdown", handlePointerDown)
|
||||
|
||||
handleTextareaFocus = () => {
|
||||
t.options.cursorBlink = true
|
||||
}
|
||||
handleTextareaBlur = () => {
|
||||
t.options.cursorBlink = false
|
||||
}
|
||||
|
||||
t.textarea?.addEventListener("focus", handleTextareaFocus)
|
||||
t.textarea?.addEventListener("blur", handleTextareaBlur)
|
||||
|
||||
focusTerminal()
|
||||
|
||||
if (local.pty.buffer) {
|
||||
@@ -242,6 +256,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
window.removeEventListener("resize", handleResize)
|
||||
}
|
||||
container.removeEventListener("pointerdown", handlePointerDown)
|
||||
term?.textarea?.removeEventListener("focus", handleTextareaFocus)
|
||||
term?.textarea?.removeEventListener("blur", handleTextareaBlur)
|
||||
|
||||
const t = term
|
||||
if (serializeAddon && props.onCleanup && t) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
@@ -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({
|
||||
name: "File",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
@@ -134,42 +232,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
file: {},
|
||||
})
|
||||
|
||||
const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
|
||||
const viewCache = new Map<string, ViewCacheEntry>()
|
||||
|
||||
const [view, setView, _, ready] = persisted(
|
||||
Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]),
|
||||
createStore<{
|
||||
file: Record<string, FileViewState>
|
||||
}>({
|
||||
file: {},
|
||||
}),
|
||||
)
|
||||
|
||||
const MAX_VIEW_FILES = 500
|
||||
const viewMeta = { 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]
|
||||
}
|
||||
}),
|
||||
)
|
||||
const disposeViews = () => {
|
||||
for (const entry of viewCache.values()) {
|
||||
entry.dispose()
|
||||
}
|
||||
viewCache.clear()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (viewMeta.pruned) return
|
||||
viewMeta.pruned = true
|
||||
pruneView()
|
||||
})
|
||||
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) {
|
||||
if (!path) return
|
||||
@@ -246,51 +347,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
|
||||
const get = (input: string) => store.file[normalize(input)]
|
||||
|
||||
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
|
||||
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
|
||||
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
|
||||
const scrollTop = (input: string) => view().scrollTop(normalize(input))
|
||||
const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
|
||||
const selectedLines = (input: string) => view().selectedLines(normalize(input))
|
||||
|
||||
const setScrollTop = (input: string, top: number) => {
|
||||
const path = normalize(input)
|
||||
setView("file", path, (current) => {
|
||||
if (current?.scrollTop === top) return current
|
||||
return {
|
||||
...(current ?? {}),
|
||||
scrollTop: top,
|
||||
}
|
||||
})
|
||||
pruneView(path)
|
||||
view().setScrollTop(path, top)
|
||||
}
|
||||
|
||||
const setScrollLeft = (input: string, left: number) => {
|
||||
const path = normalize(input)
|
||||
setView("file", path, (current) => {
|
||||
if (current?.scrollLeft === left) return current
|
||||
return {
|
||||
...(current ?? {}),
|
||||
scrollLeft: left,
|
||||
}
|
||||
})
|
||||
pruneView(path)
|
||||
view().setScrollLeft(path, left)
|
||||
}
|
||||
|
||||
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
||||
const path = normalize(input)
|
||||
const next = range ? normalizeSelectedLines(range) : null
|
||||
setView("file", path, (current) => {
|
||||
if (current?.selectedLines === next) return current
|
||||
return {
|
||||
...(current ?? {}),
|
||||
selectedLines: next,
|
||||
}
|
||||
})
|
||||
pruneView(path)
|
||||
view().setSelectedLines(path, range)
|
||||
}
|
||||
|
||||
onCleanup(() => stop())
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
disposeViews()
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
ready: () => view().ready(),
|
||||
normalize,
|
||||
tab,
|
||||
pathFromTab,
|
||||
|
||||
@@ -33,6 +33,8 @@ type SessionTabs = {
|
||||
type SessionView = {
|
||||
scroll: Record<string, SessionScroll>
|
||||
reviewOpen?: string[]
|
||||
terminalOpened?: boolean
|
||||
reviewPanelOpened?: boolean
|
||||
}
|
||||
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
@@ -53,11 +55,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
width: 280,
|
||||
},
|
||||
terminal: {
|
||||
opened: false,
|
||||
height: 280,
|
||||
},
|
||||
review: {
|
||||
opened: true,
|
||||
diffStyle: "split" as ReviewDiffStyle,
|
||||
},
|
||||
session: {
|
||||
@@ -150,7 +150,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const current = store.sessionView[sessionKey]
|
||||
const keep = meta.active ?? sessionKey
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: next })
|
||||
setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
|
||||
prune(keep)
|
||||
return
|
||||
}
|
||||
@@ -306,40 +306,20 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
opened: createMemo(() => store.terminal.opened),
|
||||
open() {
|
||||
setStore("terminal", "opened", true)
|
||||
},
|
||||
close() {
|
||||
setStore("terminal", "opened", false)
|
||||
},
|
||||
toggle() {
|
||||
setStore("terminal", "opened", (x) => !x)
|
||||
},
|
||||
height: createMemo(() => store.terminal.height),
|
||||
resize(height: number) {
|
||||
setStore("terminal", "height", height)
|
||||
},
|
||||
},
|
||||
review: {
|
||||
opened: createMemo(() => store.review?.opened ?? true),
|
||||
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
||||
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
||||
if (!store.review) {
|
||||
setStore("review", { opened: true, diffStyle })
|
||||
setStore("review", { diffStyle })
|
||||
return
|
||||
}
|
||||
setStore("review", "diffStyle", diffStyle)
|
||||
},
|
||||
open() {
|
||||
setStore("review", "opened", true)
|
||||
},
|
||||
close() {
|
||||
setStore("review", "opened", false)
|
||||
},
|
||||
toggle() {
|
||||
setStore("review", "opened", (x) => !x)
|
||||
},
|
||||
},
|
||||
session: {
|
||||
width: createMemo(() => store.session?.width ?? 600),
|
||||
@@ -367,6 +347,33 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
touch(sessionKey)
|
||||
scroll.seed(sessionKey)
|
||||
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
||||
const terminalOpened = createMemo(() => s().terminalOpened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true)
|
||||
|
||||
function setTerminalOpened(next: boolean) {
|
||||
const current = store.sessionView[sessionKey]
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true })
|
||||
return
|
||||
}
|
||||
|
||||
const value = current.terminalOpened ?? false
|
||||
if (value === next) return
|
||||
setStore("sessionView", sessionKey, "terminalOpened", next)
|
||||
}
|
||||
|
||||
function setReviewPanelOpened(next: boolean) {
|
||||
const current = store.sessionView[sessionKey]
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next })
|
||||
return
|
||||
}
|
||||
|
||||
const value = current.reviewPanelOpened ?? true
|
||||
if (value === next) return
|
||||
setStore("sessionView", sessionKey, "reviewPanelOpened", next)
|
||||
}
|
||||
|
||||
return {
|
||||
scroll(tab: string) {
|
||||
return scroll.scroll(sessionKey, tab)
|
||||
@@ -374,12 +381,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setScroll(tab: string, pos: SessionScroll) {
|
||||
scroll.setScroll(sessionKey, tab, pos)
|
||||
},
|
||||
terminal: {
|
||||
opened: terminalOpened,
|
||||
open() {
|
||||
setTerminalOpened(true)
|
||||
},
|
||||
close() {
|
||||
setTerminalOpened(false)
|
||||
},
|
||||
toggle() {
|
||||
setTerminalOpened(!terminalOpened())
|
||||
},
|
||||
},
|
||||
reviewPanel: {
|
||||
opened: reviewPanelOpened,
|
||||
open() {
|
||||
setReviewPanelOpened(true)
|
||||
},
|
||||
close() {
|
||||
setReviewPanelOpened(false)
|
||||
},
|
||||
toggle() {
|
||||
setReviewPanelOpened(!reviewPanelOpened())
|
||||
},
|
||||
},
|
||||
review: {
|
||||
open: createMemo(() => s().reviewOpen),
|
||||
setOpen(open: string[]) {
|
||||
const current = store.sessionView[sessionKey]
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
|
||||
setStore("sessionView", sessionKey, {
|
||||
scroll: {},
|
||||
terminalOpened: false,
|
||||
reviewPanelOpened: true,
|
||||
reviewOpen: open,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -276,7 +276,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
visible(model: ModelKey) {
|
||||
const key = `${model.providerID}:${model.modelID}`
|
||||
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) {
|
||||
updateVisibility(model, visible ? "show" : "hide")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
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 type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -99,74 +99,146 @@ function clonePrompt(prompt: Prompt): Prompt {
|
||||
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({
|
||||
name: "Prompt",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.scoped(params.dir!, params.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}`
|
||||
const disposeAll = () => {
|
||||
for (const entry of cache.values()) {
|
||||
entry.dispose()
|
||||
}
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
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 {
|
||||
ready,
|
||||
current: createMemo(() => store.prompt),
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||
ready: () => session().ready(),
|
||||
current: () => session().current(),
|
||||
cursor: () => session().cursor(),
|
||||
dirty: () => session().dirty(),
|
||||
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)
|
||||
})
|
||||
activeTab: () => session().context.activeTab(),
|
||||
items: () => session().context.items(),
|
||||
addActive: () => session().context.addActive(),
|
||||
removeActive: () => session().context.removeActive(),
|
||||
add: (item: ContextItem) => session().context.add(item),
|
||||
remove: (key: string) => session().context.remove(key),
|
||||
},
|
||||
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
|
||||
reset: () => session().reset(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { retry } from "@opencode-ai/util/retry"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
@@ -14,6 +14,76 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const sdk = useSDK()
|
||||
const [store, setStore] = globalSync.child(sdk.directory)
|
||||
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 {
|
||||
data: store,
|
||||
@@ -30,11 +100,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return undefined
|
||||
},
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
},
|
||||
get: getSession,
|
||||
addOptimisticMessage(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -66,58 +132,98 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}),
|
||||
)
|
||||
},
|
||||
async sync(sessionID: string, _isRetry = false) {
|
||||
const [session, messages, todo, diff] = await Promise.all([
|
||||
retry(() => sdk.client.session.get({ sessionID })),
|
||||
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
|
||||
retry(() => sdk.client.session.todo({ sessionID })),
|
||||
retry(() => sdk.client.session.diff({ sessionID })),
|
||||
])
|
||||
async sync(sessionID: string) {
|
||||
const hasSession = getSession(sessionID) !== undefined
|
||||
hydrateMessages(sessionID)
|
||||
|
||||
batch(() => {
|
||||
setStore(
|
||||
"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!)
|
||||
}),
|
||||
)
|
||||
const hasMessages = store.message[sessionID] !== undefined
|
||||
if (hasSession && hasMessages) return
|
||||
|
||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||
setStore(
|
||||
"message",
|
||||
sessionID,
|
||||
reconcile(
|
||||
(messages.data ?? [])
|
||||
.map((x) => x.info)
|
||||
.filter((m) => !!m?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
const pending = inflight.get(sessionID)
|
||||
if (pending) return pending
|
||||
|
||||
for (const message of messages.data ?? []) {
|
||||
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" },
|
||||
),
|
||||
)
|
||||
}
|
||||
const limit = meta.limit[sessionID] ?? chunk
|
||||
|
||||
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) => {
|
||||
setStore("limit", (x) => x + count)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
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 { useSDK } from "./sdk"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -14,108 +14,175 @@ export type LocalPTY = {
|
||||
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({
|
||||
name: "Terminal",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const params = useParams()
|
||||
const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
|
||||
const cache = new Map<string, TerminalCacheEntry>()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.scoped(params.dir!, params.id, "terminal", [legacy()]),
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
}>({
|
||||
all: [],
|
||||
}),
|
||||
)
|
||||
const disposeAll = () => {
|
||||
for (const entry of cache.values()) {
|
||||
entry.dispose()
|
||||
}
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
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 {
|
||||
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])
|
||||
}),
|
||||
)
|
||||
},
|
||||
ready: () => session().ready(),
|
||||
all: () => session().all(),
|
||||
active: () => session().active(),
|
||||
new: () => session().new(),
|
||||
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
|
||||
clone: (id: string) => session().clone(id),
|
||||
open: (id: string) => session().open(id),
|
||||
close: (id: string) => session().close(id),
|
||||
move: (id: string, to: number) => session().move(id, to),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
@@ -31,7 +32,7 @@ import { getFilename } from "@opencode-ai/util/path"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
@@ -47,6 +48,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
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 { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { navStart } from "@/utils/perf"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { useServer } from "@/context/server"
|
||||
|
||||
@@ -284,6 +287,146 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const currentSessions = createMemo(() => projectSessions(currentProject()))
|
||||
|
||||
type PrefetchQueue = {
|
||||
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) {
|
||||
const projects = layout.projects.list()
|
||||
if (projects.length === 0) return
|
||||
@@ -309,6 +452,27 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
if (targetIndex >= 0 && targetIndex < sessions.length) {
|
||||
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)
|
||||
queueMicrotask(() => scrollToSession(session.id))
|
||||
return
|
||||
@@ -324,7 +488,27 @@ export default function Layout(props: ParentProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
|
||||
const index = offset > 0 ? 0 : nextProjectSessions.length - 1
|
||||
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))
|
||||
}
|
||||
@@ -679,6 +863,8 @@ export default function Layout(props: ParentProps) {
|
||||
<A
|
||||
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"
|
||||
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">
|
||||
<span
|
||||
@@ -875,7 +1061,7 @@ export default function Layout(props: ParentProps) {
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Tooltip placement="right" value={props.project.worktree}>
|
||||
<Tooltip placement="right" value={getFilename(props.project.worktree)}>
|
||||
<ProjectVisual project={props.project} />
|
||||
</Tooltip>
|
||||
</Match>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
@@ -8,6 +8,7 @@ import { createStore } from "solid-js/store"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
@@ -49,10 +50,17 @@ import {
|
||||
NewSessionView,
|
||||
} from "@/components/session"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { navMark, navParams } from "@/utils/perf"
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
type DiffStyle = "unified" | "split"
|
||||
|
||||
const handoff = {
|
||||
prompt: "",
|
||||
terminals: [] as string[],
|
||||
files: {} as Record<string, SelectedLineRange | null>,
|
||||
}
|
||||
|
||||
interface SessionReviewTabProps {
|
||||
diffs: () => FileDiff[]
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
@@ -162,6 +170,46 @@ export default function Page() {
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.dir, params.id] as const,
|
||||
([dir, id], prev) => {
|
||||
if (!id) return
|
||||
navParams({ dir, from: prev?.[1], to: id })
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!prompt.ready()) return
|
||||
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!terminal.ready()) return
|
||||
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!file.ready()) return
|
||||
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (sync.data.message[id] === undefined) return
|
||||
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
|
||||
})
|
||||
}
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
function normalizeTab(tab: string) {
|
||||
@@ -216,6 +264,8 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const reviewCount = createMemo(() => info()?.summary?.files ?? 0)
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const messagesReady = createMemo(() => {
|
||||
@@ -223,6 +273,16 @@ export default function Page() {
|
||||
if (!id) return true
|
||||
return sync.data.message[id] !== undefined
|
||||
})
|
||||
const historyMore = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return sync.session.history.more(id)
|
||||
})
|
||||
const historyLoading = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
|
||||
const visibleUserMessages = createMemo(() => {
|
||||
@@ -249,11 +309,20 @@ export default function Page() {
|
||||
activeTerminalDraggable: undefined as string | undefined,
|
||||
expanded: {} as Record<string, boolean>,
|
||||
messageId: undefined as string | undefined,
|
||||
turnStart: 0,
|
||||
mobileTab: "session" as "session" | "review",
|
||||
newSessionWorktree: "main",
|
||||
promptHeight: 0,
|
||||
})
|
||||
|
||||
const renderedUserMessages = createMemo(() => {
|
||||
const msgs = visibleUserMessages()
|
||||
const start = store.turnStart
|
||||
if (start <= 0) return msgs
|
||||
if (start >= msgs.length) return emptyUserMessages
|
||||
return msgs.slice(start)
|
||||
}, emptyUserMessages)
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
const project = sync.project
|
||||
@@ -290,6 +359,12 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
let inputRef!: HTMLDivElement
|
||||
@@ -302,11 +377,10 @@ export default function Page() {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (layout.terminal.opened()) {
|
||||
if (terminal.all().length === 0) {
|
||||
terminal.new()
|
||||
}
|
||||
}
|
||||
if (!view().terminal.opened()) return
|
||||
if (!terminal.ready()) return
|
||||
if (terminal.all().length !== 0) return
|
||||
terminal.new()
|
||||
})
|
||||
|
||||
createEffect(
|
||||
@@ -366,7 +440,7 @@ export default function Page() {
|
||||
category: "View",
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => layout.terminal.toggle(),
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
},
|
||||
{
|
||||
id: "review.toggle",
|
||||
@@ -374,7 +448,7 @@ export default function Page() {
|
||||
description: "Show or hide the review panel",
|
||||
category: "View",
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => layout.review.toggle(),
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
},
|
||||
{
|
||||
id: "terminal.new",
|
||||
@@ -643,11 +717,11 @@ export default function Page() {
|
||||
.filter((tab) => tab !== "context"),
|
||||
)
|
||||
|
||||
const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
|
||||
const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")
|
||||
const reviewTab = createMemo(() => hasReview() || tabs().active() === "review")
|
||||
const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review")
|
||||
|
||||
const showTabs = createMemo(
|
||||
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
|
||||
() => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()),
|
||||
)
|
||||
|
||||
const activeTab = createMemo(() => {
|
||||
@@ -664,10 +738,22 @@ export default function Page() {
|
||||
createEffect(() => {
|
||||
if (!layout.ready()) return
|
||||
if (tabs().active()) return
|
||||
if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return
|
||||
if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return
|
||||
tabs().setActive(activeTab())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!hasReview()) return
|
||||
|
||||
const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : store.mobileTab === "review"
|
||||
if (!wants) return
|
||||
if (diffsReady()) return
|
||||
|
||||
sync.session.diff(id)
|
||||
})
|
||||
|
||||
const isWorking = createMemo(() => status().type !== "idle")
|
||||
const autoScroll = createAutoScroll({
|
||||
working: isWorking,
|
||||
@@ -683,6 +769,88 @@ export default function Page() {
|
||||
autoScroll.scrollRef(el)
|
||||
}
|
||||
|
||||
const turnInit = 20
|
||||
const turnBatch = 20
|
||||
let turnHandle: number | undefined
|
||||
let turnIdle = false
|
||||
|
||||
function cancelTurnBackfill() {
|
||||
const handle = turnHandle
|
||||
if (handle === undefined) return
|
||||
turnHandle = undefined
|
||||
|
||||
if (turnIdle && window.cancelIdleCallback) {
|
||||
window.cancelIdleCallback(handle)
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(handle)
|
||||
}
|
||||
|
||||
function scheduleTurnBackfill() {
|
||||
if (turnHandle !== undefined) return
|
||||
if (store.turnStart <= 0) return
|
||||
|
||||
if (window.requestIdleCallback) {
|
||||
turnIdle = true
|
||||
turnHandle = window.requestIdleCallback(() => {
|
||||
turnHandle = undefined
|
||||
backfillTurns()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
turnIdle = false
|
||||
turnHandle = window.setTimeout(() => {
|
||||
turnHandle = undefined
|
||||
backfillTurns()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function backfillTurns() {
|
||||
const start = store.turnStart
|
||||
if (start <= 0) return
|
||||
|
||||
const next = start - turnBatch
|
||||
const nextStart = next > 0 ? next : 0
|
||||
|
||||
const el = scroller
|
||||
if (!el) {
|
||||
setStore("turnStart", nextStart)
|
||||
scheduleTurnBackfill()
|
||||
return
|
||||
}
|
||||
|
||||
const beforeTop = el.scrollTop
|
||||
const beforeHeight = el.scrollHeight
|
||||
|
||||
setStore("turnStart", nextStart)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const delta = el.scrollHeight - beforeHeight
|
||||
if (delta) el.scrollTop = beforeTop + delta
|
||||
})
|
||||
|
||||
scheduleTurnBackfill()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.id, messagesReady()] as const,
|
||||
([id, ready]) => {
|
||||
cancelTurnBackfill()
|
||||
setStore("turnStart", 0)
|
||||
if (!id || !ready) return
|
||||
|
||||
const len = visibleUserMessages().length
|
||||
const start = len > turnInit ? len - turnInit : 0
|
||||
setStore("turnStart", start)
|
||||
scheduleTurnBackfill()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createResizeObserver(
|
||||
() => promptDock,
|
||||
({ height }) => {
|
||||
@@ -710,6 +878,21 @@ export default function Page() {
|
||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||
setActiveMessage(message)
|
||||
|
||||
const msgs = visibleUserMessages()
|
||||
const index = msgs.findIndex((m) => m.id === message.id)
|
||||
if (index !== -1 && index < store.turnStart) {
|
||||
setStore("turnStart", index)
|
||||
scheduleTurnBackfill()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(anchor(message.id))
|
||||
if (el) el.scrollIntoView({ behavior, block: "start" })
|
||||
})
|
||||
|
||||
updateHash(message.id)
|
||||
return
|
||||
}
|
||||
|
||||
const el = document.getElementById(anchor(message.id))
|
||||
if (el) el.scrollIntoView({ behavior, block: "start" })
|
||||
updateHash(message.id)
|
||||
@@ -755,12 +938,27 @@ export default function Page() {
|
||||
if (!sessionID || !ready) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const id = window.location.hash.slice(1)
|
||||
const hashTarget = id ? document.getElementById(id) : undefined
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) {
|
||||
autoScroll.forceScrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const hashTarget = document.getElementById(hash)
|
||||
if (hashTarget) {
|
||||
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
|
||||
return
|
||||
}
|
||||
|
||||
const match = hash.match(/^message-(.+)$/)
|
||||
if (match) {
|
||||
const msg = visibleUserMessages().find((m) => m.id === match[1])
|
||||
if (msg) {
|
||||
scrollToMessage(msg, "auto")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
autoScroll.forceScrollToBottom()
|
||||
})
|
||||
})
|
||||
@@ -769,7 +967,43 @@ export default function Page() {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
const previewPrompt = () =>
|
||||
prompt
|
||||
.current()
|
||||
.map((part) => {
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
if (part.type === "image") return `[image:${part.filename}]`
|
||||
return part.content
|
||||
})
|
||||
.join("")
|
||||
.trim()
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
handoff.prompt = previewPrompt()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!terminal.ready()) return
|
||||
handoff.terminals = terminal.all().map((t) => t.title)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!file.ready()) return
|
||||
handoff.files = Object.fromEntries(
|
||||
tabs()
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return []
|
||||
return [[path, file.selectedLines(path) ?? null] as const]
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
cancelTurnBackfill()
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
||||
})
|
||||
@@ -779,7 +1013,7 @@ export default function Page() {
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||
{/* Mobile tab bar - only shown on mobile when there are diffs */}
|
||||
<Show when={!isDesktop() && diffs().length > 0}>
|
||||
<Show when={!isDesktop() && hasReview()}>
|
||||
<Tabs class="h-auto">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger
|
||||
@@ -796,7 +1030,7 @@ export default function Page() {
|
||||
classes={{ button: "w-full" }}
|
||||
onClick={() => setStore("mobileTab", "review")}
|
||||
>
|
||||
{diffs().length} Files Changed
|
||||
{reviewCount()} Files Changed
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
@@ -821,21 +1055,26 @@ export default function Page() {
|
||||
when={!mobileReview()}
|
||||
fallback={
|
||||
<div class="relative h-full overflow-hidden">
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle="unified"
|
||||
onViewFile={(path) => {
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
}}
|
||||
classes={{
|
||||
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle="unified"
|
||||
onViewFile={(path) => {
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
}}
|
||||
classes={{
|
||||
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -868,42 +1107,82 @@ export default function Page() {
|
||||
"mt-0": showTabs(),
|
||||
}}
|
||||
>
|
||||
<For each={visibleUserMessages()}>
|
||||
{(message) => (
|
||||
<div
|
||||
id={anchor(message.id)}
|
||||
data-message-id={message.id}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
|
||||
platform.platform !== "desktop",
|
||||
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
|
||||
platform.platform === "desktop",
|
||||
<Show when={store.turnStart > 0}>
|
||||
<div class="w-full flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="text-12-medium opacity-50"
|
||||
onClick={() => setStore("turnStart", 0)}
|
||||
>
|
||||
Render earlier messages
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={historyMore()}>
|
||||
<div class="w-full flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="text-12-medium opacity-50"
|
||||
disabled={historyLoading()}
|
||||
onClick={() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
setStore("turnStart", 0)
|
||||
sync.session.history.loadMore(id)
|
||||
}}
|
||||
>
|
||||
<SessionTurn
|
||||
sessionID={params.id!}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={lastUserMessage()?.id}
|
||||
stepsExpanded={store.expanded[message.id] ?? false}
|
||||
onStepsExpandedToggle={() =>
|
||||
setStore("expanded", message.id, (open: boolean | undefined) => !open)
|
||||
}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content:
|
||||
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
||||
container:
|
||||
"px-4 md:px-6 " +
|
||||
(!showTabs()
|
||||
? "md:max-w-200 md:mx-auto"
|
||||
: visibleUserMessages().length > 1
|
||||
? "md:pr-6 md:pl-18"
|
||||
: ""),
|
||||
{historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={renderedUserMessages()}>
|
||||
{(message) => {
|
||||
if (import.meta.env.DEV) {
|
||||
onMount(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={anchor(message.id)}
|
||||
data-message-id={message.id}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
|
||||
platform.platform !== "desktop",
|
||||
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
|
||||
platform.platform === "desktop",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<SessionTurn
|
||||
sessionID={params.id!}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={lastUserMessage()?.id}
|
||||
stepsExpanded={store.expanded[message.id] ?? false}
|
||||
onStepsExpandedToggle={() =>
|
||||
setStore("expanded", message.id, (open: boolean | undefined) => !open)
|
||||
}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content:
|
||||
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
||||
container:
|
||||
"px-4 md:px-6 " +
|
||||
(!showTabs()
|
||||
? "md:max-w-200 md:mx-auto"
|
||||
: visibleUserMessages().length > 1
|
||||
? "md:pr-6 md:pl-18"
|
||||
: ""),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
@@ -944,13 +1223,22 @@ export default function Page() {
|
||||
"md:max-w-200": !showTabs(),
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||
/>
|
||||
<Show
|
||||
when={prompt.ready()}
|
||||
fallback={
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
{handoff.prompt || "Loading prompt..."}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PromptInput
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1034,31 +1322,40 @@ export default function Page() {
|
||||
</div>
|
||||
<Show when={reviewTab()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle={layout.review.diffStyle()}
|
||||
onDiffStyleChange={layout.review.setDiffStyle}
|
||||
onViewFile={(path) => {
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Show when={activeTab() === "review"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle={layout.review.diffStyle()}
|
||||
onDiffStyleChange={layout.review.setDiffStyle}
|
||||
onViewFile={(path) => {
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab
|
||||
messages={messages}
|
||||
visibleUserMessages={visibleUserMessages}
|
||||
view={view}
|
||||
info={info}
|
||||
/>
|
||||
</div>
|
||||
<Show when={activeTab() === "context"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab
|
||||
messages={messages}
|
||||
visibleUserMessages={visibleUserMessages}
|
||||
view={view}
|
||||
info={info}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
<For each={openedTabs()}>
|
||||
@@ -1107,7 +1404,8 @@ export default function Page() {
|
||||
const selectedLines = createMemo(() => {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
return file.selectedLines(p) ?? null
|
||||
if (file.ready()) return file.selectedLines(p) ?? null
|
||||
return handoff.files[p] ?? null
|
||||
})
|
||||
const selection = createMemo(() => {
|
||||
const range = selectedLines()
|
||||
@@ -1204,37 +1502,63 @@ export default function Page() {
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Show when={selection()}>
|
||||
{(sel) => (
|
||||
<div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
prompt.context.add({ type: "file", path: p, selection: sel() })
|
||||
}}
|
||||
>
|
||||
<Icon name="plus-small" size="small" />
|
||||
<span>Add {selectionLabel()} to context</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded && isImage()}>
|
||||
<div class="px-6 py-4 pb-40">
|
||||
<img src={imageDataUrl()} alt={path()} class="max-w-full" />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded && isSvg()}>
|
||||
<div class="flex flex-col gap-4 px-6 py-4">
|
||||
<Show when={activeTab() === tab}>
|
||||
<Show when={selection()}>
|
||||
{(sel) => (
|
||||
<div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
prompt.context.add({ type: "file", path: p, selection: sel() })
|
||||
}}
|
||||
>
|
||||
<Icon name="plus-small" size="small" />
|
||||
<span>Add {selectionLabel()} to context</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded && isImage()}>
|
||||
<div class="px-6 py-4 pb-40">
|
||||
<img src={imageDataUrl()} alt={path()} class="max-w-full" />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded && isSvg()}>
|
||||
<div class="flex flex-col gap-4 px-6 py-4">
|
||||
<Dynamic
|
||||
component={codeComponent}
|
||||
file={{
|
||||
name: path() ?? "",
|
||||
contents: svgContent() ?? "",
|
||||
cacheKey: cacheKey(),
|
||||
}}
|
||||
enableLineSelection
|
||||
selectedLines={selectedLines()}
|
||||
onLineSelected={(range: SelectedLineRange | null) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, range)
|
||||
}}
|
||||
overflow="scroll"
|
||||
class="select-text"
|
||||
/>
|
||||
<Show when={svgPreviewUrl()}>
|
||||
<div class="flex justify-center pb-40">
|
||||
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded}>
|
||||
<Dynamic
|
||||
component={codeComponent}
|
||||
file={{
|
||||
name: path() ?? "",
|
||||
contents: svgContent() ?? "",
|
||||
contents: contents(),
|
||||
cacheKey: cacheKey(),
|
||||
}}
|
||||
enableLineSelection
|
||||
@@ -1245,41 +1569,17 @@ export default function Page() {
|
||||
file.setSelectedLines(p, range)
|
||||
}}
|
||||
overflow="scroll"
|
||||
class="select-text"
|
||||
class="select-text pb-40"
|
||||
/>
|
||||
<Show when={svgPreviewUrl()}>
|
||||
<div class="flex justify-center pb-40">
|
||||
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded}>
|
||||
<Dynamic
|
||||
component={codeComponent}
|
||||
file={{
|
||||
name: path() ?? "",
|
||||
contents: contents(),
|
||||
cacheKey: cacheKey(),
|
||||
}}
|
||||
enableLineSelection
|
||||
selectedLines={selectedLines()}
|
||||
onLineSelected={(range: SelectedLineRange | null) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, range)
|
||||
}}
|
||||
overflow="scroll"
|
||||
class="select-text pb-40"
|
||||
/>
|
||||
</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">Loading...</div>
|
||||
</Match>
|
||||
<Match when={state()?.error}>
|
||||
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">Loading...</div>
|
||||
</Match>
|
||||
<Match when={state()?.error}>
|
||||
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
)
|
||||
}}
|
||||
@@ -1302,7 +1602,7 @@ export default function Page() {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={isDesktop() && layout.terminal.opened()}>
|
||||
<Show when={isDesktop() && view().terminal.opened()}>
|
||||
<div
|
||||
class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
|
||||
style={{ height: `${layout.terminal.height()}px` }}
|
||||
@@ -1314,56 +1614,76 @@ export default function Page() {
|
||||
max={window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={layout.terminal.resize}
|
||||
onCollapse={layout.terminal.close}
|
||||
onCollapse={view().terminal.close}
|
||||
/>
|
||||
<DragDropProvider
|
||||
onDragStart={handleTerminalDragStart}
|
||||
onDragEnd={handleTerminalDragEnd}
|
||||
onDragOver={handleTerminalDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
|
||||
<Tabs.List class="h-10">
|
||||
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
||||
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<TooltipKeybind
|
||||
title="New terminal"
|
||||
keybind={command.keybind("terminal.new")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
||||
</TooltipKeybind>
|
||||
<Show
|
||||
when={terminal.ready()}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
||||
<For each={handoff.terminals}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="flex-1" />
|
||||
<div class="text-text-weak pr-2">Loading...</div>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<For each={terminal.all()}>
|
||||
{(pty) => (
|
||||
<Tabs.Content value={pty.id}>
|
||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeTerminalDraggable}>
|
||||
{(draggedId) => {
|
||||
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
||||
return (
|
||||
<Show when={pty()}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{t().title}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DragDropProvider
|
||||
onDragStart={handleTerminalDragStart}
|
||||
onDragEnd={handleTerminalDragEnd}
|
||||
onDragOver={handleTerminalDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
|
||||
<Tabs.List class="h-10">
|
||||
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
||||
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<TooltipKeybind
|
||||
title="New terminal"
|
||||
keybind={command.keybind("terminal.new")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<For each={terminal.all()}>
|
||||
{(pty) => (
|
||||
<Tabs.Content value={pty.id}>
|
||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeTerminalDraggable}>
|
||||
{(draggedId) => {
|
||||
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
||||
return (
|
||||
<Show when={pty()}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{t().title}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
135
packages/app/src/utils/perf.ts
Normal file
135
packages/app/src/utils/perf.ts
Normal file
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -129,9 +129,9 @@ export default function Download() {
|
||||
</code>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
<button data-component="cli-row" onClick={handleCopyClick("brew install opencode")}>
|
||||
<button data-component="cli-row" onClick={handleCopyClick("brew install anomalyco/tap/opencode")}>
|
||||
<code>
|
||||
brew install <strong>opencode</strong>
|
||||
brew install <strong>anomalyco/tap/opencode</strong>
|
||||
</code>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function Home() {
|
||||
<button data-copy data-slot="command" onClick={handleCopyClick}>
|
||||
<span>
|
||||
<span data-slot="protocol">brew install </span>
|
||||
<span data-slot="highlight">opencode</span>
|
||||
<span data-slot="highlight">anomalyco/tap/opencode</span>
|
||||
</span>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
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 { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||
@@ -40,7 +41,7 @@ export async function POST(input: APIEvent) {
|
||||
.where(eq(BillingTable.customerID, customerID))
|
||||
})
|
||||
}
|
||||
if (body.type === "checkout.session.completed") {
|
||||
if (body.type === "checkout.session.completed" && body.data.object.mode === "payment") {
|
||||
const workspaceID = body.data.object.metadata?.workspaceID
|
||||
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
||||
const customerID = body.data.object.customer as string
|
||||
@@ -103,85 +104,112 @@ export async function POST(input: APIEvent) {
|
||||
})
|
||||
})
|
||||
}
|
||||
if (body.type === "charge.refunded") {
|
||||
if (body.type === "checkout.session.completed" && body.data.object.mode === "subscription") {
|
||||
const workspaceID = body.data.object.custom_fields.find((f) => f.key === "workspaceid")?.text?.value
|
||||
const amountInCents = body.data.object.amount_total as number
|
||||
const customerID = body.data.object.customer as string
|
||||
const paymentIntentID = body.data.object.payment_intent as string
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!paymentIntentID) throw new Error("Payment ID not found")
|
||||
const customerEmail = body.data.object.customer_details?.email as string
|
||||
const invoiceID = body.data.object.invoice as string
|
||||
const subscriptionID = body.data.object.subscription as string
|
||||
const promoCode = body.data.object.discounts?.[0]?.promotion_code as string
|
||||
|
||||
const workspaceID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
workspaceID: BillingTable.workspaceID,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.customerID, customerID))
|
||||
.then((rows) => rows[0]?.workspaceID),
|
||||
)
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
|
||||
const amount = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
amount: PaymentTable.amount,
|
||||
})
|
||||
.from(PaymentTable)
|
||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||
.then((rows) => rows[0]?.amount),
|
||||
)
|
||||
if (!amount) throw new Error("Payment not found")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(PaymentTable)
|
||||
.set({
|
||||
timeRefunded: new Date(body.created * 1000),
|
||||
})
|
||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} - ${amount}`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
})
|
||||
}
|
||||
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 (!amountInCents) throw new Error("Amount not found")
|
||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||
|
||||
// get payment id from invoice
|
||||
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")
|
||||
// get payment method for the payment intent
|
||||
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
||||
expand: ["payment_method"],
|
||||
})
|
||||
const paymentMethod = paymentIntent.payment_method
|
||||
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx.insert(PaymentTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("payment"),
|
||||
amount: centsToMicroCents(amountInCents),
|
||||
paymentID,
|
||||
invoiceID,
|
||||
customerID,
|
||||
}),
|
||||
)
|
||||
// get coupon id from promotion code
|
||||
const couponID = await (async () => {
|
||||
if (!promoCode) return
|
||||
const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
|
||||
const couponID = coupon.coupon.id
|
||||
if (!couponID) throw new Error("Coupon not found for promotion code")
|
||||
return couponID
|
||||
})()
|
||||
|
||||
// get user
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
// look up current billing
|
||||
const billing = await Billing.get()
|
||||
if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
|
||||
|
||||
// Temporarily skip this check because during Black drop, user can checkout
|
||||
// as a new customer
|
||||
//if (billing.customerID !== customerID) throw new Error("Customer ID mismatch")
|
||||
|
||||
// Temporarily check the user to apply to. After Black drop, we will allow
|
||||
// look up the user to apply to
|
||||
const users = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ id: UserTable.id, email: AuthTable.subject })
|
||||
.from(UserTable)
|
||||
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||
)
|
||||
const user = users.find((u) => u.email === customerEmail) ?? users[0]
|
||||
if (!user) {
|
||||
console.error(`Error: User with email ${customerEmail} not found in workspace ${workspaceID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// set customer metadata
|
||||
if (!billing?.customerID) {
|
||||
await Billing.stripe().customers.update(customerID, {
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID,
|
||||
subscriptionID,
|
||||
subscriptionCouponID: couponID,
|
||||
paymentMethodID: paymentMethod.id,
|
||||
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||
paymentMethodType: paymentMethod.type,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.insert(SubscriptionTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("subscription"),
|
||||
userID: user.id,
|
||||
})
|
||||
|
||||
await tx.insert(PaymentTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("payment"),
|
||||
amount: centsToMicroCents(amountInCents),
|
||||
paymentID,
|
||||
invoiceID,
|
||||
customerID,
|
||||
enrichment: {
|
||||
type: "subscription",
|
||||
couponID,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
if (body.type === "customer.subscription.created") {
|
||||
const data = {
|
||||
@@ -378,9 +406,111 @@ export async function POST(input: APIEvent) {
|
||||
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(BillingTable)
|
||||
.set({ subscriptionID: null, subscriptionCouponID: null })
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
|
||||
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
|
||||
})
|
||||
}
|
||||
if (body.type === "invoice.payment_succeeded") {
|
||||
if (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")
|
||||
|
||||
// get coupon id from subscription
|
||||
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscriptionID, {
|
||||
expand: ["discounts"],
|
||||
})
|
||||
const couponID =
|
||||
typeof subscriptionData.discounts[0] === "string"
|
||||
? subscriptionData.discounts[0]
|
||||
: subscriptionData.discounts[0]?.coupon?.id
|
||||
|
||||
// get payment id from invoice
|
||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||
expand: ["payments"],
|
||||
})
|
||||
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||
if (!paymentID) {
|
||||
// payment id can be undefined when using coupon
|
||||
if (!couponID) 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,
|
||||
enrichment: {
|
||||
type: "subscription",
|
||||
couponID,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (body.type === "charge.refunded") {
|
||||
const customerID = body.data.object.customer as string
|
||||
const paymentIntentID = body.data.object.payment_intent as string
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!paymentIntentID) throw new Error("Payment ID not found")
|
||||
|
||||
const workspaceID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
workspaceID: BillingTable.workspaceID,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.customerID, customerID))
|
||||
.then((rows) => rows[0]?.workspaceID),
|
||||
)
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
|
||||
const amount = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
amount: PaymentTable.amount,
|
||||
})
|
||||
.from(PaymentTable)
|
||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||
.then((rows) => rows[0]?.amount),
|
||||
)
|
||||
if (!amount) throw new Error("Payment not found")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(PaymentTable)
|
||||
.set({
|
||||
timeRefunded: new Date(body.created * 1000),
|
||||
})
|
||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} - ${amount}`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
|
||||
import { For, Show } from "solid-js"
|
||||
import { For, Match, Show, Switch } from "solid-js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { formatDateUTC, formatDateForTable } from "../../common"
|
||||
import styles from "./payment-section.module.css"
|
||||
@@ -77,7 +77,8 @@ export function PaymentSection() {
|
||||
<For each={payments()!}>
|
||||
{(payment) => {
|
||||
const date = new Date(payment.timeCreated)
|
||||
const isCredit = !payment.paymentID
|
||||
const amount =
|
||||
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
|
||||
return (
|
||||
<tr>
|
||||
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
||||
@@ -85,13 +86,14 @@ export function PaymentSection() {
|
||||
</td>
|
||||
<td data-slot="payment-id">{payment.id}</td>
|
||||
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
||||
${((payment.amount ?? 0) / 100000000).toFixed(2)}
|
||||
{isCredit ? " (credit)" : ""}
|
||||
${((amount ?? 0) / 100000000).toFixed(2)}
|
||||
<Switch>
|
||||
<Match when={payment.enrichment?.type === "credit"}> (credit)</Match>
|
||||
<Match when={payment.enrichment?.type === "subscription"}> (subscription)</Match>
|
||||
</Switch>
|
||||
</td>
|
||||
<td data-slot="payment-receipt">
|
||||
{isCredit ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
{payment.paymentID ? (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
|
||||
@@ -103,6 +105,8 @@ export function PaymentSection() {
|
||||
>
|
||||
View
|
||||
</button>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -44,6 +44,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
|
||||
eq(UsageTable.workspaceID, workspaceID),
|
||||
gte(UsageTable.timeCreated, startDate),
|
||||
lte(UsageTable.timeCreated, endDate),
|
||||
or(isNull(UsageTable.enrichment), sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') != 'sub'`),
|
||||
),
|
||||
)
|
||||
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { BillingTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { BillingTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { getWeekBounds } from "@opencode-ai/console-core/util/date.js"
|
||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
@@ -415,11 +416,11 @@ export async function handler(
|
||||
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
||||
},
|
||||
subscription: {
|
||||
timeSubscribed: UserTable.timeSubscribed,
|
||||
subIntervalUsage: UserTable.subIntervalUsage,
|
||||
subMonthlyUsage: UserTable.subMonthlyUsage,
|
||||
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
|
||||
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
|
||||
id: SubscriptionTable.id,
|
||||
rollingUsage: SubscriptionTable.rollingUsage,
|
||||
fixedUsage: SubscriptionTable.fixedUsage,
|
||||
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
||||
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
||||
},
|
||||
provider: {
|
||||
credentials: ProviderTable.credentials,
|
||||
@@ -440,6 +441,14 @@ export async function handler(
|
||||
)
|
||||
: sql`false`,
|
||||
)
|
||||
.leftJoin(
|
||||
SubscriptionTable,
|
||||
and(
|
||||
eq(SubscriptionTable.workspaceID, KeyTable.workspaceID),
|
||||
eq(SubscriptionTable.userID, KeyTable.userID),
|
||||
isNull(SubscriptionTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
@@ -448,7 +457,7 @@ export async function handler(
|
||||
logger.metric({
|
||||
api_key: data.apiKey,
|
||||
workspace: data.workspaceID,
|
||||
isSubscription: data.subscription.timeSubscribed ? true : false,
|
||||
isSubscription: data.subscription ? true : false,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -456,7 +465,7 @@ export async function handler(
|
||||
workspaceID: data.workspaceID,
|
||||
billing: data.billing,
|
||||
user: data.user,
|
||||
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
|
||||
subscription: data.subscription,
|
||||
provider: data.provider,
|
||||
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
||||
isDisabled: !!data.timeDisabled,
|
||||
@@ -484,23 +493,11 @@ export async function handler(
|
||||
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)
|
||||
// Check weekly limit
|
||||
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
||||
const week = getWeekBounds(now)
|
||||
if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
|
||||
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
|
||||
throw new SubscriptionError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||
retryAfter,
|
||||
@@ -508,14 +505,12 @@ export async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Check rolling limit
|
||||
if (sub.rollingUsage && sub.timeRollingUpdated) {
|
||||
const rollingWindowMs = black.rollingWindow * 3600 * 1000
|
||||
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
||||
if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
|
||||
const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
|
||||
throw new SubscriptionError(
|
||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||
retryAfter,
|
||||
@@ -661,38 +656,39 @@ export async function handler(
|
||||
.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),
|
||||
)
|
||||
const black = BlackData.get()
|
||||
const week = getWeekBounds(new Date())
|
||||
const rollingWindowSeconds = black.rollingWindow * 3600
|
||||
return [
|
||||
db
|
||||
.update(UserTable)
|
||||
.update(SubscriptionTable)
|
||||
.set({
|
||||
subMonthlyUsage: sql`
|
||||
fixedUsage: sql`
|
||||
CASE
|
||||
WHEN ${UserTable.timeSubMonthlyUsageUpdated} >= ${cycleStart} AND ${UserTable.timeSubMonthlyUsageUpdated} < ${cycleEnd} THEN ${UserTable.subMonthlyUsage} + ${cost}
|
||||
WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeSubMonthlyUsageUpdated: sql`now()`,
|
||||
subIntervalUsage: sql`
|
||||
timeFixedUpdated: sql`now()`,
|
||||
rollingUsage: sql`
|
||||
CASE
|
||||
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 3600)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 3600)) THEN ${UserTable.subIntervalUsage} + ${cost}
|
||||
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeSubIntervalUsageUpdated: sql`now()`,
|
||||
timeRollingUpdated: sql`
|
||||
CASE
|
||||
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.timeRollingUpdated}
|
||||
ELSE now()
|
||||
END
|
||||
`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||
.where(
|
||||
and(
|
||||
eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
|
||||
eq(SubscriptionTable.userID, authInfo.user.id),
|
||||
),
|
||||
),
|
||||
]
|
||||
})()
|
||||
: [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE `subscription` (
|
||||
`id` varchar(30) NOT NULL,
|
||||
`workspace_id` varchar(30) NOT NULL,
|
||||
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`time_deleted` timestamp(3),
|
||||
`user_id` varchar(30) NOT NULL,
|
||||
`rolling_usage` bigint,
|
||||
`fixed_usage` bigint,
|
||||
`time_rolling_updated` timestamp(3),
|
||||
`time_fixed_updated` timestamp(3),
|
||||
CONSTRAINT `subscription_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`)
|
||||
);
|
||||
6
packages/console/core/migrations/0047_huge_omega_red.sql
Normal file
6
packages/console/core/migrations/0047_huge_omega_red.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
CREATE INDEX `workspace_user_id` ON `subscription` (`workspace_id`,`user_id`);--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP COLUMN `time_subscribed`;--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP COLUMN `sub_interval_usage`;--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP COLUMN `sub_monthly_usage`;--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP COLUMN `sub_time_interval_usage_updated`;--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP COLUMN `sub_time_monthly_usage_updated`;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX `workspace_user_id` ON `subscription`;--> statement-breakpoint
|
||||
ALTER TABLE `subscription` ADD CONSTRAINT `workspace_user_id` UNIQUE(`workspace_id`,`user_id`);
|
||||
1
packages/console/core/migrations/0049_noisy_domino.sql
Normal file
1
packages/console/core/migrations/0049_noisy_domino.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `billing` ADD `subscription_coupon_id` varchar(28);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `payment` ADD `enrichment` json;
|
||||
1236
packages/console/core/migrations/meta/0046_snapshot.json
Normal file
1236
packages/console/core/migrations/meta/0046_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1207
packages/console/core/migrations/meta/0047_snapshot.json
Normal file
1207
packages/console/core/migrations/meta/0047_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1207
packages/console/core/migrations/meta/0048_snapshot.json
Normal file
1207
packages/console/core/migrations/meta/0048_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1214
packages/console/core/migrations/meta/0049_snapshot.json
Normal file
1214
packages/console/core/migrations/meta/0049_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1221
packages/console/core/migrations/meta/0050_snapshot.json
Normal file
1221
packages/console/core/migrations/meta/0050_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -323,6 +323,41 @@
|
||||
"when": 1767765497502,
|
||||
"tag": "0045_cuddly_diamondback",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "5",
|
||||
"when": 1767912262458,
|
||||
"tag": "0046_charming_black_bolt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "5",
|
||||
"when": 1767916965243,
|
||||
"tag": "0047_huge_omega_red",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "5",
|
||||
"when": 1767917785224,
|
||||
"tag": "0048_mean_frank_castle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 49,
|
||||
"version": "5",
|
||||
"when": 1767922954153,
|
||||
"tag": "0049_noisy_domino",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 50,
|
||||
"version": "5",
|
||||
"when": 1767931290031,
|
||||
"tag": "0050_bumpy_mephistopheles",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Database, eq, sql, inArray } from "../src/drizzle/index.js"
|
||||
import { Database, and, eq, 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, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
import { getWeekBounds } from "../src/util/date.js"
|
||||
|
||||
// get input from command line
|
||||
const identifier = process.argv[2]
|
||||
@@ -56,6 +59,44 @@ async function printWorkspace(workspaceID: string) {
|
||||
|
||||
printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
|
||||
|
||||
await printTable("Users", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
authEmail: AuthTable.subject,
|
||||
inviteEmail: UserTable.email,
|
||||
role: UserTable.role,
|
||||
timeSeen: UserTable.timeSeen,
|
||||
monthlyLimit: UserTable.monthlyLimit,
|
||||
monthlyUsage: UserTable.monthlyUsage,
|
||||
timeDeleted: UserTable.timeDeleted,
|
||||
fixedUsage: SubscriptionTable.fixedUsage,
|
||||
rollingUsage: SubscriptionTable.rollingUsage,
|
||||
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
||||
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
||||
timeSubscriptionCreated: SubscriptionTable.timeCreated,
|
||||
})
|
||||
.from(UserTable)
|
||||
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
|
||||
.where(eq(UserTable.workspaceID, workspace.id))
|
||||
.then((rows) =>
|
||||
rows.map((row) => {
|
||||
const subStatus = getSubscriptionStatus(row)
|
||||
return {
|
||||
email: (row.timeDeleted ? "❌ " : "") + (row.authEmail ?? row.inviteEmail),
|
||||
role: row.role,
|
||||
timeSeen: formatDate(row.timeSeen),
|
||||
monthly: formatMonthlyUsage(row.monthlyUsage, row.monthlyLimit),
|
||||
subscribed: formatDate(row.timeSubscriptionCreated),
|
||||
subWeekly: subStatus.weekly,
|
||||
subRolling: subStatus.rolling,
|
||||
rateLimited: subStatus.rateLimited,
|
||||
retryIn: subStatus.retryIn,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
await printTable("Billing", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
@@ -124,6 +165,80 @@ async function printWorkspace(workspaceID: string) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatMicroCents(value: number | null | undefined) {
|
||||
if (value === null || value === undefined) return null
|
||||
return `$${(value / 100000000).toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatDate(value: Date | null | undefined) {
|
||||
if (!value) return null
|
||||
return value.toISOString().split("T")[0]
|
||||
}
|
||||
|
||||
function formatMonthlyUsage(usage: number | null | undefined, limit: number | null | undefined) {
|
||||
const usageText = formatMicroCents(usage) ?? "$0.00"
|
||||
if (limit === null || limit === undefined) return `${usageText} / no limit`
|
||||
return `${usageText} / $${limit.toFixed(2)}`
|
||||
}
|
||||
|
||||
function 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`
|
||||
}
|
||||
|
||||
function getSubscriptionStatus(row: {
|
||||
timeSubscriptionCreated: Date | null
|
||||
fixedUsage: number | null
|
||||
rollingUsage: number | null
|
||||
timeFixedUpdated: Date | null
|
||||
timeRollingUpdated: Date | null
|
||||
}) {
|
||||
if (!row.timeSubscriptionCreated) {
|
||||
return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
|
||||
}
|
||||
|
||||
const black = BlackData.get()
|
||||
const now = new Date()
|
||||
const week = getWeekBounds(now)
|
||||
|
||||
const fixedLimit = black.fixedLimit ? centsToMicroCents(black.fixedLimit * 100) : null
|
||||
const rollingLimit = black.rollingLimit ? centsToMicroCents(black.rollingLimit * 100) : null
|
||||
const rollingWindowMs = (black.rollingWindow ?? 5) * 3600 * 1000
|
||||
|
||||
// Calculate current weekly usage (reset if outside current week)
|
||||
const currentWeekly =
|
||||
row.fixedUsage && row.timeFixedUpdated && row.timeFixedUpdated >= week.start ? row.fixedUsage : 0
|
||||
|
||||
// Calculate current rolling usage
|
||||
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
||||
const currentRolling =
|
||||
row.rollingUsage && row.timeRollingUpdated && row.timeRollingUpdated >= windowStart ? row.rollingUsage : 0
|
||||
|
||||
// Check rate limiting
|
||||
const isWeeklyLimited = fixedLimit !== null && currentWeekly >= fixedLimit
|
||||
const isRollingLimited = rollingLimit !== null && currentRolling >= rollingLimit
|
||||
|
||||
let retryIn: string | null = null
|
||||
if (isWeeklyLimited) {
|
||||
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
|
||||
retryIn = formatRetryTime(retryAfter)
|
||||
} else if (isRollingLimited && row.timeRollingUpdated) {
|
||||
const retryAfter = Math.ceil((row.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
|
||||
retryIn = formatRetryTime(retryAfter)
|
||||
}
|
||||
|
||||
return {
|
||||
weekly: fixedLimit !== null ? `${formatMicroCents(currentWeekly)} / $${black.fixedLimit}` : null,
|
||||
rolling: rollingLimit !== null ? `${formatMicroCents(currentRolling)} / $${black.rollingLimit}` : null,
|
||||
rateLimited: isWeeklyLimited || isRollingLimited ? "yes" : "no",
|
||||
retryIn,
|
||||
}
|
||||
}
|
||||
|
||||
function printHeader(title: string) {
|
||||
console.log()
|
||||
console.log("─".repeat(title.length))
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
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 { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
const email = process.argv[3]
|
||||
|
||||
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
|
||||
|
||||
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) {
|
||||
const customers = await Billing.stripe().customers.list({ email, limit: 10, expand: ["data.subscriptions"] })
|
||||
if (!customers.data) {
|
||||
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`)
|
||||
const customer = customers.data.find((c) => c.subscriptions?.data[0]?.items.data[0]?.price.unit_amount === 20000)
|
||||
if (!customer) {
|
||||
console.error(`Error: No Stripe customer found for email ${email} with $200 subscription`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const customerID = customer.id
|
||||
const subscription = customer.subscriptions!.data[0]
|
||||
const subscriptionID = subscription.id
|
||||
|
||||
// Validate the subscription is $200
|
||||
@@ -39,6 +39,12 @@ if (amountInCents !== 20000) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscription.id, { expand: ["discounts"] })
|
||||
const couponID =
|
||||
typeof subscriptionData.discounts[0] === "string"
|
||||
? subscriptionData.discounts[0]
|
||||
: subscriptionData.discounts[0]?.coupon?.id
|
||||
|
||||
// Check if subscription is already tied to another workspace
|
||||
const existingSubscription = await Database.use((tx) =>
|
||||
tx
|
||||
@@ -90,29 +96,21 @@ const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.re
|
||||
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) =>
|
||||
// Look up the user in the workspace
|
||||
const users = 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]),
|
||||
.select({ id: UserTable.id, email: AuthTable.subject })
|
||||
.from(UserTable)
|
||||
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||
)
|
||||
if (!auth) {
|
||||
console.error(`Error: No user found with email ${email}`)
|
||||
if (users.length === 0) {
|
||||
console.error(`Error: No users found in workspace ${workspaceID}`)
|
||||
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]),
|
||||
)
|
||||
const user = users.length === 1 ? users[0] : users.find((u) => u.email === email)
|
||||
if (!user) {
|
||||
console.error(`Error: User with email ${email} is not a member of workspace ${workspaceID}`)
|
||||
console.error(`Error: User with email ${email} not found in workspace ${workspaceID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -130,19 +128,19 @@ await Database.transaction(async (tx) => {
|
||||
.set({
|
||||
customerID,
|
||||
subscriptionID,
|
||||
subscriptionCouponID: couponID,
|
||||
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 subscription table
|
||||
await tx.insert(SubscriptionTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("subscription"),
|
||||
userID: user.id,
|
||||
})
|
||||
|
||||
// Create a row in payments table
|
||||
await tx.insert(PaymentTable).values({
|
||||
@@ -152,6 +150,10 @@ await Database.transaction(async (tx) => {
|
||||
customerID,
|
||||
invoiceID,
|
||||
paymentID,
|
||||
enrichment: {
|
||||
type: "subscription",
|
||||
couponID,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -171,6 +171,9 @@ export namespace Billing {
|
||||
workspaceID,
|
||||
id: Identifier.create("payment"),
|
||||
amount: amountInMicroCents,
|
||||
enrichment: {
|
||||
type: "credit",
|
||||
},
|
||||
})
|
||||
})
|
||||
return amountInMicroCents
|
||||
|
||||
@@ -4,9 +4,9 @@ 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(),
|
||||
fixedLimit: z.number().int(),
|
||||
rollingLimit: z.number().int(),
|
||||
rollingWindow: z.number().int(),
|
||||
})
|
||||
|
||||
export const validate = fn(Schema, (input) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export namespace Identifier {
|
||||
model: "mod",
|
||||
payment: "pay",
|
||||
provider: "prv",
|
||||
subscription: "sub",
|
||||
usage: "usg",
|
||||
user: "usr",
|
||||
workspace: "wrk",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const BillingTable = mysqlTable(
|
||||
timeReloadError: utc("time_reload_error"),
|
||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
|
||||
},
|
||||
(table) => [
|
||||
...workspaceIndexes(table),
|
||||
@@ -30,6 +31,20 @@ export const BillingTable = mysqlTable(
|
||||
],
|
||||
)
|
||||
|
||||
export const SubscriptionTable = mysqlTable(
|
||||
"subscription",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
userID: ulid("user_id").notNull(),
|
||||
rollingUsage: bigint("rolling_usage", { mode: "number" }),
|
||||
fixedUsage: bigint("fixed_usage", { mode: "number" }),
|
||||
timeRollingUpdated: utc("time_rolling_updated"),
|
||||
timeFixedUpdated: utc("time_fixed_updated"),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
|
||||
)
|
||||
|
||||
export const PaymentTable = mysqlTable(
|
||||
"payment",
|
||||
{
|
||||
@@ -40,6 +55,15 @@ export const PaymentTable = mysqlTable(
|
||||
paymentID: varchar("payment_id", { length: 255 }),
|
||||
amount: bigint("amount", { mode: "number" }).notNull(),
|
||||
timeRefunded: utc("time_refunded"),
|
||||
enrichment: json("enrichment").$type<
|
||||
| {
|
||||
type: "subscription"
|
||||
couponID?: string
|
||||
}
|
||||
| {
|
||||
type: "credit"
|
||||
}
|
||||
>(),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table)],
|
||||
)
|
||||
|
||||
@@ -18,12 +18,6 @@ export const UserTable = mysqlTable(
|
||||
monthlyLimit: int("monthly_limit"),
|
||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||
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) => [
|
||||
...workspaceIndexes(table),
|
||||
|
||||
9
packages/console/core/src/util/date.ts
Normal file
9
packages/console/core/src/util/date.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function getWeekBounds(date: Date) {
|
||||
const dayOfWeek = date.getUTCDay()
|
||||
const start = new Date(date)
|
||||
start.setUTCDate(date.getUTCDate() - dayOfWeek + 1)
|
||||
start.setUTCHours(0, 0, 0, 0)
|
||||
const end = new Date(start)
|
||||
end.setUTCDate(start.getUTCDate() + 7)
|
||||
return { start, end }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -13,36 +13,7 @@
|
||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||
<meta property="og:image" content="/social-share.png" />
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
<script id="oc-theme-preload-script">
|
||||
;(function () {
|
||||
var themeId = localStorage.getItem("opencode-theme-id")
|
||||
if (!themeId) return
|
||||
|
||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
var mode = isDark ? "dark" : "light"
|
||||
|
||||
document.documentElement.dataset.theme = themeId
|
||||
document.documentElement.dataset.colorScheme = mode
|
||||
|
||||
if (themeId === "oc-1") return
|
||||
|
||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||
if (css) {
|
||||
var style = document.createElement("style")
|
||||
style.id = "oc-theme-preload"
|
||||
style.textContent =
|
||||
":root{color-scheme:" +
|
||||
mode +
|
||||
";--text-mix-blend-mode:" +
|
||||
(isDark ? "plus-lighter" : "multiply") +
|
||||
";" +
|
||||
css +
|
||||
"}"
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use tauri::Manager;
|
||||
|
||||
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||
const CLI_BINARY_NAME: &str = "opencode";
|
||||
|
||||
@@ -9,9 +11,10 @@ fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_sidecar_path() -> std::path::PathBuf {
|
||||
tauri::utils::platform::current_exe()
|
||||
.expect("Failed to get current exe")
|
||||
pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
|
||||
// Get binary with symlinks support
|
||||
tauri::process::current_binary(&app.env())
|
||||
.expect("Failed to get current binary")
|
||||
.parent()
|
||||
.expect("Failed to get parent dir")
|
||||
.join("opencode-cli")
|
||||
@@ -26,12 +29,12 @@ fn is_cli_installed() -> bool {
|
||||
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
||||
|
||||
#[tauri::command]
|
||||
pub fn install_cli() -> Result<String, String> {
|
||||
pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
|
||||
if cfg!(not(unix)) {
|
||||
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
||||
}
|
||||
|
||||
let sidecar = get_sidecar_path();
|
||||
let sidecar = get_sidecar_path(&app);
|
||||
if !sidecar.exists() {
|
||||
return Err("Sidecar binary not found".to_string());
|
||||
}
|
||||
@@ -108,7 +111,7 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
|
||||
cli_version, app_version
|
||||
);
|
||||
|
||||
install_cli()?;
|
||||
install_cli(app)?;
|
||||
|
||||
println!("Synced installed CLI");
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ use tauri::{
|
||||
};
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::net::TcpSocket;
|
||||
|
||||
use crate::window_customizer::PinchZoomDisablePlugin;
|
||||
@@ -46,65 +45,6 @@ impl ServerState {
|
||||
struct LogState(Arc<Mutex<VecDeque<String>>>);
|
||||
|
||||
const MAX_LOG_ENTRIES: usize = 200;
|
||||
const GLOBAL_STORAGE: &str = "opencode.global.dat";
|
||||
|
||||
/// Check if a URL's origin matches any configured server in the store.
|
||||
/// Returns true if the URL should be allowed for internal navigation.
|
||||
fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool {
|
||||
// Always allow localhost and 127.0.0.1
|
||||
if let Some(host) = url.host_str() {
|
||||
if host == "localhost" || host == "127.0.0.1" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to read the server list from the store
|
||||
let Ok(store) = app.store(GLOBAL_STORAGE) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(server_data) = store.get("server") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Parse the server list from the stored JSON
|
||||
let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get the origin of the navigation URL (scheme + host + port)
|
||||
let url_origin = format!(
|
||||
"{}://{}{}",
|
||||
url.scheme(),
|
||||
url.host_str().unwrap_or(""),
|
||||
url.port().map(|p| format!(":{}", p)).unwrap_or_default()
|
||||
);
|
||||
|
||||
// Check if any configured server matches the URL's origin
|
||||
for server in list {
|
||||
let Some(server_url) = server.as_str() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Parse the server URL to extract its origin
|
||||
let Ok(parsed) = tauri::Url::parse(server_url) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let server_origin = format!(
|
||||
"{}://{}{}",
|
||||
parsed.scheme(),
|
||||
parsed.host_str().unwrap_or(""),
|
||||
parsed.port().map(|p| format!(":{}", p)).unwrap_or_default()
|
||||
);
|
||||
|
||||
if url_origin == server_origin {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn kill_sidecar(app: AppHandle) {
|
||||
@@ -189,7 +129,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let (mut rx, child) = {
|
||||
let sidecar = get_sidecar_path();
|
||||
let sidecar = get_sidecar_path(app);
|
||||
let shell = get_user_shell();
|
||||
app.shell()
|
||||
.command(&shell)
|
||||
@@ -296,7 +236,6 @@ pub fn run() {
|
||||
.unwrap_or(LogicalSize::new(1920, 1080));
|
||||
|
||||
// Create window immediately with serverReady = false
|
||||
let app_for_nav = app.clone();
|
||||
let mut window_builder =
|
||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
||||
.title("OpenCode")
|
||||
@@ -304,22 +243,6 @@ pub fn run() {
|
||||
.decorations(true)
|
||||
.zoom_hotkeys_enabled(true)
|
||||
.disable_drag_drop_handler()
|
||||
.on_navigation(move |url| {
|
||||
// Allow internal navigation (tauri:// scheme)
|
||||
if url.scheme() == "tauri" {
|
||||
return true;
|
||||
}
|
||||
// Allow navigation to configured servers (localhost, 127.0.0.1, or remote)
|
||||
if is_allowed_server(&app_for_nav, url) {
|
||||
return true;
|
||||
}
|
||||
// Open external http/https URLs in default browser
|
||||
if url.scheme() == "http" || url.scheme() == "https" {
|
||||
let _ = app_for_nav.shell().open(url.as_str(), None);
|
||||
return false; // Cancel internal navigation
|
||||
}
|
||||
true
|
||||
})
|
||||
.initialization_script(format!(
|
||||
r#"
|
||||
window.__OPENCODE__ ??= {{}};
|
||||
|
||||
@@ -6,6 +6,7 @@ const host = process.env.TAURI_DEV_HOST
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [appPlugin],
|
||||
publicDir: "../app/public",
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.6"
|
||||
version = "1.1.8"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -81,8 +81,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.69",
|
||||
"@opentui/solid": "0.1.69",
|
||||
"@opentui/core": "0.1.72",
|
||||
"@opentui/solid": "0.1.72",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -167,6 +167,8 @@ export namespace ACP {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
},
|
||||
@@ -242,6 +244,7 @@ export namespace ACP {
|
||||
kind,
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawInput: part.state.input,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
@@ -260,6 +263,9 @@ export namespace ACP {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "failed",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
rawInput: part.state.input,
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
@@ -491,6 +497,8 @@ export namespace ACP {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
},
|
||||
@@ -566,6 +574,7 @@ export namespace ACP {
|
||||
kind,
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawInput: part.state.input,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
@@ -584,6 +593,9 @@ export namespace ACP {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "failed",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
rawInput: part.state.input,
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
|
||||
@@ -209,6 +209,21 @@ export namespace Agent {
|
||||
item.options = mergeDeep(item.options, value.options ?? {})
|
||||
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
|
||||
}
|
||||
|
||||
// Ensure Truncate.DIR is allowed unless explicitly configured
|
||||
for (const name in result) {
|
||||
const agent = result[name]
|
||||
const explicit = agent.permission.some(
|
||||
(r) => r.permission === "external_directory" && r.pattern === Truncate.DIR && r.action === "deny",
|
||||
)
|
||||
if (explicit) continue
|
||||
|
||||
result[name].permission = PermissionNext.merge(
|
||||
result[name].permission,
|
||||
PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow" } }),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod"
|
||||
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
export namespace Auth {
|
||||
export const Oauth = z
|
||||
.object({
|
||||
|
||||
@@ -341,8 +341,6 @@ export const AuthLoginCommand = cmd({
|
||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
|
||||
)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (provider === "opencode") {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cmd } from "../cmd"
|
||||
|
||||
export const AgentCommand = cmd({
|
||||
command: "agent <name>",
|
||||
describe: "show agent configuration details",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("name", {
|
||||
type: "string",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cmd } from "../cmd"
|
||||
|
||||
export const ConfigCommand = cmd({
|
||||
command: "config",
|
||||
describe: "show resolved configuration",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Ripgrep } from "@/file/ripgrep"
|
||||
|
||||
const FileSearchCommand = cmd({
|
||||
command: "search <query>",
|
||||
describe: "search files by query",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("query", {
|
||||
type: "string",
|
||||
@@ -22,6 +23,7 @@ const FileSearchCommand = cmd({
|
||||
|
||||
const FileReadCommand = cmd({
|
||||
command: "read <path>",
|
||||
describe: "read file contents as JSON",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("path", {
|
||||
type: "string",
|
||||
@@ -38,6 +40,7 @@ const FileReadCommand = cmd({
|
||||
|
||||
const FileStatusCommand = cmd({
|
||||
command: "status",
|
||||
describe: "show file status information",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
@@ -49,6 +52,7 @@ const FileStatusCommand = cmd({
|
||||
|
||||
const FileListCommand = cmd({
|
||||
command: "list <path>",
|
||||
describe: "list files in a directory",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("path", {
|
||||
type: "string",
|
||||
@@ -65,6 +69,7 @@ const FileListCommand = cmd({
|
||||
|
||||
const FileTreeCommand = cmd({
|
||||
command: "tree [dir]",
|
||||
describe: "show directory tree",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("dir", {
|
||||
type: "string",
|
||||
@@ -79,6 +84,7 @@ const FileTreeCommand = cmd({
|
||||
|
||||
export const FileCommand = cmd({
|
||||
command: "file",
|
||||
describe: "file system debugging utilities",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(FileReadCommand)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AgentCommand } from "./agent"
|
||||
|
||||
export const DebugCommand = cmd({
|
||||
command: "debug",
|
||||
describe: "debugging and troubleshooting tools",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(ConfigCommand)
|
||||
@@ -25,6 +26,7 @@ export const DebugCommand = cmd({
|
||||
.command(PathsCommand)
|
||||
.command({
|
||||
command: "wait",
|
||||
describe: "wait indefinitely (for debugging)",
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
|
||||
@@ -37,6 +39,7 @@ export const DebugCommand = cmd({
|
||||
|
||||
const PathsCommand = cmd({
|
||||
command: "paths",
|
||||
describe: "show global paths (data, config, cache, state)",
|
||||
handler() {
|
||||
for (const [key, value] of Object.entries(Global.Path)) {
|
||||
console.log(key.padEnd(10), value)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EOL } from "os"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
describe: "LSP debugging utilities",
|
||||
builder: (yargs) =>
|
||||
yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
|
||||
async handler() {},
|
||||
@@ -13,6 +14,7 @@ export const LSPCommand = cmd({
|
||||
|
||||
const DiagnosticsCommand = cmd({
|
||||
command: "diagnostics <file>",
|
||||
describe: "get diagnostics for a file",
|
||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
@@ -25,6 +27,7 @@ const DiagnosticsCommand = cmd({
|
||||
|
||||
export const SymbolsCommand = cmd({
|
||||
command: "symbols <query>",
|
||||
describe: "search workspace symbols",
|
||||
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
@@ -37,6 +40,7 @@ export const SymbolsCommand = cmd({
|
||||
|
||||
export const DocumentSymbolsCommand = cmd({
|
||||
command: "document-symbols <uri>",
|
||||
describe: "get symbols from a document",
|
||||
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
|
||||
@@ -6,12 +6,14 @@ import { cmd } from "../cmd"
|
||||
|
||||
export const RipgrepCommand = cmd({
|
||||
command: "rg",
|
||||
describe: "ripgrep debugging utilities",
|
||||
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const TreeCommand = cmd({
|
||||
command: "tree",
|
||||
describe: "show file tree using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs.option("limit", {
|
||||
type: "number",
|
||||
@@ -25,6 +27,7 @@ const TreeCommand = cmd({
|
||||
|
||||
const FilesCommand = cmd({
|
||||
command: "files",
|
||||
describe: "list files using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("query", {
|
||||
@@ -56,6 +59,7 @@ const FilesCommand = cmd({
|
||||
|
||||
const SearchCommand = cmd({
|
||||
command: "search <pattern>",
|
||||
describe: "search file contents using ripgrep",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("pattern", {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cmd } from "../cmd"
|
||||
|
||||
export const ScrapCommand = cmd({
|
||||
command: "scrap",
|
||||
describe: "list all known projects",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
const timer = Log.Default.time("scrap")
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cmd } from "../cmd"
|
||||
|
||||
export const SkillCommand = cmd({
|
||||
command: "skill",
|
||||
describe: "list all available skills",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
|
||||
@@ -4,12 +4,14 @@ import { cmd } from "../cmd"
|
||||
|
||||
export const SnapshotCommand = cmd({
|
||||
command: "snapshot",
|
||||
describe: "snapshot debugging utilities",
|
||||
builder: (yargs) => yargs.command(TrackCommand).command(PatchCommand).command(DiffCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const TrackCommand = cmd({
|
||||
command: "track",
|
||||
describe: "track current snapshot state",
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await Snapshot.track())
|
||||
@@ -19,6 +21,7 @@ const TrackCommand = cmd({
|
||||
|
||||
const PatchCommand = cmd({
|
||||
command: "patch <hash>",
|
||||
describe: "show patch for a snapshot hash",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("hash", {
|
||||
type: "string",
|
||||
@@ -34,6 +37,7 @@ const PatchCommand = cmd({
|
||||
|
||||
const DiffCommand = cmd({
|
||||
command: "diff <hash>",
|
||||
describe: "show diff for a snapshot hash",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("hash", {
|
||||
type: "string",
|
||||
|
||||
@@ -653,9 +653,17 @@ function ErrorComponent(props: {
|
||||
mode?: "dark" | "light"
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
|
||||
const handleExit = async () => {
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
props.onExit()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
props.onExit()
|
||||
handleExit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
@@ -708,7 +716,7 @@ function ErrorComponent(props: {
|
||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={props.onExit} backgroundColor={colors.primary} padding={1}>
|
||||
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -33,76 +33,82 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
|
||||
const options = createMemo(() => {
|
||||
const q = query()
|
||||
const favorites = showExtra() ? local.model.favorite() : []
|
||||
const needle = q.trim()
|
||||
const showSections = showExtra() && needle.length === 0
|
||||
const favorites = connected() ? local.model.favorite() : []
|
||||
const recents = local.model.recent()
|
||||
|
||||
const recentList = showExtra()
|
||||
const recentList = showSections
|
||||
? recents.filter(
|
||||
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
|
||||
)
|
||||
: []
|
||||
|
||||
const favoriteOptions = favorites.flatMap((item) => {
|
||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||
if (!provider) return []
|
||||
const model = provider.models[item.modelID]
|
||||
if (!model) return []
|
||||
return [
|
||||
{
|
||||
key: item,
|
||||
value: {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
category: "Favorites",
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
const favoriteOptions = showSections
|
||||
? favorites.flatMap((item) => {
|
||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||
if (!provider) return []
|
||||
const model = provider.models[item.modelID]
|
||||
if (!model) return []
|
||||
return [
|
||||
{
|
||||
key: item,
|
||||
value: {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
category: "Favorites",
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
: []
|
||||
|
||||
const recentOptions = recentList.flatMap((item) => {
|
||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||
if (!provider) return []
|
||||
const model = provider.models[item.modelID]
|
||||
if (!model) return []
|
||||
return [
|
||||
{
|
||||
key: item,
|
||||
value: {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
category: "Recent",
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
const recentOptions = showSections
|
||||
? recentList.flatMap((item) => {
|
||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||
if (!provider) return []
|
||||
const model = provider.models[item.modelID]
|
||||
if (!model) return []
|
||||
return [
|
||||
{
|
||||
key: item,
|
||||
value: {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
category: "Recent",
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
: []
|
||||
|
||||
const providerOptions = pipe(
|
||||
sync.data.provider,
|
||||
@@ -145,6 +151,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
}
|
||||
}),
|
||||
filter((x) => {
|
||||
if (!showSections) return true
|
||||
const value = x.value
|
||||
const inFavorites = favorites.some(
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
@@ -177,16 +184,11 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
: []
|
||||
|
||||
// Apply fuzzy filtering to each section separately, maintaining section order
|
||||
if (q) {
|
||||
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj)
|
||||
const filteredRecents = fuzzysort
|
||||
.go(q, recentOptions, { keys: ["title"] })
|
||||
.map((x) => x.obj)
|
||||
.slice(0, 5)
|
||||
const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
|
||||
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
|
||||
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
|
||||
// Search shows a single merged list (favorites inline)
|
||||
if (needle) {
|
||||
const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
|
||||
const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj)
|
||||
return [...filteredProviders, ...filteredPopular]
|
||||
}
|
||||
|
||||
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
|
||||
|
||||
@@ -53,6 +53,7 @@ export type AutocompleteRef = {
|
||||
|
||||
export type AutocompleteOption = {
|
||||
display: string
|
||||
value?: string
|
||||
aliases?: string[]
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
@@ -221,6 +222,7 @@ export function Autocomplete(props: {
|
||||
const isDir = item.endsWith("/")
|
||||
return {
|
||||
display: Locale.truncateMiddle(filename, width),
|
||||
value: filename,
|
||||
isDirectory: isDir,
|
||||
path: item,
|
||||
onSelect: () => {
|
||||
@@ -259,8 +261,10 @@ export function Autocomplete(props: {
|
||||
const width = props.anchor().width - 4
|
||||
|
||||
for (const res of Object.values(sync.data.mcp_resource)) {
|
||||
const text = `${res.name} (${res.uri})`
|
||||
options.push({
|
||||
display: Locale.truncateMiddle(`${res.name} (${res.uri})`, width),
|
||||
display: Locale.truncateMiddle(text, width),
|
||||
value: text,
|
||||
description: res.description,
|
||||
onSelect: () => {
|
||||
insertPart(res.name, {
|
||||
@@ -485,7 +489,11 @@ export function Autocomplete(props: {
|
||||
}
|
||||
|
||||
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
|
||||
keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""],
|
||||
keys: [
|
||||
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
|
||||
"description",
|
||||
(obj) => obj.aliases?.join(" ") ?? "",
|
||||
],
|
||||
limit: 10,
|
||||
scoreFn: (objResults) => {
|
||||
const displayResult = objResults[0]
|
||||
|
||||
@@ -26,7 +26,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
return ready()
|
||||
},
|
||||
signal<T>(name: string, defaultValue: T) {
|
||||
if (!kvStore[name]) setKvStore(name, defaultValue)
|
||||
if (kvStore[name] === undefined) setKvStore(name, defaultValue)
|
||||
return [
|
||||
function () {
|
||||
return result.get(name)
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useRouteData } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { pipe, sumBy } from "remeda"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder, EmptyBorder } from "@tui/component/border"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
|
||||
const Title = (props: { session: Accessor<Session> }) => {
|
||||
@@ -33,7 +32,6 @@ export function Header() {
|
||||
const sync = useSync()
|
||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
@@ -99,24 +97,6 @@ export function Header() {
|
||||
<Title session={session} />
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
<Show when={shareEnabled()}>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexGrow={1} flexShrink={1}>
|
||||
<Switch>
|
||||
<Match when={session().share?.url}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{session().share!.url}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
/share <span style={{ fg: theme.textMuted }}>copy link</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
@@ -52,7 +53,6 @@ import { useDialog } from "../../ui/dialog"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import type { PromptInfo } from "../../component/prompt/history"
|
||||
import { iife } from "@/util/iife"
|
||||
import { DialogConfirm } from "@tui/ui/dialog-confirm"
|
||||
import { DialogTimeline } from "./dialog-timeline"
|
||||
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
|
||||
@@ -92,7 +92,6 @@ const context = createContext<{
|
||||
conceal: () => boolean
|
||||
showThinking: () => boolean
|
||||
showTimestamps: () => boolean
|
||||
usernameVisible: () => boolean
|
||||
showDetails: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
sync: ReturnType<typeof useSync>
|
||||
@@ -137,24 +136,25 @@ export function Session() {
|
||||
})
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
|
||||
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "hide")
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [conceal, setConceal] = createSignal(true)
|
||||
const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true))
|
||||
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
|
||||
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
|
||||
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
|
||||
const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true))
|
||||
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
|
||||
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
|
||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
||||
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
|
||||
const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
|
||||
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
||||
|
||||
const wide = createMemo(() => dimensions().width > 120)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
if (session()?.parentID) return false
|
||||
if (sidebar() === "show") return true
|
||||
if (sidebarOpen()) return true
|
||||
if (sidebar() === "auto" && wide()) return true
|
||||
return false
|
||||
})
|
||||
const showTimestamps = createMemo(() => timestamps() === "show")
|
||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
||||
|
||||
const scrollAcceleration = createMemo(() => {
|
||||
@@ -455,26 +455,10 @@ export function Session() {
|
||||
keybind: "sidebar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
setSidebar((prev) => {
|
||||
if (prev === "auto") return sidebarVisible() ? "hide" : "show"
|
||||
if (prev === "show") return "hide"
|
||||
return "show"
|
||||
})
|
||||
if (sidebar() === "show") kv.set("sidebar", "auto")
|
||||
if (sidebar() === "hide") kv.set("sidebar", "hide")
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: usernameVisible() ? "Hide username" : "Show username",
|
||||
value: "session.username_visible.toggle",
|
||||
keybind: "username_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
setUsernameVisible((prev) => {
|
||||
const next = !prev
|
||||
kv.set("username_visible", next)
|
||||
return next
|
||||
batch(() => {
|
||||
const isVisible = sidebarVisible()
|
||||
setSidebar(() => (isVisible ? "hide" : "auto"))
|
||||
setSidebarOpen(!isVisible)
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -494,11 +478,7 @@ export function Session() {
|
||||
value: "session.toggle.timestamps",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
setShowTimestamps((prev) => {
|
||||
const next = !prev
|
||||
kv.set("timestamps", next ? "show" : "hide")
|
||||
return next
|
||||
})
|
||||
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -507,11 +487,7 @@ export function Session() {
|
||||
value: "session.toggle.thinking",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
setShowThinking((prev) => {
|
||||
const next = !prev
|
||||
kv.set("thinking_visibility", next)
|
||||
return next
|
||||
})
|
||||
setShowThinking((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -530,9 +506,7 @@ export function Session() {
|
||||
keybind: "tool_details",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
const newValue = !showDetails()
|
||||
setShowDetails(newValue)
|
||||
kv.set("tool_details_visibility", newValue)
|
||||
setShowDetails((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -542,11 +516,7 @@ export function Session() {
|
||||
keybind: "scrollbar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
setShowScrollbar((prev) => {
|
||||
const next = !prev
|
||||
kv.set("scrollbar_visible", next)
|
||||
return next
|
||||
})
|
||||
setShowScrollbar((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -555,11 +525,7 @@ export function Session() {
|
||||
value: "session.toggle.animations",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
setAnimationsEnabled((prev) => {
|
||||
const next = !prev
|
||||
kv.set("animations_enabled", next)
|
||||
return next
|
||||
})
|
||||
setAnimationsEnabled((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -913,7 +879,6 @@ export function Session() {
|
||||
conceal,
|
||||
showThinking,
|
||||
showTimestamps,
|
||||
usernameVisible,
|
||||
showDetails,
|
||||
diffWrapMode,
|
||||
sync,
|
||||
@@ -922,7 +887,7 @@ export function Session() {
|
||||
<box flexDirection="row">
|
||||
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<Show when={session()}>
|
||||
<Show when={!sidebarVisible()}>
|
||||
<Show when={!sidebarVisible() || !wide()}>
|
||||
<Header />
|
||||
</Show>
|
||||
<scrollbox
|
||||
@@ -1063,14 +1028,28 @@ export function Session() {
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</box>
|
||||
<Show when={!sidebarVisible()}>
|
||||
<Footer />
|
||||
</Show>
|
||||
</Show>
|
||||
<Toast />
|
||||
</box>
|
||||
<Show when={sidebarVisible()}>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
<Switch>
|
||||
<Match when={wide()}>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</Match>
|
||||
<Match when={!wide()}>
|
||||
<box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
alignItems="flex-end"
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
|
||||
>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</box>
|
||||
</context.Provider>
|
||||
@@ -1103,6 +1082,7 @@ function UserMessage(props: {
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const queued = createMemo(() => props.pending && props.message.id > props.pending)
|
||||
const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
|
||||
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
|
||||
|
||||
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
|
||||
|
||||
@@ -1132,7 +1112,7 @@ function UserMessage(props: {
|
||||
>
|
||||
<text fg={theme.text}>{text()?.text}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const bg = createMemo(() => {
|
||||
@@ -1150,23 +1130,22 @@ function UserMessage(props: {
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>
|
||||
{ctx.usernameVisible() ? `${sync.data.config.username ?? "You "}` : "You "}
|
||||
<Show
|
||||
when={queued()}
|
||||
fallback={
|
||||
<Show when={ctx.showTimestamps()}>
|
||||
<Show
|
||||
when={queued()}
|
||||
fallback={
|
||||
<Show when={ctx.showTimestamps()}>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
{ctx.usernameVisible() ? " · " : " "}
|
||||
{Locale.todayTimeOrDateTime(props.message.time.created)}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<span> </span>
|
||||
</text>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
|
||||
</Show>
|
||||
</text>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -1324,8 +1303,16 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
||||
// Pending messages moved to individual tool pending functions
|
||||
|
||||
function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
|
||||
const ctx = use()
|
||||
const sync = useSync()
|
||||
|
||||
// Hide tool if showDetails is false and tool completed successfully
|
||||
const shouldHide = createMemo(() => {
|
||||
if (ctx.showDetails()) return false
|
||||
if (props.part.state.status !== "completed") return false
|
||||
return true
|
||||
})
|
||||
|
||||
const toolprops = {
|
||||
get metadata() {
|
||||
return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
|
||||
@@ -1350,53 +1337,55 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.tool === "bash"}>
|
||||
<Bash {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "glob"}>
|
||||
<Glob {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<Read {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "grep"}>
|
||||
<Grep {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "list"}>
|
||||
<List {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "webfetch"}>
|
||||
<WebFetch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "codesearch"}>
|
||||
<CodeSearch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "websearch"}>
|
||||
<WebSearch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<Write {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<Edit {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "task"}>
|
||||
<Task {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "patch"}>
|
||||
<Patch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "todowrite"}>
|
||||
<TodoWrite {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "question"}>
|
||||
<Question {...toolprops} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<GenericTool {...toolprops} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={!shouldHide()}>
|
||||
<Switch>
|
||||
<Match when={props.part.tool === "bash"}>
|
||||
<Bash {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "glob"}>
|
||||
<Glob {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<Read {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "grep"}>
|
||||
<Grep {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "list"}>
|
||||
<List {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "webfetch"}>
|
||||
<WebFetch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "codesearch"}>
|
||||
<CodeSearch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "websearch"}>
|
||||
<WebSearch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<Write {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<Edit {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "task"}>
|
||||
<Task {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "patch"}>
|
||||
<Patch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "todowrite"}>
|
||||
<TodoWrite {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "question"}>
|
||||
<Question {...toolprops} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<GenericTool {...toolprops} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1535,15 +1524,30 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
|
||||
}
|
||||
|
||||
function Bash(props: ToolProps<typeof BashTool>) {
|
||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||
const { theme } = useTheme()
|
||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const lines = createMemo(() => output().split("\n"))
|
||||
const overflow = createMemo(() => lines().length > 10)
|
||||
const limited = createMemo(() => {
|
||||
if (expanded() || !overflow()) return output()
|
||||
return [...lines().slice(0, 10), "…"].join("\n")
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.output !== undefined}>
|
||||
<BlockTool title={"# " + (props.input.description ?? "Shell")} part={props.part}>
|
||||
<BlockTool
|
||||
title={"# " + (props.input.description ?? "Shell")}
|
||||
part={props.part}
|
||||
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
|
||||
>
|
||||
<box gap={1}>
|
||||
<text fg={theme.text}>$ {props.input.command}</text>
|
||||
<text fg={theme.text}>{output()}</text>
|
||||
<text fg={theme.text}>{limited()}</text>
|
||||
<Show when={overflow()}>
|
||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
@@ -1836,6 +1840,12 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
|
||||
function Question(props: ToolProps<typeof QuestionTool>) {
|
||||
const { theme } = useTheme()
|
||||
const count = createMemo(() => props.input.questions?.length ?? 0)
|
||||
|
||||
function format(answer?: string[]) {
|
||||
if (!answer?.length) return "(no answer)"
|
||||
return answer.join(", ")
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.answers}>
|
||||
@@ -1845,7 +1855,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
|
||||
{(q, i) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>{q.question}</text>
|
||||
<text fg={theme.text}>{props.metadata.answers?.[i()] || "(no answer)"}</text>
|
||||
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useKeyboard } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
|
||||
@@ -17,11 +17,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const bindings = useTextareaKeybindings()
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const single = createMemo(() => questions().length === 1)
|
||||
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single)
|
||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
||||
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: [] as string[],
|
||||
answers: [] as QuestionAnswer[],
|
||||
custom: [] as string[],
|
||||
selected: 0,
|
||||
editing: false,
|
||||
@@ -34,10 +34,15 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const other = createMemo(() => store.selected === options().length)
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const customPicked = createMemo(() => {
|
||||
const value = input()
|
||||
if (!value) return false
|
||||
return store.answers[store.tab]?.includes(value) ?? false
|
||||
})
|
||||
|
||||
function submit() {
|
||||
// Fill in empty answers with empty strings
|
||||
const answers = questions().map((_, i) => store.answers[i] ?? "")
|
||||
const answers = questions().map((_, i) => store.answers[i] ?? [])
|
||||
sdk.client.question.reply({
|
||||
requestID: props.request.id,
|
||||
answers,
|
||||
@@ -52,7 +57,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
|
||||
function pick(answer: string, custom: boolean = false) {
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = answer
|
||||
answers[store.tab] = [answer]
|
||||
setStore("answers", answers)
|
||||
if (custom) {
|
||||
const inputs = [...store.custom]
|
||||
@@ -62,7 +67,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
if (single()) {
|
||||
sdk.client.question.reply({
|
||||
requestID: props.request.id,
|
||||
answers: [answer],
|
||||
answers: [[answer]],
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -70,6 +75,17 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
function toggle(answer: string) {
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
const index = next.indexOf(answer)
|
||||
if (index === -1) next.push(answer)
|
||||
if (index !== -1) next.splice(index, 1)
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
}
|
||||
|
||||
const dialog = useDialog()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
@@ -82,11 +98,49 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
}
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
const text = textarea?.plainText?.trim()
|
||||
if (text) {
|
||||
pick(text, true)
|
||||
const text = textarea?.plainText?.trim() ?? ""
|
||||
const prev = store.custom[store.tab]
|
||||
|
||||
if (!text) {
|
||||
if (prev) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = ""
|
||||
setStore("custom", inputs)
|
||||
}
|
||||
|
||||
const answers = [...store.answers]
|
||||
if (prev) {
|
||||
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
|
||||
}
|
||||
if (!prev) {
|
||||
answers[store.tab] = []
|
||||
}
|
||||
setStore("answers", answers)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
if (multi()) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = text
|
||||
setStore("custom", inputs)
|
||||
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
if (prev) {
|
||||
const index = next.indexOf(prev)
|
||||
if (index !== -1) next.splice(index, 1)
|
||||
}
|
||||
if (!next.includes(text)) next.push(text)
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
pick(text, true)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
// Let textarea handle all other keys
|
||||
@@ -133,13 +187,25 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
if (other()) {
|
||||
setStore("editing", true)
|
||||
} else {
|
||||
const opt = opts[store.selected]
|
||||
if (opt) {
|
||||
pick(opt.label)
|
||||
if (!multi()) {
|
||||
setStore("editing", true)
|
||||
return
|
||||
}
|
||||
const value = input()
|
||||
if (value && customPicked()) {
|
||||
toggle(value)
|
||||
return
|
||||
}
|
||||
setStore("editing", true)
|
||||
return
|
||||
}
|
||||
const opt = opts[store.selected]
|
||||
if (!opt) return
|
||||
if (multi()) {
|
||||
toggle(opt.label)
|
||||
return
|
||||
}
|
||||
pick(opt.label)
|
||||
}
|
||||
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
@@ -162,7 +228,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const isActive = () => index() === store.tab
|
||||
const isAnswered = () => store.answers[index()] !== undefined
|
||||
const isAnswered = () => {
|
||||
return (store.answers[index()]?.length ?? 0) > 0
|
||||
}
|
||||
return (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
@@ -185,13 +253,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
<Show when={!confirm()}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.text}>{question()?.question}</text>
|
||||
<text fg={theme.text}>
|
||||
{question()?.question}
|
||||
{multi() ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
</box>
|
||||
<box>
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const active = () => i() === store.selected
|
||||
const picked = () => store.answers[store.tab] === opt.label
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
@@ -212,25 +283,30 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
<box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
|
||||
<text fg={other() ? theme.secondary : input() ? theme.success : theme.text}>
|
||||
{options().length + 1}. Other
|
||||
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
|
||||
{options().length + 1}. Type your own answer
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.success}>{input() ? "✓" : ""}</text>
|
||||
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
|
||||
</box>
|
||||
<Show when={store.editing}>
|
||||
<textarea
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
focused
|
||||
placeholder="Type your own answer"
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.primary}
|
||||
keyBindings={bindings()}
|
||||
/>
|
||||
<box paddingLeft={3}>
|
||||
<textarea
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
focused
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.primary}
|
||||
keyBindings={bindings()}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!store.editing && input()}>
|
||||
<text fg={theme.textMuted}>{input()}</text>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{input()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
@@ -243,11 +319,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
</box>
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const answer = () => store.answers[index()]
|
||||
const value = () => store.answers[index()]?.join(", ") ?? ""
|
||||
const answered = () => Boolean(value())
|
||||
return (
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{q.header}:</text>
|
||||
<text fg={answer() ? theme.text : theme.error}>{answer() ?? "(not answered)"}</text>
|
||||
<text fg={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
@@ -275,8 +352,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>{confirm() ? "submit" : single() ? "submit" : "confirm"}</span>
|
||||
enter{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
{confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
|
||||
</span>
|
||||
</text>
|
||||
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
|
||||
</text>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useDirectory } from "../../context/directory"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
|
||||
export function Sidebar(props: { sessionID: string }) {
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID)!)
|
||||
@@ -77,6 +77,7 @@ export function Sidebar(props: { sessionID: string }) {
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
position={props.overlay ? "absolute" : "relative"}
|
||||
>
|
||||
<scrollbox flexGrow={1}>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
|
||||
@@ -62,6 +62,7 @@ function init() {
|
||||
current.onClose?.()
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
refocus()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ function getNetworkIPs() {
|
||||
export const WebCommand = cmd({
|
||||
command: "web",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "starts a headless opencode server",
|
||||
describe: "start opencode server and open web interface",
|
||||
handler: async (args) => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
|
||||
@@ -178,6 +178,8 @@ export namespace Config {
|
||||
result.compaction = { ...result.compaction, prune: false }
|
||||
}
|
||||
|
||||
result.plugin = deduplicatePlugins(result.plugin ?? [])
|
||||
|
||||
return {
|
||||
config: result,
|
||||
directories,
|
||||
@@ -332,6 +334,58 @@ export namespace Config {
|
||||
return plugins
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a canonical plugin name from a plugin specifier.
|
||||
* - For file:// URLs: extracts filename without extension
|
||||
* - For npm packages: extracts package name without version
|
||||
*
|
||||
* @example
|
||||
* getPluginName("file:///path/to/plugin/foo.js") // "foo"
|
||||
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
|
||||
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
|
||||
*/
|
||||
export function getPluginName(plugin: string): string {
|
||||
if (plugin.startsWith("file://")) {
|
||||
return path.parse(new URL(plugin).pathname).name
|
||||
}
|
||||
const lastAt = plugin.lastIndexOf("@")
|
||||
if (lastAt > 0) {
|
||||
return plugin.substring(0, lastAt)
|
||||
}
|
||||
return plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates plugins by name, with later entries (higher priority) winning.
|
||||
* Priority order (highest to lowest):
|
||||
* 1. Local plugin/ directory
|
||||
* 2. Local opencode.json
|
||||
* 3. Global plugin/ directory
|
||||
* 4. Global opencode.json
|
||||
*
|
||||
* Since plugins are added in low-to-high priority order,
|
||||
* we reverse, deduplicate (keeping first occurrence), then restore order.
|
||||
*/
|
||||
export function deduplicatePlugins(plugins: string[]): string[] {
|
||||
// seenNames: canonical plugin names for duplicate detection
|
||||
// e.g., "oh-my-opencode", "@scope/pkg"
|
||||
const seenNames = new Set<string>()
|
||||
|
||||
// uniqueSpecifiers: full plugin specifiers to return
|
||||
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
|
||||
const uniqueSpecifiers: string[] = []
|
||||
|
||||
for (const specifier of plugins.toReversed()) {
|
||||
const name = getPluginName(specifier)
|
||||
if (!seenNames.has(name)) {
|
||||
seenNames.add(name)
|
||||
uniqueSpecifiers.push(specifier)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueSpecifiers.toReversed()
|
||||
}
|
||||
|
||||
export const McpLocal = z
|
||||
.object({
|
||||
type: z.literal("local").describe("Type of MCP server connection"),
|
||||
|
||||
@@ -13,6 +13,11 @@ export namespace Flag {
|
||||
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
|
||||
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
|
||||
export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH")
|
||||
export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
|
||||
export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT =
|
||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
|
||||
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
|
||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
|
||||
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
|
||||
export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
|
||||
|
||||
|
||||
@@ -348,7 +348,7 @@ export const rustfmt: Info = {
|
||||
}
|
||||
|
||||
export const cargofmt: Info = {
|
||||
name: "cargo fmt",
|
||||
name: "cargofmt",
|
||||
command: ["cargo", "fmt", "--", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
|
||||
@@ -33,7 +33,7 @@ await Promise.all([
|
||||
fs.mkdir(Global.Path.bin, { recursive: true }),
|
||||
])
|
||||
|
||||
const CACHE_VERSION = "14"
|
||||
const CACHE_VERSION = "16"
|
||||
|
||||
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
||||
.text()
|
||||
|
||||
@@ -99,14 +99,16 @@ const cli = yargs(hideBin(process.argv))
|
||||
.command(GithubCommand)
|
||||
.command(PrCommand)
|
||||
.command(SessionCommand)
|
||||
.fail((msg) => {
|
||||
.fail((msg, err) => {
|
||||
if (
|
||||
msg.startsWith("Unknown argument") ||
|
||||
msg.startsWith("Not enough non-option arguments") ||
|
||||
msg.startsWith("Invalid values:")
|
||||
msg?.startsWith("Unknown argument") ||
|
||||
msg?.startsWith("Not enough non-option arguments") ||
|
||||
msg?.startsWith("Invalid values:")
|
||||
) {
|
||||
if (err) throw err
|
||||
cli.showHelp("log")
|
||||
}
|
||||
if (err) throw err
|
||||
process.exit(1)
|
||||
})
|
||||
.strict()
|
||||
|
||||
417
packages/opencode/src/plugin/codex.ts
Normal file
417
packages/opencode/src/plugin/codex.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { Log } from "../util/log"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
const ISSUER = "https://auth.openai.com"
|
||||
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
|
||||
const OAUTH_PORT = 1455
|
||||
|
||||
interface PkceCodes {
|
||||
verifier: string
|
||||
challenge: string
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<PkceCodes> {
|
||||
const verifier = generateRandomString(43)
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(verifier)
|
||||
const hash = await crypto.subtle.digest("SHA-256", data)
|
||||
const challenge = base64UrlEncode(hash)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
||||
return Array.from(bytes)
|
||||
.map((b) => chars[b % chars.length])
|
||||
.join("")
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
const binary = String.fromCharCode(...bytes)
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
scope: "openid profile email offline_access",
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
id_token_add_organizations: "true",
|
||||
codex_cli_simplified_flow: "true",
|
||||
state,
|
||||
originator: "opencode",
|
||||
})
|
||||
return `${ISSUER}/oauth/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
id_token: string
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
||||
const response = await fetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: pkce.verifier,
|
||||
}).toString(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token exchange failed: ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
||||
const response = await fetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
}).toString(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenCode - Codex Authorization Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { color: #4ade80; margin-bottom: 1rem; }
|
||||
p { color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Successful</h1>
|
||||
<p>You can close this window and return to OpenCode.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenCode - Codex Authorization Failed</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { color: #f87171; margin-bottom: 1rem; }
|
||||
p { color: #aaa; }
|
||||
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>An error occurred during authorization.</p>
|
||||
<div class="error">${error}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
interface PendingOAuth {
|
||||
pkce: PkceCodes
|
||||
state: string
|
||||
resolve: (tokens: TokenResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||
if (oauthServer) {
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
oauthServer = Bun.serve({
|
||||
port: OAUTH_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
return new Response("Login cancelled", { status: 200 })
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (oauthServer) {
|
||||
oauthServer.stop()
|
||||
oauthServer = undefined
|
||||
log.info("codex oauth server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
if (pendingOAuth) {
|
||||
pendingOAuth = undefined
|
||||
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
) // 5 minute timeout
|
||||
|
||||
pendingOAuth = {
|
||||
pkce,
|
||||
state,
|
||||
resolve: (tokens) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(tokens)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
return {
|
||||
auth: {
|
||||
provider: "openai",
|
||||
async loader(getAuth, provider) {
|
||||
const auth = await getAuth()
|
||||
if (auth.type !== "oauth") return {}
|
||||
|
||||
// Filter models to only allowed Codex models for OAuth
|
||||
const allowedModels = new Set(["gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2", "gpt-5.2-codex"])
|
||||
for (const modelId of Object.keys(provider.models)) {
|
||||
if (!allowedModels.has(modelId)) {
|
||||
delete provider.models[modelId]
|
||||
}
|
||||
}
|
||||
|
||||
if (!provider.models["gpt-5.2-codex"]) {
|
||||
provider.models["gpt-5.2-codex"] = {
|
||||
id: "gpt-5.2-codex",
|
||||
providerID: "openai",
|
||||
api: {
|
||||
id: "gpt-5.2-codex",
|
||||
url: "https://chatgpt.com/backend-api/codex",
|
||||
npm: "@ai-sdk/openai",
|
||||
},
|
||||
name: "GPT-5.2 Codex",
|
||||
capabilities: {
|
||||
temperature: false,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 400000, output: 128000 },
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
}
|
||||
}
|
||||
|
||||
// Zero out costs for Codex (included with ChatGPT subscription)
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: OAUTH_DUMMY_KEY,
|
||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
// Remove dummy API key authorization header
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.delete("authorization")
|
||||
init.headers.delete("Authorization")
|
||||
} else if (Array.isArray(init.headers)) {
|
||||
init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization")
|
||||
} else {
|
||||
delete init.headers["authorization"]
|
||||
delete init.headers["Authorization"]
|
||||
}
|
||||
}
|
||||
|
||||
const currentAuth = await getAuth()
|
||||
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||
|
||||
// Check if token needs refresh
|
||||
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
||||
log.info("refreshing codex access token")
|
||||
const tokens = await refreshAccessToken(currentAuth.refresh)
|
||||
await input.client.auth.set({
|
||||
path: { id: "codex" },
|
||||
body: {
|
||||
type: "oauth",
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
},
|
||||
})
|
||||
currentAuth.access = tokens.access_token
|
||||
}
|
||||
|
||||
// Build headers
|
||||
const headers = new Headers()
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.forEach((value, key) => headers.set(key, value))
|
||||
} else if (Array.isArray(init.headers)) {
|
||||
for (const [key, value] of init.headers) {
|
||||
if (value !== undefined) headers.set(key, String(value))
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(init.headers)) {
|
||||
if (value !== undefined) headers.set(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set authorization header with access token
|
||||
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
||||
|
||||
// Rewrite URL to Codex endpoint
|
||||
let url: URL
|
||||
if (typeof requestInput === "string") {
|
||||
url = new URL(requestInput)
|
||||
} else if (requestInput instanceof URL) {
|
||||
url = requestInput
|
||||
} else {
|
||||
url = new URL(requestInput.url)
|
||||
}
|
||||
|
||||
// If this is a messages/responses request, redirect to Codex endpoint
|
||||
if (url.pathname.includes("/v1/responses") || url.pathname.includes("/chat/completions")) {
|
||||
url = new URL(CODEX_API_ENDPOINT)
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: [
|
||||
{
|
||||
label: "ChatGPT Pro/Plus",
|
||||
type: "oauth",
|
||||
authorize: async () => {
|
||||
const { redirectUri } = await startOAuthServer()
|
||||
const pkce = await generatePKCE()
|
||||
const state = generateState()
|
||||
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
|
||||
|
||||
const callbackPromise = waitForOAuthCallback(pkce, state)
|
||||
|
||||
return {
|
||||
url: authUrl,
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
method: "auto" as const,
|
||||
callback: async () => {
|
||||
const tokens = await callbackPromise
|
||||
stopOAuthServer()
|
||||
return {
|
||||
type: "success" as const,
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,15 @@ import { Server } from "../server/server"
|
||||
import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
const BUILTIN = ["opencode-copilot-auth@0.0.9", "opencode-anthropic-auth@0.0.5"]
|
||||
const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.8"]
|
||||
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const client = createOpencodeClient({
|
||||
@@ -20,7 +24,7 @@ export namespace Plugin {
|
||||
fetch: async (...args) => Server.App().fetch(...args),
|
||||
})
|
||||
const config = await Config.get()
|
||||
const hooks = []
|
||||
const hooks: Hooks[] = []
|
||||
const input: PluginInput = {
|
||||
client,
|
||||
project: Instance.project,
|
||||
@@ -29,11 +33,23 @@ export namespace Plugin {
|
||||
serverUrl: Server.url(),
|
||||
$: Bun.$,
|
||||
}
|
||||
|
||||
// Load internal plugins first
|
||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||
for (const plugin of INTERNAL_PLUGINS) {
|
||||
log.info("loading internal plugin", { name: plugin.name })
|
||||
const init = await plugin(input)
|
||||
hooks.push(init)
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = [...(config.plugin ?? [])]
|
||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||
plugins.push(...BUILTIN)
|
||||
}
|
||||
for (let plugin of plugins) {
|
||||
// ignore old codex plugin since it is supported first party now
|
||||
if (plugin.includes("opencode-openai-codex-auth")) continue
|
||||
log.info("loading plugin", { path: plugin })
|
||||
if (!plugin.startsWith("file://")) {
|
||||
const lastAtIndex = plugin.lastIndexOf("@")
|
||||
|
||||
@@ -852,6 +852,7 @@ export namespace Provider {
|
||||
if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat"))
|
||||
delete provider.models[modelID]
|
||||
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
|
||||
if (model.status === "deprecated") delete provider.models[modelID]
|
||||
if (
|
||||
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
|
||||
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
|
||||
|
||||
@@ -23,6 +23,7 @@ export namespace Question {
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().max(12).describe("Very short label (max 12 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
})
|
||||
.meta({
|
||||
ref: "QuestionInfo",
|
||||
@@ -46,8 +47,15 @@ export namespace Question {
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Answer = z.array(z.string()).meta({
|
||||
ref: "QuestionAnswer",
|
||||
})
|
||||
export type Answer = z.infer<typeof Answer>
|
||||
|
||||
export const Reply = z.object({
|
||||
answers: z.array(z.string()).describe("User answers in order of questions"),
|
||||
answers: z
|
||||
.array(Answer)
|
||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
@@ -58,7 +66,7 @@ export namespace Question {
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
requestID: z.string(),
|
||||
answers: z.array(z.string()),
|
||||
answers: z.array(Answer),
|
||||
}),
|
||||
),
|
||||
Rejected: BusEvent.define(
|
||||
@@ -75,7 +83,7 @@ export namespace Question {
|
||||
string,
|
||||
{
|
||||
info: Request
|
||||
resolve: (answers: string[]) => void
|
||||
resolve: (answers: Answer[]) => void
|
||||
reject: (e: any) => void
|
||||
}
|
||||
> = {}
|
||||
@@ -89,13 +97,13 @@ export namespace Question {
|
||||
sessionID: string
|
||||
questions: Info[]
|
||||
tool?: { messageID: string; callID: string }
|
||||
}): Promise<string[]> {
|
||||
}): Promise<Answer[]> {
|
||||
const s = await state()
|
||||
const id = Identifier.ascending("question")
|
||||
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
return new Promise<Answer[]>((resolve, reject) => {
|
||||
const info: Request = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
@@ -111,7 +119,7 @@ export namespace Question {
|
||||
})
|
||||
}
|
||||
|
||||
export async function reply(input: { requestID: string; answers: string[] }): Promise<void> {
|
||||
export async function reply(input: { requestID: string; answers: Answer[] }): Promise<void> {
|
||||
const s = await state()
|
||||
const existing = s.pending[input.requestID]
|
||||
if (!existing) {
|
||||
|
||||
@@ -52,7 +52,7 @@ export const QuestionRoute = new Hono()
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ answers: z.array(z.string()) })),
|
||||
validator("json", Question.Reply),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
|
||||
@@ -74,6 +74,7 @@ export namespace Server {
|
||||
const app = new Hono()
|
||||
export const App: () => Hono = lazy(
|
||||
() =>
|
||||
// TODO: Break server.ts into smaller route files to fix type inference
|
||||
app
|
||||
.onError((err, c) => {
|
||||
log.error("failed", {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user