Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4af389a2a2 | ||
|
|
fbc60399a2 |
@@ -1,3 +0,0 @@
|
|||||||
### What does this PR do?
|
|
||||||
|
|
||||||
### How did you verify your code works?
|
|
||||||
@@ -9,13 +9,6 @@ on:
|
|||||||
- "nix/**"
|
- "nix/**"
|
||||||
- "packages/app/**"
|
- "packages/app/**"
|
||||||
- "packages/desktop/**"
|
- "packages/desktop/**"
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "flake.nix"
|
|
||||||
- "flake.lock"
|
|
||||||
- "nix/**"
|
|
||||||
- "packages/app/**"
|
|
||||||
- "packages/desktop/**"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
name: PR Standards
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, edited, synchronize]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-standards:
|
|
||||||
if: |
|
|
||||||
github.event.pull_request.user.login != 'actions-user' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode' &&
|
|
||||||
github.event.pull_request.user.login != 'rekram1-node' &&
|
|
||||||
github.event.pull_request.user.login != 'thdxr' &&
|
|
||||||
github.event.pull_request.user.login != 'kommander' &&
|
|
||||||
github.event.pull_request.user.login != 'jayair' &&
|
|
||||||
github.event.pull_request.user.login != 'fwang' &&
|
|
||||||
github.event.pull_request.user.login != 'adamdotdevin' &&
|
|
||||||
github.event.pull_request.user.login != 'iamdavidhill' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode-agent[bot]'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Check PR standards
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const pr = context.payload.pull_request;
|
|
||||||
const title = pr.title;
|
|
||||||
|
|
||||||
async function addLabel(label) {
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: pr.number,
|
|
||||||
labels: [label]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeLabel(label) {
|
|
||||||
try {
|
|
||||||
await github.rest.issues.removeLabel({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: pr.number,
|
|
||||||
name: label
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// Label wasn't present, ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function comment(marker, body) {
|
|
||||||
const markerText = `<!-- pr-standards:${marker} -->`;
|
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: pr.number
|
|
||||||
});
|
|
||||||
|
|
||||||
const existing = comments.find(c => c.body.includes(markerText));
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: pr.number,
|
|
||||||
body: markerText + '\n' + body
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Check title format
|
|
||||||
// Matches: feat:, feat(scope):, feat (scope):, etc.
|
|
||||||
const titlePattern = /^(feat|fix|docs|chore|refactor|test)\s*(\([a-zA-Z0-9-]+\))?\s*:/;
|
|
||||||
const hasValidTitle = titlePattern.test(title);
|
|
||||||
|
|
||||||
if (!hasValidTitle) {
|
|
||||||
await addLabel('needs:title');
|
|
||||||
await comment('title', `Hey! Your PR title \`${title}\` doesn't follow conventional commit format.
|
|
||||||
|
|
||||||
Please update it to start with one of:
|
|
||||||
- \`feat:\` or \`feat(scope):\` new feature
|
|
||||||
- \`fix:\` or \`fix(scope):\` bug fix
|
|
||||||
- \`docs:\` or \`docs(scope):\` documentation changes
|
|
||||||
- \`chore:\` or \`chore(scope):\` maintenance tasks
|
|
||||||
- \`refactor:\` or \`refactor(scope):\` code refactoring
|
|
||||||
- \`test:\` or \`test(scope):\` adding or updating tests
|
|
||||||
|
|
||||||
Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`).
|
|
||||||
|
|
||||||
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await removeLabel('needs:title');
|
|
||||||
|
|
||||||
// Step 2: Check for linked issue (skip for docs/refactor PRs)
|
|
||||||
const skipIssueCheck = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
|
||||||
if (skipIssueCheck) {
|
|
||||||
await removeLabel('needs:issue');
|
|
||||||
console.log('Skipping issue check for docs/refactor PR');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const query = `
|
|
||||||
query($owner: String!, $repo: String!, $number: Int!) {
|
|
||||||
repository(owner: $owner, name: $repo) {
|
|
||||||
pullRequest(number: $number) {
|
|
||||||
closingIssuesReferences(first: 1) {
|
|
||||||
totalCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await github.graphql(query, {
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
number: pr.number
|
|
||||||
});
|
|
||||||
|
|
||||||
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
|
|
||||||
|
|
||||||
if (linkedIssues === 0) {
|
|
||||||
await addLabel('needs:issue');
|
|
||||||
await comment('issue', `Thanks for your contribution!
|
|
||||||
|
|
||||||
This PR doesn't have a linked issue. All PRs must reference an existing issue.
|
|
||||||
|
|
||||||
Please:
|
|
||||||
1. Open an issue describing the bug/feature (if one doesn't exist)
|
|
||||||
2. Add \`Fixes #<number>\` or \`Closes #<number>\` to this PR description
|
|
||||||
|
|
||||||
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await removeLabel('needs:issue');
|
|
||||||
console.log('PR meets all standards');
|
|
||||||
@@ -177,22 +177,8 @@ jobs:
|
|||||||
cargo tauri --version
|
cargo tauri --version
|
||||||
|
|
||||||
- name: Build and upload artifacts
|
- name: Build and upload artifacts
|
||||||
uses: Wandalen/wretry.action@v3
|
timeout-minutes: 20
|
||||||
timeout-minutes: 60
|
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||||
with:
|
|
||||||
attempt_limit: 3
|
|
||||||
attempt_delay: 10000
|
|
||||||
action: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
|
||||||
with: |
|
|
||||||
projectPath: packages/desktop
|
|
||||||
uploadWorkflowArtifacts: true
|
|
||||||
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
|
||||||
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
|
|
||||||
updaterJsonPreferNsis: true
|
|
||||||
releaseId: ${{ needs.publish.outputs.release }}
|
|
||||||
tagName: ${{ needs.publish.outputs.tag }}
|
|
||||||
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
|
||||||
releaseDraft: true
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
|
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
|
||||||
@@ -204,6 +190,16 @@ jobs:
|
|||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||||
|
with:
|
||||||
|
projectPath: packages/desktop
|
||||||
|
uploadWorkflowArtifacts: true
|
||||||
|
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
||||||
|
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
|
||||||
|
updaterJsonPreferNsis: true
|
||||||
|
releaseId: ${{ needs.publish.outputs.release }}
|
||||||
|
tagName: ${{ needs.publish.outputs.tag }}
|
||||||
|
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
||||||
|
releaseDraft: true
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
needs:
|
needs:
|
||||||
|
|||||||
+4
-56
@@ -149,63 +149,11 @@ With that said, you may want to try these methods, as they might work for you.
|
|||||||
|
|
||||||
## Pull Request Expectations
|
## Pull Request Expectations
|
||||||
|
|
||||||
### Issue First Policy
|
- Try to keep pull requests small and focused.
|
||||||
|
- Link relevant issue(s) in the description
|
||||||
**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review.
|
|
||||||
|
|
||||||
- Use `Fixes #123` or `Closes #123` in your PR description to link the issue
|
|
||||||
- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem
|
|
||||||
|
|
||||||
### General Requirements
|
|
||||||
|
|
||||||
- Keep pull requests small and focused
|
|
||||||
- Explain the issue and why your change fixes it
|
- Explain the issue and why your change fixes it
|
||||||
- Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase
|
- 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.
|
||||||
### UI Changes
|
|
||||||
|
|
||||||
If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback.
|
|
||||||
|
|
||||||
### Logic Changes
|
|
||||||
|
|
||||||
For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**:
|
|
||||||
|
|
||||||
- What did you test?
|
|
||||||
- How can a reviewer reproduce/confirm the fix?
|
|
||||||
|
|
||||||
### No AI-Generated Walls of Text
|
|
||||||
|
|
||||||
Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time:
|
|
||||||
|
|
||||||
- Write short, focused descriptions
|
|
||||||
- Explain what changed and why in your own words
|
|
||||||
- If you can't explain it briefly, your PR might be too large
|
|
||||||
|
|
||||||
### PR Titles
|
|
||||||
|
|
||||||
PR titles should follow conventional commit standards:
|
|
||||||
|
|
||||||
- `feat:` new feature or functionality
|
|
||||||
- `fix:` bug fix
|
|
||||||
- `docs:` documentation or README changes
|
|
||||||
- `chore:` maintenance tasks, dependency updates, etc.
|
|
||||||
- `refactor:` code refactoring without changing behavior
|
|
||||||
- `test:` adding or updating tests
|
|
||||||
|
|
||||||
You can optionally include a scope to indicate which package is affected:
|
|
||||||
|
|
||||||
- `feat(app):` feature in the app package
|
|
||||||
- `fix(desktop):` bug fix in the desktop package
|
|
||||||
- `chore(opencode):` maintenance in the opencode package
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- `docs: update contributing guidelines`
|
|
||||||
- `fix: resolve crash on startup`
|
|
||||||
- `feat: add dark mode support`
|
|
||||||
- `feat(app): add dark mode support`
|
|
||||||
- `fix(desktop): resolve crash on startup`
|
|
||||||
- `chore: bump dependency versions`
|
|
||||||
|
|
||||||
### Style Preferences
|
### Style Preferences
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
|||||||
scoop bucket add extras; scoop install extras/opencode # Windows
|
scoop bucket add extras; scoop install extras/opencode # Windows
|
||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
||||||
brew install opencode # macOS and Linux (official brew formula, updated less)
|
brew install opencode # macOS and Linux (official brew formula, updated less frequently)
|
||||||
paru -S opencode-bin # Arch Linux
|
paru -S opencode-bin # Arch Linux
|
||||||
mise use -g opencode # Any OS
|
mise use -g opencode # Any OS
|
||||||
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
||||||
|
|||||||
@@ -194,5 +194,3 @@
|
|||||||
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
|
| 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-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-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) |
|
|
||||||
|
|||||||
+1
-2
@@ -1,8 +1,7 @@
|
|||||||
## Style Guide
|
## Style Guide
|
||||||
|
|
||||||
- Try to keep things in one function unless composable or reusable
|
- Try to keep things in one function unless composable or reusable
|
||||||
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
|
- AVOID unnecessary destructuring of variables
|
||||||
= obj` just reference it as obj.a and obj.b. this preserves context
|
|
||||||
- AVOID `try`/`catch` where possible
|
- AVOID `try`/`catch` where possible
|
||||||
- AVOID `else` statements
|
- AVOID `else` statements
|
||||||
- AVOID using `any` type
|
- AVOID using `any` type
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "2.0.0",
|
"@ai-sdk/anthropic": "2.0.0",
|
||||||
"@ai-sdk/openai": "2.0.2",
|
"@ai-sdk/openai": "2.0.2",
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -286,8 +286,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||||
"@opentui/core": "0.1.72",
|
"@opentui/core": "0.1.69",
|
||||||
"@opentui/solid": "0.1.72",
|
"@opentui/solid": "0.1.69",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -350,7 +350,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -370,7 +370,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.88.1",
|
"@hey-api/openapi-ts": "0.88.1",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -381,7 +381,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -394,7 +394,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -433,7 +433,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -444,7 +444,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1201,21 +1201,21 @@
|
|||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.1.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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.72", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RoU48kOrhLZYDBiXaDu1LXS2bwRdlJlFle8eUQiqJjLRbMIY34J/srBuL0JnAS3qKW4J34NepUQa0l0/S43Q3w=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.69", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d9RPAh84O2XIyMw+7+X0fEyi+4KH5sPk9AxLze8GHRBGOzkRunqagFCLBrN5VFs2e2nbhIYtjMszo7gcpWyh7g=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.72", "", { "os": "darwin", "cpu": "x64" }, "sha512-hHUQw8i2LWPToRW1rjAiRqmNf34iJPS9ve9CJDygvFs5JOqUxN5yrfLfKfE+1bQjfFDHnpqW1HUk96iLhkPj8Q=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.69", "", { "os": "darwin", "cpu": "x64" }, "sha512-41K9zkL2IG0ahL+8Gd+e9ulMrnJF6lArPzG7grjWzo+FWEZwvw0WLCO1/Gn5K85G8Yx7gQXkZOUaw1BmHjxoRw=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.72", "", { "os": "linux", "cpu": "arm64" }, "sha512-63yml0OQ8tVa0JuDF9lBAWiChX6Q+iDO7lKv7c2n0352n/WyPr3iAgq4uSoH49HXuKeAXY/VwHGjvPzjXD/SDA=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.69", "", { "os": "linux", "cpu": "arm64" }, "sha512-IcUjwjuIpX3BBG1a9kjMqWrHYCFHAVfjh5nIRozWZZoqaczLzJb3nJeF2eg8aDeIoGhXvERWB1r1gmqPW8u3vQ=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.72", "", { "os": "linux", "cpu": "x64" }, "sha512-51veiQXNLvzDsFzsEvt71uK7WhiRe2DnvlJSGBSe6aRRHHxjCFYHzYi7t6bitJqtDTUj+EaMPbH81oZ6xy7tyg=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.69", "", { "os": "linux", "cpu": "x64" }, "sha512-5S9vqEIq7q+MEdp4cT0HLegBWu0pWLcletHZL80bsLbJt9OT8en3sQmL5bvas9sIuyeBFru9bfCmrQ/gnVTTiA=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.72", "", { "os": "win32", "cpu": "arm64" }, "sha512-1Ep6OcaYTy1RlLOln+LNN7DL1iNyLwLjG2M8aO0pVJKFvxeD5P7rdRzY065E4uhkHeJIHuduUqxvUjD0dyuwbw=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.69", "", { "os": "win32", "cpu": "arm64" }, "sha512-eSKcGwbcnJJPtrTFJI7STZ7inSYeedHS0swwjZhh9SADAruEz08intamunOslffv5+mnlvRp7UBGK35cMjbv/w=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.69", "", { "os": "win32", "cpu": "x64" }, "sha512-OjG/0jqYXURqbbUwNgSPrBA6yuKF3OOFh8JSG7VvzoYHJFJRmwVWY0fztWv/hgGHe354ti37c7JDJBQ44HOCdA=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": "sha256-+QM5BDFxzrm1HY5ealjCm7jIO1t/rpW1q4GGLViPMmA="
|
"nodeModules": "sha256-rNGq0yjL5ZHYVg+zyV4nFPug4gqhKhyOnfebaufyd34="
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-1
@@ -14,7 +14,36 @@
|
|||||||
<meta property="og:image" content="/social-share.png" />
|
<meta property="og:image" content="/social-share.png" />
|
||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
||||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
<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>
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
;(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)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
+15
-10
@@ -38,6 +38,9 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultServerUrl = iife(() => {
|
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 (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
|
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
|
||||||
if (import.meta.env.DEV)
|
if (import.meta.env.DEV)
|
||||||
@@ -105,16 +108,18 @@ export function AppInterface() {
|
|||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
<Route
|
<Route
|
||||||
path="/session/:id?"
|
path="/session/:id?"
|
||||||
component={() => (
|
component={(p) => (
|
||||||
<TerminalProvider>
|
<Show when={p.params.id ?? "new"} keyed>
|
||||||
<FileProvider>
|
<TerminalProvider>
|
||||||
<PromptProvider>
|
<FileProvider>
|
||||||
<Suspense fallback={<Loading />}>
|
<PromptProvider>
|
||||||
<Session />
|
<Suspense fallback={<Loading />}>
|
||||||
</Suspense>
|
<Session />
|
||||||
</PromptProvider>
|
</Suspense>
|
||||||
</FileProvider>
|
</PromptProvider>
|
||||||
</TerminalProvider>
|
</FileProvider>
|
||||||
|
</TerminalProvider>
|
||||||
|
</Show>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import { createMemo, createSignal, For, Show } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
|
function getFilename(input: string) {
|
||||||
|
const parts = input.split("/")
|
||||||
|
return parts[parts.length - 1] || input
|
||||||
|
}
|
||||||
|
|
||||||
export function DialogEditProject(props: { project: LocalProject }) {
|
export function DialogEditProject(props: { project: LocalProject }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export function DialogSelectFile() {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
const view = createMemo(() => layout.view(sessionKey()))
|
|
||||||
return (
|
return (
|
||||||
<Dialog title="Select file">
|
<Dialog title="Select file">
|
||||||
<List
|
<List
|
||||||
@@ -28,7 +27,7 @@ export function DialogSelectFile() {
|
|||||||
const value = file.tab(path)
|
const value = file.tab(path)
|
||||||
tabs().open(value)
|
tabs().open(value)
|
||||||
file.load(path)
|
file.load(path)
|
||||||
view().reviewPanel.open()
|
layout.review.open()
|
||||||
}
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const ModelSelectorPopover: Component<{
|
|||||||
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
||||||
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
|
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
|
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
|
||||||
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
|
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
|
||||||
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
|
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
|
||||||
</Kobalte.Content>
|
</Kobalte.Content>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
const variant = createMemo(() => props.variant ?? "button")
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
const view = createMemo(() => layout.view(sessionKey()))
|
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
@@ -49,7 +48,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
view().reviewPanel.open()
|
layout.review.open()
|
||||||
tabs().open("context")
|
tabs().open("context")
|
||||||
tabs().setActive("context")
|
tabs().setActive("context")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
|
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) {
|
function navigateToProject(directory: string) {
|
||||||
navigate(`/${base64Encode(directory)}`)
|
navigate(`/${base64Encode(directory)}`)
|
||||||
@@ -173,24 +171,20 @@ export function SessionHeader() {
|
|||||||
title="Toggle review"
|
title="Toggle review"
|
||||||
keybind={command.keybind("review.toggle")}
|
keybind={command.keybind("review.toggle")}
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
|
||||||
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">
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
<Icon
|
<Icon
|
||||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
|
name={layout.review.opened() ? "layout-right" : "layout-left"}
|
||||||
size="small"
|
size="small"
|
||||||
class="group-hover/review-toggle:hidden"
|
class="group-hover/review-toggle:hidden"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
|
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
|
||||||
size="small"
|
size="small"
|
||||||
class="hidden group-hover/review-toggle:inline-block"
|
class="hidden group-hover/review-toggle:inline-block"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
|
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
|
||||||
size="small"
|
size="small"
|
||||||
class="hidden group-active/review-toggle:inline-block"
|
class="hidden group-active/review-toggle:inline-block"
|
||||||
/>
|
/>
|
||||||
@@ -203,11 +197,11 @@ export function SessionHeader() {
|
|||||||
title="Toggle terminal"
|
title="Toggle terminal"
|
||||||
keybind={command.keybind("terminal.toggle")}
|
keybind={command.keybind("terminal.toggle")}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={() => view().terminal.toggle()}>
|
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
<Icon
|
<Icon
|
||||||
size="small"
|
size="small"
|
||||||
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
||||||
class="group-hover/terminal-toggle:hidden"
|
class="group-hover/terminal-toggle:hidden"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -217,7 +211,7 @@ export function SessionHeader() {
|
|||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
size="small"
|
size="small"
|
||||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
||||||
class="hidden group-active/terminal-toggle:inline-block"
|
class="hidden group-active/terminal-toggle:inline-block"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
let serializeAddon: SerializeAddon
|
let serializeAddon: SerializeAddon
|
||||||
let fitAddon: FitAddon
|
let fitAddon: FitAddon
|
||||||
let handleResize: () => void
|
let handleResize: () => void
|
||||||
let handleTextareaFocus: () => void
|
|
||||||
let handleTextareaBlur: () => void
|
|
||||||
let reconnect: number | undefined
|
let reconnect: number | undefined
|
||||||
let disposed = false
|
let disposed = false
|
||||||
|
|
||||||
@@ -107,7 +105,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const t = new mod.Terminal({
|
const t = new mod.Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: "bar",
|
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "IBM Plex Mono, monospace",
|
fontFamily: "IBM Plex Mono, monospace",
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
@@ -173,17 +170,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
t.open(container)
|
t.open(container)
|
||||||
container.addEventListener("pointerdown", handlePointerDown)
|
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()
|
focusTerminal()
|
||||||
|
|
||||||
if (local.pty.buffer) {
|
if (local.pty.buffer) {
|
||||||
@@ -256,8 +242,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
window.removeEventListener("resize", handleResize)
|
window.removeEventListener("resize", handleResize)
|
||||||
}
|
}
|
||||||
container.removeEventListener("pointerdown", handlePointerDown)
|
container.removeEventListener("pointerdown", handlePointerDown)
|
||||||
term?.textarea?.removeEventListener("focus", handleTextareaFocus)
|
|
||||||
term?.textarea?.removeEventListener("blur", handleTextareaBlur)
|
|
||||||
|
|
||||||
const t = term
|
const t = term
|
||||||
if (serializeAddon && props.onCleanup && t) {
|
if (serializeAddon && props.onCleanup && t) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||||
@@ -82,106 +82,8 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORKSPACE_KEY = "__workspace__"
|
|
||||||
const MAX_FILE_VIEW_SESSIONS = 20
|
|
||||||
const MAX_VIEW_FILES = 500
|
|
||||||
|
|
||||||
type ViewSession = ReturnType<typeof createViewSession>
|
|
||||||
|
|
||||||
type ViewCacheEntry = {
|
|
||||||
value: ViewSession
|
|
||||||
dispose: VoidFunction
|
|
||||||
}
|
|
||||||
|
|
||||||
function createViewSession(dir: string, id: string | undefined) {
|
|
||||||
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
|
||||||
|
|
||||||
const [view, setView, _, ready] = persisted(
|
|
||||||
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
|
|
||||||
createStore<{
|
|
||||||
file: Record<string, FileViewState>
|
|
||||||
}>({
|
|
||||||
file: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const meta = { pruned: false }
|
|
||||||
|
|
||||||
const pruneView = (keep?: string) => {
|
|
||||||
const keys = Object.keys(view.file)
|
|
||||||
if (keys.length <= MAX_VIEW_FILES) return
|
|
||||||
|
|
||||||
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
|
|
||||||
if (drop.length === 0) return
|
|
||||||
|
|
||||||
setView(
|
|
||||||
produce((draft) => {
|
|
||||||
for (const key of drop) {
|
|
||||||
delete draft.file[key]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready()) return
|
|
||||||
if (meta.pruned) return
|
|
||||||
meta.pruned = true
|
|
||||||
pruneView()
|
|
||||||
})
|
|
||||||
|
|
||||||
const scrollTop = (path: string) => view.file[path]?.scrollTop
|
|
||||||
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
|
|
||||||
const selectedLines = (path: string) => view.file[path]?.selectedLines
|
|
||||||
|
|
||||||
const setScrollTop = (path: string, top: number) => {
|
|
||||||
setView("file", path, (current) => {
|
|
||||||
if (current?.scrollTop === top) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
scrollTop: top,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
pruneView(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setScrollLeft = (path: string, left: number) => {
|
|
||||||
setView("file", path, (current) => {
|
|
||||||
if (current?.scrollLeft === left) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
scrollLeft: left,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
pruneView(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
|
|
||||||
const next = range ? normalizeSelectedLines(range) : null
|
|
||||||
setView("file", path, (current) => {
|
|
||||||
if (current?.selectedLines === next) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
selectedLines: next,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
pruneView(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready,
|
|
||||||
scrollTop,
|
|
||||||
scrollLeft,
|
|
||||||
selectedLines,
|
|
||||||
setScrollTop,
|
|
||||||
setScrollLeft,
|
|
||||||
setSelectedLines,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
name: "File",
|
name: "File",
|
||||||
gate: false,
|
|
||||||
init: () => {
|
init: () => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -232,45 +134,42 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
file: {},
|
file: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewCache = new Map<string, ViewCacheEntry>()
|
const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
|
||||||
|
|
||||||
const disposeViews = () => {
|
const [view, setView, _, ready] = persisted(
|
||||||
for (const entry of viewCache.values()) {
|
Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]),
|
||||||
entry.dispose()
|
createStore<{
|
||||||
}
|
file: Record<string, FileViewState>
|
||||||
viewCache.clear()
|
}>({
|
||||||
|
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 pruneViews = () => {
|
createEffect(() => {
|
||||||
while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
|
if (!ready()) return
|
||||||
const first = viewCache.keys().next().value
|
if (viewMeta.pruned) return
|
||||||
if (!first) return
|
viewMeta.pruned = true
|
||||||
const entry = viewCache.get(first)
|
pruneView()
|
||||||
entry?.dispose()
|
})
|
||||||
viewCache.delete(first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadView = (dir: string, id: string | undefined) => {
|
|
||||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
|
||||||
const existing = viewCache.get(key)
|
|
||||||
if (existing) {
|
|
||||||
viewCache.delete(key)
|
|
||||||
viewCache.set(key, existing)
|
|
||||||
return existing.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = createRoot((dispose) => ({
|
|
||||||
value: createViewSession(dir, id),
|
|
||||||
dispose,
|
|
||||||
}))
|
|
||||||
|
|
||||||
viewCache.set(key, entry)
|
|
||||||
pruneViews()
|
|
||||||
return entry.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const view = createMemo(() => loadView(params.dir!, params.id))
|
|
||||||
|
|
||||||
function ensure(path: string) {
|
function ensure(path: string) {
|
||||||
if (!path) return
|
if (!path) return
|
||||||
@@ -347,32 +246,51 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const get = (input: string) => store.file[normalize(input)]
|
const get = (input: string) => store.file[normalize(input)]
|
||||||
|
|
||||||
const scrollTop = (input: string) => view().scrollTop(normalize(input))
|
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
|
||||||
const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
|
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
|
||||||
const selectedLines = (input: string) => view().selectedLines(normalize(input))
|
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
|
||||||
|
|
||||||
const setScrollTop = (input: string, top: number) => {
|
const setScrollTop = (input: string, top: number) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
view().setScrollTop(path, top)
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollTop === top) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollTop: top,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setScrollLeft = (input: string, left: number) => {
|
const setScrollLeft = (input: string, left: number) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
view().setScrollLeft(path, left)
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollLeft === left) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollLeft: left,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
view().setSelectedLines(path, range)
|
const next = range ? normalizeSelectedLines(range) : null
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.selectedLines === next) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
selectedLines: next,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => stop())
|
||||||
stop()
|
|
||||||
disposeViews()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => view().ready(),
|
ready,
|
||||||
normalize,
|
normalize,
|
||||||
tab,
|
tab,
|
||||||
pathFromTab,
|
pathFromTab,
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ type SessionTabs = {
|
|||||||
type SessionView = {
|
type SessionView = {
|
||||||
scroll: Record<string, SessionScroll>
|
scroll: Record<string, SessionScroll>
|
||||||
reviewOpen?: string[]
|
reviewOpen?: string[]
|
||||||
terminalOpened?: boolean
|
|
||||||
reviewPanelOpened?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||||
@@ -55,9 +53,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
width: 280,
|
width: 280,
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
|
opened: false,
|
||||||
height: 280,
|
height: 280,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
|
opened: true,
|
||||||
diffStyle: "split" as ReviewDiffStyle,
|
diffStyle: "split" as ReviewDiffStyle,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
@@ -150,7 +150,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
const current = store.sessionView[sessionKey]
|
const current = store.sessionView[sessionKey]
|
||||||
const keep = meta.active ?? sessionKey
|
const keep = meta.active ?? sessionKey
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
|
setStore("sessionView", sessionKey, { scroll: next })
|
||||||
prune(keep)
|
prune(keep)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -306,20 +306,40 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
terminal: {
|
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),
|
height: createMemo(() => store.terminal.height),
|
||||||
resize(height: number) {
|
resize(height: number) {
|
||||||
setStore("terminal", "height", height)
|
setStore("terminal", "height", height)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
|
opened: createMemo(() => store.review?.opened ?? true),
|
||||||
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
||||||
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
||||||
if (!store.review) {
|
if (!store.review) {
|
||||||
setStore("review", { diffStyle })
|
setStore("review", { opened: true, diffStyle })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("review", "diffStyle", diffStyle)
|
setStore("review", "diffStyle", diffStyle)
|
||||||
},
|
},
|
||||||
|
open() {
|
||||||
|
setStore("review", "opened", true)
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
setStore("review", "opened", false)
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
setStore("review", "opened", (x) => !x)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
width: createMemo(() => store.session?.width ?? 600),
|
width: createMemo(() => store.session?.width ?? 600),
|
||||||
@@ -347,33 +367,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
touch(sessionKey)
|
touch(sessionKey)
|
||||||
scroll.seed(sessionKey)
|
scroll.seed(sessionKey)
|
||||||
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
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 {
|
return {
|
||||||
scroll(tab: string) {
|
scroll(tab: string) {
|
||||||
return scroll.scroll(sessionKey, tab)
|
return scroll.scroll(sessionKey, tab)
|
||||||
@@ -381,41 +374,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
setScroll(tab: string, pos: SessionScroll) {
|
setScroll(tab: string, pos: SessionScroll) {
|
||||||
scroll.setScroll(sessionKey, tab, pos)
|
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: {
|
review: {
|
||||||
open: createMemo(() => s().reviewOpen),
|
open: createMemo(() => s().reviewOpen),
|
||||||
setOpen(open: string[]) {
|
setOpen(open: string[]) {
|
||||||
const current = store.sessionView[sessionKey]
|
const current = store.sessionView[sessionKey]
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setStore("sessionView", sessionKey, {
|
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
|
||||||
scroll: {},
|
|
||||||
terminalOpened: false,
|
|
||||||
reviewPanelOpened: true,
|
|
||||||
reviewOpen: open,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,13 +276,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
visible(model: ModelKey) {
|
visible(model: ModelKey) {
|
||||||
const key = `${model.providerID}:${model.modelID}`
|
const key = `${model.providerID}:${model.modelID}`
|
||||||
const visibility = userVisibilityMap().get(key)
|
const visibility = userVisibilityMap().get(key)
|
||||||
if (visibility === "hide") return false
|
return visibility !== "hide" && (latestSet().has(key) || visibility === "show")
|
||||||
if (visibility === "show") return true
|
|
||||||
if (latestSet().has(key)) return true
|
|
||||||
// For models without valid release_date (e.g. custom models), show by default
|
|
||||||
const m = find(model)
|
|
||||||
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
|
|
||||||
return false
|
|
||||||
},
|
},
|
||||||
setVisibility(model: ModelKey, visible: boolean) {
|
setVisibility(model: ModelKey, visible: boolean) {
|
||||||
updateVisibility(model, visible ? "show" : "hide")
|
updateVisibility(model, visible ? "show" : "hide")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -99,146 +99,74 @@ function clonePrompt(prompt: Prompt): Prompt {
|
|||||||
return prompt.map(clonePart)
|
return prompt.map(clonePart)
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORKSPACE_KEY = "__workspace__"
|
|
||||||
const MAX_PROMPT_SESSIONS = 20
|
|
||||||
|
|
||||||
type PromptSession = ReturnType<typeof createPromptSession>
|
|
||||||
|
|
||||||
type PromptCacheEntry = {
|
|
||||||
value: PromptSession
|
|
||||||
dispose: VoidFunction
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPromptSession(dir: string, id: string | undefined) {
|
|
||||||
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
|
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
|
||||||
Persist.scoped(dir, id, "prompt", [legacy]),
|
|
||||||
createStore<{
|
|
||||||
prompt: Prompt
|
|
||||||
cursor?: number
|
|
||||||
context: {
|
|
||||||
activeTab: boolean
|
|
||||||
items: (ContextItem & { key: string })[]
|
|
||||||
}
|
|
||||||
}>({
|
|
||||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
|
||||||
cursor: undefined,
|
|
||||||
context: {
|
|
||||||
activeTab: true,
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function keyForItem(item: ContextItem) {
|
|
||||||
if (item.type !== "file") return item.type
|
|
||||||
const start = item.selection?.startLine
|
|
||||||
const end = item.selection?.endLine
|
|
||||||
return `${item.type}:${item.path}:${start}:${end}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready,
|
|
||||||
current: createMemo(() => store.prompt),
|
|
||||||
cursor: createMemo(() => store.cursor),
|
|
||||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
|
||||||
context: {
|
|
||||||
activeTab: createMemo(() => store.context.activeTab),
|
|
||||||
items: createMemo(() => store.context.items),
|
|
||||||
addActive() {
|
|
||||||
setStore("context", "activeTab", true)
|
|
||||||
},
|
|
||||||
removeActive() {
|
|
||||||
setStore("context", "activeTab", false)
|
|
||||||
},
|
|
||||||
add(item: ContextItem) {
|
|
||||||
const key = keyForItem(item)
|
|
||||||
if (store.context.items.find((x) => x.key === key)) return
|
|
||||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
|
||||||
},
|
|
||||||
remove(key: string) {
|
|
||||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
set(prompt: Prompt, cursorPosition?: number) {
|
|
||||||
const next = clonePrompt(prompt)
|
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", next)
|
|
||||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
reset() {
|
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
|
||||||
setStore("cursor", 0)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||||
name: "Prompt",
|
name: "Prompt",
|
||||||
gate: false,
|
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const cache = new Map<string, PromptCacheEntry>()
|
const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
|
||||||
|
|
||||||
const disposeAll = () => {
|
const [store, setStore, _, ready] = persisted(
|
||||||
for (const entry of cache.values()) {
|
Persist.scoped(params.dir!, params.id, "prompt", [legacy()]),
|
||||||
entry.dispose()
|
createStore<{
|
||||||
}
|
prompt: Prompt
|
||||||
cache.clear()
|
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}`
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(disposeAll)
|
|
||||||
|
|
||||||
const prune = () => {
|
|
||||||
while (cache.size > MAX_PROMPT_SESSIONS) {
|
|
||||||
const first = cache.keys().next().value
|
|
||||||
if (!first) return
|
|
||||||
const entry = cache.get(first)
|
|
||||||
entry?.dispose()
|
|
||||||
cache.delete(first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = (dir: string, id: string | undefined) => {
|
|
||||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
|
||||||
const existing = cache.get(key)
|
|
||||||
if (existing) {
|
|
||||||
cache.delete(key)
|
|
||||||
cache.set(key, existing)
|
|
||||||
return existing.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = createRoot((dispose) => ({
|
|
||||||
value: createPromptSession(dir, id),
|
|
||||||
dispose,
|
|
||||||
}))
|
|
||||||
|
|
||||||
cache.set(key, entry)
|
|
||||||
prune()
|
|
||||||
return entry.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = createMemo(() => load(params.dir!, params.id))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => session().ready(),
|
ready,
|
||||||
current: () => session().current(),
|
current: createMemo(() => store.prompt),
|
||||||
cursor: () => session().cursor(),
|
cursor: createMemo(() => store.cursor),
|
||||||
dirty: () => session().dirty(),
|
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||||
context: {
|
context: {
|
||||||
activeTab: () => session().context.activeTab(),
|
activeTab: createMemo(() => store.context.activeTab),
|
||||||
items: () => session().context.items(),
|
items: createMemo(() => store.context.items),
|
||||||
addActive: () => session().context.addActive(),
|
addActive() {
|
||||||
removeActive: () => session().context.removeActive(),
|
setStore("context", "activeTab", true)
|
||||||
add: (item: ContextItem) => session().context.add(item),
|
},
|
||||||
remove: (key: string) => session().context.remove(key),
|
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)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
|
|
||||||
reset: () => session().reset(),
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { produce, reconcile } from "solid-js/store"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
@@ -14,76 +14,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const [store, setStore] = globalSync.child(sdk.directory)
|
const [store, setStore] = globalSync.child(sdk.directory)
|
||||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||||
const chunk = 200
|
|
||||||
const inflight = new Map<string, Promise<void>>()
|
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
|
||||||
const [meta, setMeta] = createStore({
|
|
||||||
limit: {} as Record<string, number>,
|
|
||||||
complete: {} as Record<string, boolean>,
|
|
||||||
loading: {} as Record<string, boolean>,
|
|
||||||
})
|
|
||||||
|
|
||||||
const getSession = (sessionID: string) => {
|
|
||||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
|
||||||
if (match.found) return store.session[match.index]
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const limitFor = (count: number) => {
|
|
||||||
if (count <= chunk) return chunk
|
|
||||||
return Math.ceil(count / chunk) * chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrateMessages = (sessionID: string) => {
|
|
||||||
if (meta.limit[sessionID] !== undefined) return
|
|
||||||
|
|
||||||
const messages = store.message[sessionID]
|
|
||||||
if (!messages) return
|
|
||||||
|
|
||||||
const limit = limitFor(messages.length)
|
|
||||||
setMeta("limit", sessionID, limit)
|
|
||||||
setMeta("complete", sessionID, messages.length < limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadMessages = async (sessionID: string, limit: number) => {
|
|
||||||
if (meta.loading[sessionID]) return
|
|
||||||
|
|
||||||
setMeta("loading", sessionID, true)
|
|
||||||
await retry(() => sdk.client.session.messages({ sessionID, limit }))
|
|
||||||
.then((messages) => {
|
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
|
||||||
const next = items
|
|
||||||
.map((x) => x.info)
|
|
||||||
.filter((m) => !!m?.id)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
|
||||||
|
|
||||||
batch(() => {
|
|
||||||
setStore("message", sessionID, reconcile(next, { key: "id" }))
|
|
||||||
|
|
||||||
for (const message of items) {
|
|
||||||
setStore(
|
|
||||||
"part",
|
|
||||||
message.info.id,
|
|
||||||
reconcile(
|
|
||||||
message.parts
|
|
||||||
.filter((p) => !!p?.id)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setMeta("limit", sessionID, limit)
|
|
||||||
setMeta("complete", sessionID, next.length < limit)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setMeta("loading", sessionID, false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: store,
|
data: store,
|
||||||
@@ -100,7 +30,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
get: getSession,
|
get(sessionID: string) {
|
||||||
|
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||||
|
if (match.found) return store.session[match.index]
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
addOptimisticMessage(input: {
|
addOptimisticMessage(input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID: string
|
messageID: string
|
||||||
@@ -132,98 +66,58 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
async sync(sessionID: string) {
|
async sync(sessionID: string, _isRetry = false) {
|
||||||
const hasSession = getSession(sessionID) !== undefined
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
hydrateMessages(sessionID)
|
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 })),
|
||||||
|
])
|
||||||
|
|
||||||
const hasMessages = store.message[sessionID] !== undefined
|
batch(() => {
|
||||||
if (hasSession && hasMessages) return
|
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 pending = inflight.get(sessionID)
|
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||||
if (pending) return pending
|
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 limit = meta.limit[sessionID] ?? chunk
|
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 sessionReq = hasSession
|
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||||
? Promise.resolve()
|
})
|
||||||
: retry(() => sdk.client.session.get({ sessionID })).then((session) => {
|
|
||||||
const data = session.data
|
|
||||||
if (!data) return
|
|
||||||
setStore(
|
|
||||||
"session",
|
|
||||||
produce((draft) => {
|
|
||||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
|
||||||
if (match.found) {
|
|
||||||
draft[match.index] = data
|
|
||||||
return
|
|
||||||
}
|
|
||||||
draft.splice(match.index, 0, data)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
|
|
||||||
|
|
||||||
const promise = Promise.all([sessionReq, messagesReq])
|
|
||||||
.then(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
inflight.delete(sessionID)
|
|
||||||
})
|
|
||||||
|
|
||||||
inflight.set(sessionID, promise)
|
|
||||||
return promise
|
|
||||||
},
|
|
||||||
async diff(sessionID: string) {
|
|
||||||
if (store.session_diff[sessionID] !== undefined) return
|
|
||||||
|
|
||||||
const pending = inflightDiff.get(sessionID)
|
|
||||||
if (pending) return pending
|
|
||||||
|
|
||||||
const promise = retry(() => sdk.client.session.diff({ sessionID }))
|
|
||||||
.then((diff) => {
|
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
inflightDiff.delete(sessionID)
|
|
||||||
})
|
|
||||||
|
|
||||||
inflightDiff.set(sessionID, promise)
|
|
||||||
return promise
|
|
||||||
},
|
|
||||||
async todo(sessionID: string) {
|
|
||||||
if (store.todo[sessionID] !== undefined) return
|
|
||||||
|
|
||||||
const pending = inflightTodo.get(sessionID)
|
|
||||||
if (pending) return pending
|
|
||||||
|
|
||||||
const promise = retry(() => sdk.client.session.todo({ sessionID }))
|
|
||||||
.then((todo) => {
|
|
||||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
inflightTodo.delete(sessionID)
|
|
||||||
})
|
|
||||||
|
|
||||||
inflightTodo.set(sessionID, promise)
|
|
||||||
return promise
|
|
||||||
},
|
|
||||||
history: {
|
|
||||||
more(sessionID: string) {
|
|
||||||
if (store.message[sessionID] === undefined) return false
|
|
||||||
if (meta.limit[sessionID] === undefined) return false
|
|
||||||
if (meta.complete[sessionID]) return false
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
loading(sessionID: string) {
|
|
||||||
return meta.loading[sessionID] ?? false
|
|
||||||
},
|
|
||||||
async loadMore(sessionID: string, count = chunk) {
|
|
||||||
if (meta.loading[sessionID]) return
|
|
||||||
if (meta.complete[sessionID]) return
|
|
||||||
|
|
||||||
const current = meta.limit[sessionID] ?? chunk
|
|
||||||
await loadMessages(sessionID, current + count)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
setStore("limit", (x) => x + count)
|
setStore("limit", (x) => x + count)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -14,175 +14,108 @@ export type LocalPTY = {
|
|||||||
scrollY?: number
|
scrollY?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORKSPACE_KEY = "__workspace__"
|
|
||||||
const MAX_TERMINAL_SESSIONS = 20
|
|
||||||
|
|
||||||
type TerminalSession = ReturnType<typeof createTerminalSession>
|
|
||||||
|
|
||||||
type TerminalCacheEntry = {
|
|
||||||
value: TerminalSession
|
|
||||||
dispose: VoidFunction
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
|
|
||||||
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
|
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
|
||||||
Persist.scoped(dir, id, "terminal", [legacy]),
|
|
||||||
createStore<{
|
|
||||||
active?: string
|
|
||||||
all: LocalPTY[]
|
|
||||||
}>({
|
|
||||||
all: [],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready,
|
|
||||||
all: createMemo(() => Object.values(store.all)),
|
|
||||||
active: createMemo(() => store.active),
|
|
||||||
new() {
|
|
||||||
sdk.client.pty
|
|
||||||
.create({ title: `Terminal ${store.all.length + 1}` })
|
|
||||||
.then((pty) => {
|
|
||||||
const id = pty.data?.id
|
|
||||||
if (!id) return
|
|
||||||
setStore("all", [
|
|
||||||
...store.all,
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
title: pty.data?.title ?? "Terminal",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setStore("active", id)
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to create terminal", e)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
|
||||||
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
|
||||||
sdk.client.pty
|
|
||||||
.update({
|
|
||||||
ptyID: pty.id,
|
|
||||||
title: pty.title,
|
|
||||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to update terminal", e)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async clone(id: string) {
|
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
|
||||||
const pty = store.all[index]
|
|
||||||
if (!pty) return
|
|
||||||
const clone = await sdk.client.pty
|
|
||||||
.create({
|
|
||||||
title: pty.title,
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to clone terminal", e)
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
if (!clone?.data) return
|
|
||||||
setStore("all", index, {
|
|
||||||
...pty,
|
|
||||||
...clone.data,
|
|
||||||
})
|
|
||||||
if (store.active === pty.id) {
|
|
||||||
setStore("active", clone.data.id)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
open(id: string) {
|
|
||||||
setStore("active", id)
|
|
||||||
},
|
|
||||||
async close(id: string) {
|
|
||||||
batch(() => {
|
|
||||||
setStore(
|
|
||||||
"all",
|
|
||||||
store.all.filter((x) => x.id !== id),
|
|
||||||
)
|
|
||||||
if (store.active === id) {
|
|
||||||
const index = store.all.findIndex((f) => f.id === id)
|
|
||||||
const previous = store.all[Math.max(0, index - 1)]
|
|
||||||
setStore("active", previous?.id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
|
|
||||||
console.error("Failed to close terminal", e)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
move(id: string, to: number) {
|
|
||||||
const index = store.all.findIndex((f) => f.id === id)
|
|
||||||
if (index === -1) return
|
|
||||||
setStore(
|
|
||||||
"all",
|
|
||||||
produce((all) => {
|
|
||||||
all.splice(to, 0, all.splice(index, 1)[0])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
|
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
|
||||||
name: "Terminal",
|
name: "Terminal",
|
||||||
gate: false,
|
|
||||||
init: () => {
|
init: () => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const cache = new Map<string, TerminalCacheEntry>()
|
const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
|
||||||
|
|
||||||
const disposeAll = () => {
|
const [store, setStore, _, ready] = persisted(
|
||||||
for (const entry of cache.values()) {
|
Persist.scoped(params.dir!, params.id, "terminal", [legacy()]),
|
||||||
entry.dispose()
|
createStore<{
|
||||||
}
|
active?: string
|
||||||
cache.clear()
|
all: LocalPTY[]
|
||||||
}
|
}>({
|
||||||
|
all: [],
|
||||||
onCleanup(disposeAll)
|
}),
|
||||||
|
)
|
||||||
const prune = () => {
|
|
||||||
while (cache.size > MAX_TERMINAL_SESSIONS) {
|
|
||||||
const first = cache.keys().next().value
|
|
||||||
if (!first) return
|
|
||||||
const entry = cache.get(first)
|
|
||||||
entry?.dispose()
|
|
||||||
cache.delete(first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = (dir: string, id: string | undefined) => {
|
|
||||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
|
||||||
const existing = cache.get(key)
|
|
||||||
if (existing) {
|
|
||||||
cache.delete(key)
|
|
||||||
cache.set(key, existing)
|
|
||||||
return existing.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = createRoot((dispose) => ({
|
|
||||||
value: createTerminalSession(sdk, dir, id),
|
|
||||||
dispose,
|
|
||||||
}))
|
|
||||||
|
|
||||||
cache.set(key, entry)
|
|
||||||
prune()
|
|
||||||
return entry.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = createMemo(() => load(params.dir!, params.id))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => session().ready(),
|
ready,
|
||||||
all: () => session().all(),
|
all: createMemo(() => Object.values(store.all)),
|
||||||
active: () => session().active(),
|
active: createMemo(() => store.active),
|
||||||
new: () => session().new(),
|
new() {
|
||||||
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
|
sdk.client.pty
|
||||||
clone: (id: string) => session().clone(id),
|
.create({ title: `Terminal ${store.all.length + 1}` })
|
||||||
open: (id: string) => session().open(id),
|
.then((pty) => {
|
||||||
close: (id: string) => session().close(id),
|
const id = pty.data?.id
|
||||||
move: (id: string, to: number) => session().move(id, to),
|
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])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
batch,
|
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
@@ -32,7 +31,7 @@ import { getFilename } from "@opencode-ai/util/path"
|
|||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import {
|
import {
|
||||||
DragDropProvider,
|
DragDropProvider,
|
||||||
DragDropSensors,
|
DragDropSensors,
|
||||||
@@ -48,7 +47,6 @@ import { useGlobalSDK } from "@/context/global-sdk"
|
|||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
|
||||||
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||||
@@ -57,7 +55,6 @@ import { DialogEditProject } from "@/components/dialog-edit-project"
|
|||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||||
import { navStart } from "@/utils/perf"
|
|
||||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
|
||||||
@@ -287,146 +284,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const currentSessions = createMemo(() => projectSessions(currentProject()))
|
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) {
|
function navigateSessionByOffset(offset: number) {
|
||||||
const projects = layout.projects.list()
|
const projects = layout.projects.list()
|
||||||
if (projects.length === 0) return
|
if (projects.length === 0) return
|
||||||
@@ -452,27 +309,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
if (targetIndex >= 0 && targetIndex < sessions.length) {
|
if (targetIndex >= 0 && targetIndex < sessions.length) {
|
||||||
const session = sessions[targetIndex]
|
const session = sessions[targetIndex]
|
||||||
const next = sessions[targetIndex + 1]
|
|
||||||
const prev = sessions[targetIndex - 1]
|
|
||||||
|
|
||||||
if (offset > 0) {
|
|
||||||
if (next) prefetchSession(next, "high")
|
|
||||||
if (prev) prefetchSession(prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offset < 0) {
|
|
||||||
if (prev) prefetchSession(prev, "high")
|
|
||||||
if (next) prefetchSession(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
navStart({
|
|
||||||
dir: base64Encode(session.directory),
|
|
||||||
from: params.id,
|
|
||||||
to: session.id,
|
|
||||||
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
queueMicrotask(() => scrollToSession(session.id))
|
queueMicrotask(() => scrollToSession(session.id))
|
||||||
return
|
return
|
||||||
@@ -488,27 +324,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = offset > 0 ? 0 : nextProjectSessions.length - 1
|
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[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)
|
navigateToSession(targetSession)
|
||||||
queueMicrotask(() => scrollToSession(targetSession.id))
|
queueMicrotask(() => scrollToSession(targetSession.id))
|
||||||
}
|
}
|
||||||
@@ -863,8 +679,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
<A
|
<A
|
||||||
href={`${props.slug}/session/${props.session.id}`}
|
href={`${props.slug}/session/${props.session.id}`}
|
||||||
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
|
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
|
||||||
onMouseEnter={() => prefetchSession(props.session, "high")}
|
|
||||||
onFocus={() => prefetchSession(props.session, "high")}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
||||||
<span
|
<span
|
||||||
@@ -1061,7 +875,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Tooltip placement="right" value={getFilename(props.project.worktree)}>
|
<Tooltip placement="right" value={props.project.worktree}>
|
||||||
<ProjectVisual project={props.project} />
|
<ProjectVisual project={props.project} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
+203
-523
@@ -1,4 +1,4 @@
|
|||||||
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
@@ -8,7 +8,6 @@ import { createStore } from "solid-js/store"
|
|||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||||
@@ -50,17 +49,10 @@ import {
|
|||||||
NewSessionView,
|
NewSessionView,
|
||||||
} from "@/components/session"
|
} from "@/components/session"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { navMark, navParams } from "@/utils/perf"
|
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
|
|
||||||
type DiffStyle = "unified" | "split"
|
type DiffStyle = "unified" | "split"
|
||||||
|
|
||||||
const handoff = {
|
|
||||||
prompt: "",
|
|
||||||
terminals: [] as string[],
|
|
||||||
files: {} as Record<string, SelectedLineRange | null>,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionReviewTabProps {
|
interface SessionReviewTabProps {
|
||||||
diffs: () => FileDiff[]
|
diffs: () => FileDiff[]
|
||||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||||
@@ -170,46 +162,6 @@ export default function Page() {
|
|||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
const view = createMemo(() => layout.view(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)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
function normalizeTab(tab: string) {
|
function normalizeTab(tab: string) {
|
||||||
@@ -264,8 +216,6 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
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 revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
const messagesReady = createMemo(() => {
|
const messagesReady = createMemo(() => {
|
||||||
@@ -273,16 +223,6 @@ export default function Page() {
|
|||||||
if (!id) return true
|
if (!id) return true
|
||||||
return sync.data.message[id] !== undefined
|
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 emptyUserMessages: UserMessage[] = []
|
||||||
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
|
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
|
||||||
const visibleUserMessages = createMemo(() => {
|
const visibleUserMessages = createMemo(() => {
|
||||||
@@ -309,20 +249,11 @@ export default function Page() {
|
|||||||
activeTerminalDraggable: undefined as string | undefined,
|
activeTerminalDraggable: undefined as string | undefined,
|
||||||
expanded: {} as Record<string, boolean>,
|
expanded: {} as Record<string, boolean>,
|
||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
turnStart: 0,
|
|
||||||
mobileTab: "session" as "session" | "review",
|
mobileTab: "session" as "session" | "review",
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
promptHeight: 0,
|
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(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.newSessionWorktree === "create") return "create"
|
if (store.newSessionWorktree === "create") return "create"
|
||||||
const project = sync.project
|
const project = sync.project
|
||||||
@@ -359,12 +290,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
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 }
|
const idle = { type: "idle" as const }
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
@@ -377,10 +302,11 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!view().terminal.opened()) return
|
if (layout.terminal.opened()) {
|
||||||
if (!terminal.ready()) return
|
if (terminal.all().length === 0) {
|
||||||
if (terminal.all().length !== 0) return
|
terminal.new()
|
||||||
terminal.new()
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -440,7 +366,7 @@ export default function Page() {
|
|||||||
category: "View",
|
category: "View",
|
||||||
keybind: "ctrl+`",
|
keybind: "ctrl+`",
|
||||||
slash: "terminal",
|
slash: "terminal",
|
||||||
onSelect: () => view().terminal.toggle(),
|
onSelect: () => layout.terminal.toggle(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "review.toggle",
|
id: "review.toggle",
|
||||||
@@ -448,7 +374,7 @@ export default function Page() {
|
|||||||
description: "Show or hide the review panel",
|
description: "Show or hide the review panel",
|
||||||
category: "View",
|
category: "View",
|
||||||
keybind: "mod+shift+r",
|
keybind: "mod+shift+r",
|
||||||
onSelect: () => view().reviewPanel.toggle(),
|
onSelect: () => layout.review.toggle(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "terminal.new",
|
id: "terminal.new",
|
||||||
@@ -717,11 +643,11 @@ export default function Page() {
|
|||||||
.filter((tab) => tab !== "context"),
|
.filter((tab) => tab !== "context"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const reviewTab = createMemo(() => hasReview() || tabs().active() === "review")
|
const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
|
||||||
const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review")
|
const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")
|
||||||
|
|
||||||
const showTabs = createMemo(
|
const showTabs = createMemo(
|
||||||
() => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()),
|
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
|
||||||
)
|
)
|
||||||
|
|
||||||
const activeTab = createMemo(() => {
|
const activeTab = createMemo(() => {
|
||||||
@@ -738,22 +664,10 @@ export default function Page() {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!layout.ready()) return
|
if (!layout.ready()) return
|
||||||
if (tabs().active()) return
|
if (tabs().active()) return
|
||||||
if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return
|
if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return
|
||||||
tabs().setActive(activeTab())
|
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 isWorking = createMemo(() => status().type !== "idle")
|
||||||
const autoScroll = createAutoScroll({
|
const autoScroll = createAutoScroll({
|
||||||
working: isWorking,
|
working: isWorking,
|
||||||
@@ -769,88 +683,6 @@ export default function Page() {
|
|||||||
autoScroll.scrollRef(el)
|
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(
|
createResizeObserver(
|
||||||
() => promptDock,
|
() => promptDock,
|
||||||
({ height }) => {
|
({ height }) => {
|
||||||
@@ -878,21 +710,6 @@ export default function Page() {
|
|||||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||||
setActiveMessage(message)
|
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))
|
const el = document.getElementById(anchor(message.id))
|
||||||
if (el) el.scrollIntoView({ behavior, block: "start" })
|
if (el) el.scrollIntoView({ behavior, block: "start" })
|
||||||
updateHash(message.id)
|
updateHash(message.id)
|
||||||
@@ -938,27 +755,12 @@ export default function Page() {
|
|||||||
if (!sessionID || !ready) return
|
if (!sessionID || !ready) return
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const hash = window.location.hash.slice(1)
|
const id = window.location.hash.slice(1)
|
||||||
if (!hash) {
|
const hashTarget = id ? document.getElementById(id) : undefined
|
||||||
autoScroll.forceScrollToBottom()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashTarget = document.getElementById(hash)
|
|
||||||
if (hashTarget) {
|
if (hashTarget) {
|
||||||
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
|
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
|
||||||
return
|
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()
|
autoScroll.forceScrollToBottom()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -967,43 +769,7 @@ export default function Page() {
|
|||||||
document.addEventListener("keydown", handleKeyDown)
|
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(() => {
|
onCleanup(() => {
|
||||||
cancelTurnBackfill()
|
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
||||||
})
|
})
|
||||||
@@ -1013,7 +779,7 @@ export default function Page() {
|
|||||||
<SessionHeader />
|
<SessionHeader />
|
||||||
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||||
{/* Mobile tab bar - only shown on mobile when there are diffs */}
|
{/* Mobile tab bar - only shown on mobile when there are diffs */}
|
||||||
<Show when={!isDesktop() && hasReview()}>
|
<Show when={!isDesktop() && diffs().length > 0}>
|
||||||
<Tabs class="h-auto">
|
<Tabs class="h-auto">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
@@ -1030,7 +796,7 @@ export default function Page() {
|
|||||||
classes={{ button: "w-full" }}
|
classes={{ button: "w-full" }}
|
||||||
onClick={() => setStore("mobileTab", "review")}
|
onClick={() => setStore("mobileTab", "review")}
|
||||||
>
|
>
|
||||||
{reviewCount()} Files Changed
|
{diffs().length} Files Changed
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -1055,26 +821,21 @@ export default function Page() {
|
|||||||
when={!mobileReview()}
|
when={!mobileReview()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="relative h-full overflow-hidden">
|
<div class="relative h-full overflow-hidden">
|
||||||
<Show
|
<SessionReviewTab
|
||||||
when={diffsReady()}
|
diffs={diffs}
|
||||||
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
|
view={view}
|
||||||
>
|
diffStyle="unified"
|
||||||
<SessionReviewTab
|
onViewFile={(path) => {
|
||||||
diffs={diffs}
|
const value = file.tab(path)
|
||||||
view={view}
|
tabs().open(value)
|
||||||
diffStyle="unified"
|
file.load(path)
|
||||||
onViewFile={(path) => {
|
}}
|
||||||
const value = file.tab(path)
|
classes={{
|
||||||
tabs().open(value)
|
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
||||||
file.load(path)
|
header: "px-4",
|
||||||
}}
|
container: "px-4",
|
||||||
classes={{
|
}}
|
||||||
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
/>
|
||||||
header: "px-4",
|
|
||||||
container: "px-4",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1107,82 +868,42 @@ export default function Page() {
|
|||||||
"mt-0": showTabs(),
|
"mt-0": showTabs(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={store.turnStart > 0}>
|
<For each={visibleUserMessages()}>
|
||||||
<div class="w-full flex justify-center">
|
{(message) => (
|
||||||
<Button
|
<div
|
||||||
variant="ghost"
|
id={anchor(message.id)}
|
||||||
size="large"
|
data-message-id={message.id}
|
||||||
class="text-12-medium opacity-50"
|
classList={{
|
||||||
onClick={() => setStore("turnStart", 0)}
|
"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)]":
|
||||||
Render earlier messages
|
platform.platform !== "desktop",
|
||||||
</Button>
|
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
|
||||||
</div>
|
platform.platform === "desktop",
|
||||||
</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)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
|
<SessionTurn
|
||||||
</Button>
|
sessionID={params.id!}
|
||||||
</div>
|
messageID={message.id}
|
||||||
</Show>
|
lastUserMessageID={lastUserMessage()?.id}
|
||||||
<For each={renderedUserMessages()}>
|
stepsExpanded={store.expanded[message.id] ?? false}
|
||||||
{(message) => {
|
onStepsExpandedToggle={() =>
|
||||||
if (import.meta.env.DEV) {
|
setStore("expanded", message.id, (open: boolean | undefined) => !open)
|
||||||
onMount(() => {
|
}
|
||||||
const id = params.id
|
classes={{
|
||||||
if (!id) return
|
root: "min-w-0 w-full relative",
|
||||||
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
|
content:
|
||||||
})
|
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
||||||
}
|
container:
|
||||||
|
"px-4 md:px-6 " +
|
||||||
return (
|
(!showTabs()
|
||||||
<div
|
? "md:max-w-200 md:mx-auto"
|
||||||
id={anchor(message.id)}
|
: visibleUserMessages().length > 1
|
||||||
data-message-id={message.id}
|
? "md:pr-6 md:pl-18"
|
||||||
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",
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<SessionTurn
|
</div>
|
||||||
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>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1223,22 +944,13 @@ export default function Page() {
|
|||||||
"md:max-w-200": !showTabs(),
|
"md:max-w-200": !showTabs(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show
|
<PromptInput
|
||||||
when={prompt.ready()}
|
ref={(el) => {
|
||||||
fallback={
|
inputRef = el
|
||||||
<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..."}
|
newSessionWorktree={newSessionWorktree()}
|
||||||
</div>
|
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||||
}
|
/>
|
||||||
>
|
|
||||||
<PromptInput
|
|
||||||
ref={(el) => {
|
|
||||||
inputRef = el
|
|
||||||
}}
|
|
||||||
newSessionWorktree={newSessionWorktree()}
|
|
||||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1322,40 +1034,31 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
<Show when={reviewTab()}>
|
<Show when={reviewTab()}>
|
||||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Show when={activeTab() === "review"}>
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<SessionReviewTab
|
||||||
<Show
|
diffs={diffs}
|
||||||
when={diffsReady()}
|
view={view}
|
||||||
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
|
diffStyle={layout.review.diffStyle()}
|
||||||
>
|
onDiffStyleChange={layout.review.setDiffStyle}
|
||||||
<SessionReviewTab
|
onViewFile={(path) => {
|
||||||
diffs={diffs}
|
const value = file.tab(path)
|
||||||
view={view}
|
tabs().open(value)
|
||||||
diffStyle={layout.review.diffStyle()}
|
file.load(path)
|
||||||
onDiffStyleChange={layout.review.setDiffStyle}
|
}}
|
||||||
onViewFile={(path) => {
|
/>
|
||||||
const value = file.tab(path)
|
</div>
|
||||||
tabs().open(value)
|
|
||||||
file.load(path)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={contextOpen()}>
|
<Show when={contextOpen()}>
|
||||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Show when={activeTab() === "context"}>
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<SessionContextTab
|
||||||
<SessionContextTab
|
messages={messages}
|
||||||
messages={messages}
|
visibleUserMessages={visibleUserMessages}
|
||||||
visibleUserMessages={visibleUserMessages}
|
view={view}
|
||||||
view={view}
|
info={info}
|
||||||
info={info}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
</Show>
|
||||||
<For each={openedTabs()}>
|
<For each={openedTabs()}>
|
||||||
@@ -1404,8 +1107,7 @@ export default function Page() {
|
|||||||
const selectedLines = createMemo(() => {
|
const selectedLines = createMemo(() => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
if (file.ready()) return file.selectedLines(p) ?? null
|
return file.selectedLines(p) ?? null
|
||||||
return handoff.files[p] ?? null
|
|
||||||
})
|
})
|
||||||
const selection = createMemo(() => {
|
const selection = createMemo(() => {
|
||||||
const range = selectedLines()
|
const range = selectedLines()
|
||||||
@@ -1502,63 +1204,37 @@ export default function Page() {
|
|||||||
}}
|
}}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<Show when={activeTab() === tab}>
|
<Show when={selection()}>
|
||||||
<Show when={selection()}>
|
{(sel) => (
|
||||||
{(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">
|
||||||
<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
|
||||||
<button
|
type="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"
|
||||||
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={() => {
|
||||||
onClick={() => {
|
const p = path()
|
||||||
const p = path()
|
if (!p) return
|
||||||
if (!p) return
|
prompt.context.add({ type: "file", path: p, selection: sel() })
|
||||||
prompt.context.add({ type: "file", path: p, selection: sel() })
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Icon name="plus-small" size="small" />
|
||||||
<Icon name="plus-small" size="small" />
|
<span>Add {selectionLabel()} to context</span>
|
||||||
<span>Add {selectionLabel()} to context</span>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</Show>
|
||||||
</Show>
|
<Switch>
|
||||||
<Switch>
|
<Match when={state()?.loaded && isImage()}>
|
||||||
<Match when={state()?.loaded && isImage()}>
|
<div class="px-6 py-4 pb-40">
|
||||||
<div class="px-6 py-4 pb-40">
|
<img src={imageDataUrl()} alt={path()} class="max-w-full" />
|
||||||
<img src={imageDataUrl()} alt={path()} class="max-w-full" />
|
</div>
|
||||||
</div>
|
</Match>
|
||||||
</Match>
|
<Match when={state()?.loaded && isSvg()}>
|
||||||
<Match when={state()?.loaded && isSvg()}>
|
<div class="flex flex-col gap-4 px-6 py-4">
|
||||||
<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
|
<Dynamic
|
||||||
component={codeComponent}
|
component={codeComponent}
|
||||||
file={{
|
file={{
|
||||||
name: path() ?? "",
|
name: path() ?? "",
|
||||||
contents: contents(),
|
contents: svgContent() ?? "",
|
||||||
cacheKey: cacheKey(),
|
cacheKey: cacheKey(),
|
||||||
}}
|
}}
|
||||||
enableLineSelection
|
enableLineSelection
|
||||||
@@ -1569,17 +1245,41 @@ export default function Page() {
|
|||||||
file.setSelectedLines(p, range)
|
file.setSelectedLines(p, range)
|
||||||
}}
|
}}
|
||||||
overflow="scroll"
|
overflow="scroll"
|
||||||
class="select-text pb-40"
|
class="select-text"
|
||||||
/>
|
/>
|
||||||
</Match>
|
<Show when={svgPreviewUrl()}>
|
||||||
<Match when={state()?.loading}>
|
<div class="flex justify-center pb-40">
|
||||||
<div class="px-6 py-4 text-text-weak">Loading...</div>
|
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
||||||
</Match>
|
</div>
|
||||||
<Match when={state()?.error}>
|
</Show>
|
||||||
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
<Match when={state()?.loaded}>
|
||||||
</Show>
|
<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>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -1602,7 +1302,7 @@ export default function Page() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isDesktop() && view().terminal.opened()}>
|
<Show when={isDesktop() && layout.terminal.opened()}>
|
||||||
<div
|
<div
|
||||||
class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
|
class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
|
||||||
style={{ height: `${layout.terminal.height()}px` }}
|
style={{ height: `${layout.terminal.height()}px` }}
|
||||||
@@ -1614,76 +1314,56 @@ export default function Page() {
|
|||||||
max={window.innerHeight * 0.6}
|
max={window.innerHeight * 0.6}
|
||||||
collapseThreshold={50}
|
collapseThreshold={50}
|
||||||
onResize={layout.terminal.resize}
|
onResize={layout.terminal.resize}
|
||||||
onCollapse={view().terminal.close}
|
onCollapse={layout.terminal.close}
|
||||||
/>
|
/>
|
||||||
<Show
|
<DragDropProvider
|
||||||
when={terminal.ready()}
|
onDragStart={handleTerminalDragStart}
|
||||||
fallback={
|
onDragEnd={handleTerminalDragEnd}
|
||||||
<div class="flex flex-col h-full pointer-events-none">
|
onDragOver={handleTerminalDragOver}
|
||||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
collisionDetector={closestCenter}
|
||||||
<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>
|
|
||||||
<div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<DragDropProvider
|
<DragDropSensors />
|
||||||
onDragStart={handleTerminalDragStart}
|
<ConstrainDragYAxis />
|
||||||
onDragEnd={handleTerminalDragEnd}
|
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
|
||||||
onDragOver={handleTerminalDragOver}
|
<Tabs.List class="h-10">
|
||||||
collisionDetector={closestCenter}
|
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
||||||
>
|
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
||||||
<DragDropSensors />
|
</SortableProvider>
|
||||||
<ConstrainDragYAxis />
|
<div class="h-full flex items-center justify-center">
|
||||||
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
|
<TooltipKeybind
|
||||||
<Tabs.List class="h-10">
|
title="New terminal"
|
||||||
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
keybind={command.keybind("terminal.new")}
|
||||||
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
class="flex items-center"
|
||||||
</SortableProvider>
|
>
|
||||||
<div class="h-full flex items-center justify-center">
|
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
||||||
<TooltipKeybind
|
</TooltipKeybind>
|
||||||
title="New terminal"
|
</div>
|
||||||
keybind={command.keybind("terminal.new")}
|
</Tabs.List>
|
||||||
class="flex items-center"
|
<For each={terminal.all()}>
|
||||||
>
|
{(pty) => (
|
||||||
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
<Tabs.Content value={pty.id}>
|
||||||
</TooltipKeybind>
|
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
||||||
</div>
|
</Tabs.Content>
|
||||||
</Tabs.List>
|
)}
|
||||||
<For each={terminal.all()}>
|
</For>
|
||||||
{(pty) => (
|
</Tabs>
|
||||||
<Tabs.Content value={pty.id}>
|
<DragOverlay>
|
||||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
<Show when={store.activeTerminalDraggable}>
|
||||||
</Tabs.Content>
|
{(draggedId) => {
|
||||||
)}
|
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
||||||
</For>
|
return (
|
||||||
</Tabs>
|
<Show when={pty()}>
|
||||||
<DragOverlay>
|
{(t) => (
|
||||||
<Show when={store.activeTerminalDraggable}>
|
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||||
{(draggedId) => {
|
{t().title}
|
||||||
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
</div>
|
||||||
return (
|
)}
|
||||||
<Show when={pty()}>
|
</Show>
|
||||||
{(t) => (
|
)
|
||||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
}}
|
||||||
{t().title}
|
</Show>
|
||||||
</div>
|
</DragOverlay>
|
||||||
)}
|
</DragDropProvider>
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
</DragOverlay>
|
|
||||||
</DragDropProvider>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
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",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ export default function Download() {
|
|||||||
</code>
|
</code>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
<button data-component="cli-row" onClick={handleCopyClick("brew install anomalyco/tap/opencode")}>
|
<button data-component="cli-row" onClick={handleCopyClick("brew install opencode")}>
|
||||||
<code>
|
<code>
|
||||||
brew install <strong>anomalyco/tap/opencode</strong>
|
brew install <strong>opencode</strong>
|
||||||
</code>
|
</code>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export default function Home() {
|
|||||||
<button data-copy data-slot="command" onClick={handleCopyClick}>
|
<button data-copy data-slot="command" onClick={handleCopyClick}>
|
||||||
<span>
|
<span>
|
||||||
<span data-slot="protocol">brew install </span>
|
<span data-slot="protocol">brew install </span>
|
||||||
<span data-slot="highlight">anomalyco/tap/opencode</span>
|
<span data-slot="highlight">opencode</span>
|
||||||
</span>
|
</span>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
|
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
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) {
|
export async function POST(input: APIEvent) {
|
||||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||||
@@ -41,7 +40,7 @@ export async function POST(input: APIEvent) {
|
|||||||
.where(eq(BillingTable.customerID, customerID))
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "checkout.session.completed" && body.data.object.mode === "payment") {
|
if (body.type === "checkout.session.completed") {
|
||||||
const workspaceID = body.data.object.metadata?.workspaceID
|
const workspaceID = body.data.object.metadata?.workspaceID
|
||||||
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
||||||
const customerID = body.data.object.customer as string
|
const customerID = body.data.object.customer as string
|
||||||
@@ -104,112 +103,85 @@ export async function POST(input: APIEvent) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "checkout.session.completed" && body.data.object.mode === "subscription") {
|
if (body.type === "charge.refunded") {
|
||||||
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 customerID = body.data.object.customer as string
|
||||||
const customerEmail = body.data.object.customer_details?.email as string
|
const paymentIntentID = body.data.object.payment_intent as string
|
||||||
const invoiceID = body.data.object.invoice as string
|
if (!customerID) throw new Error("Customer ID not found")
|
||||||
const subscriptionID = body.data.object.subscription as string
|
if (!paymentIntentID) throw new Error("Payment ID not found")
|
||||||
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")
|
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 (!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 (!invoiceID) throw new Error("Invoice ID not found")
|
||||||
if (!subscriptionID) throw new Error("Subscription 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, {
|
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||||
expand: ["payments"],
|
expand: ["payments"],
|
||||||
})
|
})
|
||||||
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||||
if (!paymentID) throw new Error("Payment ID not found")
|
if (!paymentID) throw new Error("Payment ID not found")
|
||||||
|
|
||||||
// get payment method for the payment intent
|
const workspaceID = await Database.use((tx) =>
|
||||||
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
tx
|
||||||
expand: ["payment_method"],
|
.select({ workspaceID: BillingTable.workspaceID })
|
||||||
})
|
.from(BillingTable)
|
||||||
const paymentMethod = paymentIntent.payment_method
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
.then((rows) => rows[0]?.workspaceID),
|
||||||
|
)
|
||||||
|
if (!workspaceID) throw new Error("Workspace ID not found for customer")
|
||||||
|
|
||||||
// get coupon id from promotion code
|
await Database.use((tx) =>
|
||||||
const couponID = await (async () => {
|
tx.insert(PaymentTable).values({
|
||||||
if (!promoCode) return
|
workspaceID,
|
||||||
const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
|
id: Identifier.create("payment"),
|
||||||
const couponID = coupon.coupon.id
|
amount: centsToMicroCents(amountInCents),
|
||||||
if (!couponID) throw new Error("Coupon not found for promotion code")
|
paymentID,
|
||||||
return couponID
|
invoiceID,
|
||||||
})()
|
customerID,
|
||||||
|
}),
|
||||||
// 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") {
|
if (body.type === "customer.subscription.created") {
|
||||||
const data = {
|
const data = {
|
||||||
@@ -406,111 +378,9 @@ export async function POST(input: APIEvent) {
|
|||||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||||
|
|
||||||
await Database.transaction(async (tx) => {
|
await Database.transaction(async (tx) => {
|
||||||
await tx
|
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
.update(BillingTable)
|
|
||||||
.set({ subscriptionID: null, subscriptionCouponID: null })
|
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
|
||||||
|
|
||||||
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
|
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.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))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function () {
|
|||||||
<div data-page="workspace-[id]">
|
<div data-page="workspace-[id]">
|
||||||
<div data-slot="sections">
|
<div data-slot="sections">
|
||||||
<Show when={sessionInfo()?.isAdmin}>
|
<Show when={sessionInfo()?.isAdmin}>
|
||||||
<Show when={billingInfo()?.subscriptionID}>
|
<Show when={sessionInfo()?.isBeta && billingInfo()?.subscriptionID}>
|
||||||
<BlackSection />
|
<BlackSection />
|
||||||
</Show>
|
</Show>
|
||||||
<BillingSection />
|
<BillingSection />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
|
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
|
||||||
import { For, Match, Show, Switch } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { formatDateUTC, formatDateForTable } from "../../common"
|
import { formatDateUTC, formatDateForTable } from "../../common"
|
||||||
import styles from "./payment-section.module.css"
|
import styles from "./payment-section.module.css"
|
||||||
@@ -77,8 +77,7 @@ export function PaymentSection() {
|
|||||||
<For each={payments()!}>
|
<For each={payments()!}>
|
||||||
{(payment) => {
|
{(payment) => {
|
||||||
const date = new Date(payment.timeCreated)
|
const date = new Date(payment.timeCreated)
|
||||||
const amount =
|
const isCredit = !payment.paymentID
|
||||||
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
||||||
@@ -86,14 +85,13 @@ export function PaymentSection() {
|
|||||||
</td>
|
</td>
|
||||||
<td data-slot="payment-id">{payment.id}</td>
|
<td data-slot="payment-id">{payment.id}</td>
|
||||||
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
||||||
${((amount ?? 0) / 100000000).toFixed(2)}
|
${((payment.amount ?? 0) / 100000000).toFixed(2)}
|
||||||
<Switch>
|
{isCredit ? " (credit)" : ""}
|
||||||
<Match when={payment.enrichment?.type === "credit"}> (credit)</Match>
|
|
||||||
<Match when={payment.enrichment?.type === "subscription"}> (subscription)</Match>
|
|
||||||
</Switch>
|
|
||||||
</td>
|
</td>
|
||||||
<td data-slot="payment-receipt">
|
<td data-slot="payment-receipt">
|
||||||
{payment.paymentID ? (
|
{isCredit ? (
|
||||||
|
<span>-</span>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
|
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
|
||||||
@@ -105,8 +103,6 @@ export function PaymentSection() {
|
|||||||
>
|
>
|
||||||
View
|
View
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<span>-</span>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ async function getCosts(workspaceID: string, year: number, month: number) {
|
|||||||
eq(UsageTable.workspaceID, workspaceID),
|
eq(UsageTable.workspaceID, workspaceID),
|
||||||
gte(UsageTable.timeCreated, startDate),
|
gte(UsageTable.timeCreated, startDate),
|
||||||
lte(UsageTable.timeCreated, endDate),
|
lte(UsageTable.timeCreated, endDate),
|
||||||
or(isNull(UsageTable.enrichment), sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') != 'sub'`),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
|
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
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 { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||||
import { BillingTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
import { BillingTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.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 { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
@@ -416,11 +415,11 @@ export async function handler(
|
|||||||
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
||||||
},
|
},
|
||||||
subscription: {
|
subscription: {
|
||||||
id: SubscriptionTable.id,
|
timeSubscribed: UserTable.timeSubscribed,
|
||||||
rollingUsage: SubscriptionTable.rollingUsage,
|
subIntervalUsage: UserTable.subIntervalUsage,
|
||||||
fixedUsage: SubscriptionTable.fixedUsage,
|
subMonthlyUsage: UserTable.subMonthlyUsage,
|
||||||
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
|
||||||
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
|
||||||
},
|
},
|
||||||
provider: {
|
provider: {
|
||||||
credentials: ProviderTable.credentials,
|
credentials: ProviderTable.credentials,
|
||||||
@@ -441,14 +440,6 @@ export async function handler(
|
|||||||
)
|
)
|
||||||
: sql`false`,
|
: 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)))
|
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||||
.then((rows) => rows[0]),
|
.then((rows) => rows[0]),
|
||||||
)
|
)
|
||||||
@@ -457,7 +448,7 @@ export async function handler(
|
|||||||
logger.metric({
|
logger.metric({
|
||||||
api_key: data.apiKey,
|
api_key: data.apiKey,
|
||||||
workspace: data.workspaceID,
|
workspace: data.workspaceID,
|
||||||
isSubscription: data.subscription ? true : false,
|
isSubscription: data.subscription.timeSubscribed ? true : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -465,7 +456,7 @@ export async function handler(
|
|||||||
workspaceID: data.workspaceID,
|
workspaceID: data.workspaceID,
|
||||||
billing: data.billing,
|
billing: data.billing,
|
||||||
user: data.user,
|
user: data.user,
|
||||||
subscription: data.subscription,
|
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
|
||||||
provider: data.provider,
|
provider: data.provider,
|
||||||
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
||||||
isDisabled: !!data.timeDisabled,
|
isDisabled: !!data.timeDisabled,
|
||||||
@@ -493,11 +484,23 @@ export async function handler(
|
|||||||
return `${minutes}min`
|
return `${minutes}min`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check weekly limit
|
// Check monthly limit (based on subscription billing cycle)
|
||||||
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
if (
|
||||||
const week = getWeekBounds(now)
|
sub.subMonthlyUsage &&
|
||||||
if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
|
sub.timeSubMonthlyUsageUpdated &&
|
||||||
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
|
sub.subMonthlyUsage >= centsToMicroCents(black.monthlyLimit * 100)
|
||||||
|
) {
|
||||||
|
const subscribeDay = sub.timeSubscribed!.getUTCDate()
|
||||||
|
const cycleStart = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
|
||||||
|
subscribeDay,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const cycleEnd = new Date(Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay))
|
||||||
|
if (sub.timeSubMonthlyUsageUpdated >= cycleStart && sub.timeSubMonthlyUsageUpdated < cycleEnd) {
|
||||||
|
const retryAfter = Math.ceil((cycleEnd.getTime() - now.getTime()) / 1000)
|
||||||
throw new SubscriptionError(
|
throw new SubscriptionError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||||
retryAfter,
|
retryAfter,
|
||||||
@@ -505,12 +508,14 @@ export async function handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rolling limit
|
// Check interval limit
|
||||||
if (sub.rollingUsage && sub.timeRollingUpdated) {
|
const intervalMs = black.intervalLength * 3600 * 1000
|
||||||
const rollingWindowMs = black.rollingWindow * 3600 * 1000
|
if (sub.subIntervalUsage && sub.timeSubIntervalUsageUpdated) {
|
||||||
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
const currentInterval = Math.floor(now.getTime() / intervalMs)
|
||||||
if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
|
const usageInterval = Math.floor(sub.timeSubIntervalUsageUpdated.getTime() / intervalMs)
|
||||||
const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
|
if (currentInterval === usageInterval && sub.subIntervalUsage >= centsToMicroCents(black.intervalLimit * 100)) {
|
||||||
|
const nextInterval = (currentInterval + 1) * intervalMs
|
||||||
|
const retryAfter = Math.ceil((nextInterval - now.getTime()) / 1000)
|
||||||
throw new SubscriptionError(
|
throw new SubscriptionError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||||
retryAfter,
|
retryAfter,
|
||||||
@@ -656,39 +661,38 @@ export async function handler(
|
|||||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||||
...(authInfo.subscription
|
...(authInfo.subscription
|
||||||
? (() => {
|
? (() => {
|
||||||
const black = BlackData.get()
|
const now = new Date()
|
||||||
const week = getWeekBounds(new Date())
|
const subscribeDay = authInfo.subscription.timeSubscribed!.getUTCDate()
|
||||||
const rollingWindowSeconds = black.rollingWindow * 3600
|
const cycleStart = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
|
||||||
|
subscribeDay,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const cycleEnd = new Date(
|
||||||
|
Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay),
|
||||||
|
)
|
||||||
return [
|
return [
|
||||||
db
|
db
|
||||||
.update(SubscriptionTable)
|
.update(UserTable)
|
||||||
.set({
|
.set({
|
||||||
fixedUsage: sql`
|
subMonthlyUsage: sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost}
|
WHEN ${UserTable.timeSubMonthlyUsageUpdated} >= ${cycleStart} AND ${UserTable.timeSubMonthlyUsageUpdated} < ${cycleEnd} THEN ${UserTable.subMonthlyUsage} + ${cost}
|
||||||
ELSE ${cost}
|
ELSE ${cost}
|
||||||
END
|
END
|
||||||
`,
|
`,
|
||||||
timeFixedUpdated: sql`now()`,
|
timeSubMonthlyUsageUpdated: sql`now()`,
|
||||||
rollingUsage: sql`
|
subIntervalUsage: sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost}
|
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 3600)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 3600)) THEN ${UserTable.subIntervalUsage} + ${cost}
|
||||||
ELSE ${cost}
|
ELSE ${cost}
|
||||||
END
|
END
|
||||||
`,
|
`,
|
||||||
timeRollingUpdated: sql`
|
timeSubIntervalUsageUpdated: sql`now()`,
|
||||||
CASE
|
|
||||||
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.timeRollingUpdated}
|
|
||||||
ELSE now()
|
|
||||||
END
|
|
||||||
`,
|
|
||||||
})
|
})
|
||||||
.where(
|
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||||
and(
|
|
||||||
eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
|
|
||||||
eq(SubscriptionTable.userID, authInfo.user.id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
})()
|
})()
|
||||||
: [
|
: [
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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`)
|
|
||||||
);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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`;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
DROP INDEX `workspace_user_id` ON `subscription`;--> statement-breakpoint
|
|
||||||
ALTER TABLE `subscription` ADD CONSTRAINT `workspace_user_id` UNIQUE(`workspace_id`,`user_id`);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE `billing` ADD `subscription_coupon_id` varchar(28);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE `payment` ADD `enrichment` json;
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -323,41 +323,6 @@
|
|||||||
"when": 1767765497502,
|
"when": 1767765497502,
|
||||||
"tag": "0045_cuddly_diamondback",
|
"tag": "0045_cuddly_diamondback",
|
||||||
"breakpoints": true
|
"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",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Database, and, eq, sql } from "../src/drizzle/index.js"
|
import { Database, eq, sql, inArray } from "../src/drizzle/index.js"
|
||||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||||
import { UserTable } from "../src/schema/user.sql.js"
|
import { UserTable } from "../src/schema/user.sql.js"
|
||||||
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "../src/schema/billing.sql.js"
|
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
|
||||||
import { WorkspaceTable } from "../src/schema/workspace.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
|
// get input from command line
|
||||||
const identifier = process.argv[2]
|
const identifier = process.argv[2]
|
||||||
@@ -59,44 +56,6 @@ async function printWorkspace(workspaceID: string) {
|
|||||||
|
|
||||||
printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
|
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) =>
|
await printTable("Billing", (tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.select({
|
||||||
@@ -165,80 +124,6 @@ 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) {
|
function printHeader(title: string) {
|
||||||
console.log()
|
console.log()
|
||||||
console.log("─".repeat(title.length))
|
console.log("─".repeat(title.length))
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { Billing } from "../src/billing.js"
|
import { Billing } from "../src/billing.js"
|
||||||
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
|
import { Database, eq, and, sql } from "../src/drizzle/index.js"
|
||||||
|
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||||
import { UserTable } from "../src/schema/user.sql.js"
|
import { UserTable } from "../src/schema/user.sql.js"
|
||||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js"
|
||||||
import { Identifier } from "../src/identifier.js"
|
import { Identifier } from "../src/identifier.js"
|
||||||
import { centsToMicroCents } from "../src/util/price.js"
|
import { centsToMicroCents } from "../src/util/price.js"
|
||||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
|
||||||
|
|
||||||
const workspaceID = process.argv[2]
|
const workspaceID = process.argv[2]
|
||||||
const email = process.argv[3]
|
const email = process.argv[3]
|
||||||
|
|
||||||
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
|
|
||||||
|
|
||||||
if (!workspaceID || !email) {
|
if (!workspaceID || !email) {
|
||||||
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
|
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the Stripe customer by email
|
// Look up the Stripe customer by email
|
||||||
const customers = await Billing.stripe().customers.list({ email, limit: 10, expand: ["data.subscriptions"] })
|
const customers = await Billing.stripe().customers.list({ email, limit: 1 })
|
||||||
if (!customers.data) {
|
const customer = customers.data[0]
|
||||||
|
if (!customer) {
|
||||||
console.error(`Error: No Stripe customer found for email ${email}`)
|
console.error(`Error: No Stripe customer found for email ${email}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
const customer = customers.data.find((c) => c.subscriptions?.data[0]?.items.data[0]?.price.unit_amount === 20000)
|
const customerID = customer.id
|
||||||
if (!customer) {
|
|
||||||
console.error(`Error: No Stripe customer found for email ${email} with $200 subscription`)
|
// Get the subscription id
|
||||||
|
const subscriptions = await Billing.stripe().subscriptions.list({ customer: customerID, limit: 1 })
|
||||||
|
const subscription = subscriptions.data[0]
|
||||||
|
if (!subscription) {
|
||||||
|
console.error(`Error: Customer ${customerID} does not have a subscription`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerID = customer.id
|
|
||||||
const subscription = customer.subscriptions!.data[0]
|
|
||||||
const subscriptionID = subscription.id
|
const subscriptionID = subscription.id
|
||||||
|
|
||||||
// Validate the subscription is $200
|
// Validate the subscription is $200
|
||||||
@@ -39,12 +39,6 @@ if (amountInCents !== 20000) {
|
|||||||
process.exit(1)
|
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
|
// Check if subscription is already tied to another workspace
|
||||||
const existingSubscription = await Database.use((tx) =>
|
const existingSubscription = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
@@ -96,21 +90,29 @@ const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.re
|
|||||||
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
|
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
|
||||||
const paymentMethodType = paymentMethod?.type ?? null
|
const paymentMethodType = paymentMethod?.type ?? null
|
||||||
|
|
||||||
// Look up the user in the workspace
|
// Look up the user by email via AuthTable
|
||||||
const users = await Database.use((tx) =>
|
const auth = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.select({ id: UserTable.id, email: AuthTable.subject })
|
.select({ accountID: AuthTable.accountID })
|
||||||
.from(UserTable)
|
.from(AuthTable)
|
||||||
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
|
||||||
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
.then((rows) => rows[0]),
|
||||||
)
|
)
|
||||||
if (users.length === 0) {
|
if (!auth) {
|
||||||
console.error(`Error: No users found in workspace ${workspaceID}`)
|
console.error(`Error: No user found with email ${email}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
const user = users.length === 1 ? users[0] : users.find((u) => u.email === email)
|
|
||||||
|
// Look up the user in the workspace
|
||||||
|
const user = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ id: UserTable.id })
|
||||||
|
.from(UserTable)
|
||||||
|
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, auth.accountID)))
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.error(`Error: User with email ${email} not found in workspace ${workspaceID}`)
|
console.error(`Error: User with email ${email} is not a member of workspace ${workspaceID}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,19 +130,19 @@ await Database.transaction(async (tx) => {
|
|||||||
.set({
|
.set({
|
||||||
customerID,
|
customerID,
|
||||||
subscriptionID,
|
subscriptionID,
|
||||||
subscriptionCouponID: couponID,
|
|
||||||
paymentMethodID,
|
paymentMethodID,
|
||||||
paymentMethodLast4,
|
paymentMethodLast4,
|
||||||
paymentMethodType,
|
paymentMethodType,
|
||||||
})
|
})
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
// Create a row in subscription table
|
// Set current time as timeSubscribed on user
|
||||||
await tx.insert(SubscriptionTable).values({
|
await tx
|
||||||
workspaceID,
|
.update(UserTable)
|
||||||
id: Identifier.create("subscription"),
|
.set({
|
||||||
userID: user.id,
|
timeSubscribed: sql`now()`,
|
||||||
})
|
})
|
||||||
|
.where(eq(UserTable.id, user.id))
|
||||||
|
|
||||||
// Create a row in payments table
|
// Create a row in payments table
|
||||||
await tx.insert(PaymentTable).values({
|
await tx.insert(PaymentTable).values({
|
||||||
@@ -150,10 +152,6 @@ await Database.transaction(async (tx) => {
|
|||||||
customerID,
|
customerID,
|
||||||
invoiceID,
|
invoiceID,
|
||||||
paymentID,
|
paymentID,
|
||||||
enrichment: {
|
|
||||||
type: "subscription",
|
|
||||||
couponID,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -171,9 +171,6 @@ export namespace Billing {
|
|||||||
workspaceID,
|
workspaceID,
|
||||||
id: Identifier.create("payment"),
|
id: Identifier.create("payment"),
|
||||||
amount: amountInMicroCents,
|
amount: amountInMicroCents,
|
||||||
enrichment: {
|
|
||||||
type: "credit",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return amountInMicroCents
|
return amountInMicroCents
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { Resource } from "@opencode-ai/console-resource"
|
|||||||
|
|
||||||
export namespace BlackData {
|
export namespace BlackData {
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
fixedLimit: z.number().int(),
|
monthlyLimit: z.number().int(),
|
||||||
rollingLimit: z.number().int(),
|
intervalLimit: z.number().int(),
|
||||||
rollingWindow: z.number().int(),
|
intervalLength: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const validate = fn(Schema, (input) => {
|
export const validate = fn(Schema, (input) => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export namespace Identifier {
|
|||||||
model: "mod",
|
model: "mod",
|
||||||
payment: "pay",
|
payment: "pay",
|
||||||
provider: "prv",
|
provider: "prv",
|
||||||
subscription: "sub",
|
|
||||||
usage: "usg",
|
usage: "usg",
|
||||||
user: "usr",
|
user: "usr",
|
||||||
workspace: "wrk",
|
workspace: "wrk",
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const BillingTable = mysqlTable(
|
|||||||
timeReloadError: utc("time_reload_error"),
|
timeReloadError: utc("time_reload_error"),
|
||||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||||
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
|
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
...workspaceIndexes(table),
|
...workspaceIndexes(table),
|
||||||
@@ -31,20 +30,6 @@ 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(
|
export const PaymentTable = mysqlTable(
|
||||||
"payment",
|
"payment",
|
||||||
{
|
{
|
||||||
@@ -55,15 +40,6 @@ export const PaymentTable = mysqlTable(
|
|||||||
paymentID: varchar("payment_id", { length: 255 }),
|
paymentID: varchar("payment_id", { length: 255 }),
|
||||||
amount: bigint("amount", { mode: "number" }).notNull(),
|
amount: bigint("amount", { mode: "number" }).notNull(),
|
||||||
timeRefunded: utc("time_refunded"),
|
timeRefunded: utc("time_refunded"),
|
||||||
enrichment: json("enrichment").$type<
|
|
||||||
| {
|
|
||||||
type: "subscription"
|
|
||||||
couponID?: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "credit"
|
|
||||||
}
|
|
||||||
>(),
|
|
||||||
},
|
},
|
||||||
(table) => [...workspaceIndexes(table)],
|
(table) => [...workspaceIndexes(table)],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export const UserTable = mysqlTable(
|
|||||||
monthlyLimit: int("monthly_limit"),
|
monthlyLimit: int("monthly_limit"),
|
||||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||||
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
||||||
|
// subscription
|
||||||
|
timeSubscribed: utc("time_subscribed"),
|
||||||
|
subIntervalUsage: bigint("sub_interval_usage", { mode: "number" }),
|
||||||
|
subMonthlyUsage: bigint("sub_monthly_usage", { mode: "number" }),
|
||||||
|
timeSubIntervalUsageUpdated: utc("sub_time_interval_usage_updated"),
|
||||||
|
timeSubMonthlyUsageUpdated: utc("sub_time_monthly_usage_updated"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
...workspaceIndexes(table),
|
...workspaceIndexes(table),
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -13,7 +13,36 @@
|
|||||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||||
<meta property="og:image" content="/social-share.png" />
|
<meta property="og:image" content="/social-share.png" />
|
||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
<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>
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use tauri::Manager;
|
|
||||||
|
|
||||||
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||||
const CLI_BINARY_NAME: &str = "opencode";
|
const CLI_BINARY_NAME: &str = "opencode";
|
||||||
|
|
||||||
@@ -11,10 +9,9 @@ fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
|
pub fn get_sidecar_path() -> std::path::PathBuf {
|
||||||
// Get binary with symlinks support
|
tauri::utils::platform::current_exe()
|
||||||
tauri::process::current_binary(&app.env())
|
.expect("Failed to get current exe")
|
||||||
.expect("Failed to get current binary")
|
|
||||||
.parent()
|
.parent()
|
||||||
.expect("Failed to get parent dir")
|
.expect("Failed to get parent dir")
|
||||||
.join("opencode-cli")
|
.join("opencode-cli")
|
||||||
@@ -29,12 +26,12 @@ fn is_cli_installed() -> bool {
|
|||||||
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
|
pub fn install_cli() -> Result<String, String> {
|
||||||
if cfg!(not(unix)) {
|
if cfg!(not(unix)) {
|
||||||
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let sidecar = get_sidecar_path(&app);
|
let sidecar = get_sidecar_path();
|
||||||
if !sidecar.exists() {
|
if !sidecar.exists() {
|
||||||
return Err("Sidecar binary not found".to_string());
|
return Err("Sidecar binary not found".to_string());
|
||||||
}
|
}
|
||||||
@@ -111,7 +108,7 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
cli_version, app_version
|
cli_version, app_version
|
||||||
);
|
);
|
||||||
|
|
||||||
install_cli(app)?;
|
install_cli()?;
|
||||||
|
|
||||||
println!("Synced installed CLI");
|
println!("Synced installed CLI");
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
|||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let (mut rx, child) = {
|
let (mut rx, child) = {
|
||||||
let sidecar = get_sidecar_path(app);
|
let sidecar = get_sidecar_path();
|
||||||
let shell = get_user_shell();
|
let shell = get_user_shell();
|
||||||
app.shell()
|
app.shell()
|
||||||
.command(&shell)
|
.command(&shell)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const host = process.env.TAURI_DEV_HOST
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [appPlugin],
|
plugins: [appPlugin],
|
||||||
publicDir: "../app/public",
|
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
// 1. prevent Vite from obscuring rust errors
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.1.8"
|
version = "1.1.6"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.6/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.1.8",
|
"version": "1.1.6",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -81,8 +81,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||||
"@opentui/core": "0.1.72",
|
"@opentui/core": "0.1.69",
|
||||||
"@opentui/solid": "0.1.72",
|
"@opentui/solid": "0.1.69",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
|||||||
@@ -167,8 +167,6 @@ export namespace ACP {
|
|||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
toolCallId: part.callID,
|
toolCallId: part.callID,
|
||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
kind: toToolKind(part.tool),
|
|
||||||
title: part.tool,
|
|
||||||
locations: toLocations(part.tool, part.state.input),
|
locations: toLocations(part.tool, part.state.input),
|
||||||
rawInput: part.state.input,
|
rawInput: part.state.input,
|
||||||
},
|
},
|
||||||
@@ -244,7 +242,6 @@ export namespace ACP {
|
|||||||
kind,
|
kind,
|
||||||
content,
|
content,
|
||||||
title: part.state.title,
|
title: part.state.title,
|
||||||
rawInput: part.state.input,
|
|
||||||
rawOutput: {
|
rawOutput: {
|
||||||
output: part.state.output,
|
output: part.state.output,
|
||||||
metadata: part.state.metadata,
|
metadata: part.state.metadata,
|
||||||
@@ -263,9 +260,6 @@ export namespace ACP {
|
|||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
toolCallId: part.callID,
|
toolCallId: part.callID,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
kind: toToolKind(part.tool),
|
|
||||||
title: part.tool,
|
|
||||||
rawInput: part.state.input,
|
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "content",
|
type: "content",
|
||||||
@@ -497,8 +491,6 @@ export namespace ACP {
|
|||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
toolCallId: part.callID,
|
toolCallId: part.callID,
|
||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
kind: toToolKind(part.tool),
|
|
||||||
title: part.tool,
|
|
||||||
locations: toLocations(part.tool, part.state.input),
|
locations: toLocations(part.tool, part.state.input),
|
||||||
rawInput: part.state.input,
|
rawInput: part.state.input,
|
||||||
},
|
},
|
||||||
@@ -574,7 +566,6 @@ export namespace ACP {
|
|||||||
kind,
|
kind,
|
||||||
content,
|
content,
|
||||||
title: part.state.title,
|
title: part.state.title,
|
||||||
rawInput: part.state.input,
|
|
||||||
rawOutput: {
|
rawOutput: {
|
||||||
output: part.state.output,
|
output: part.state.output,
|
||||||
metadata: part.state.metadata,
|
metadata: part.state.metadata,
|
||||||
@@ -593,9 +584,6 @@ export namespace ACP {
|
|||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
toolCallId: part.callID,
|
toolCallId: part.callID,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
kind: toToolKind(part.tool),
|
|
||||||
title: part.tool,
|
|
||||||
rawInput: part.state.input,
|
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "content",
|
type: "content",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Provider } from "../provider/provider"
|
|||||||
import { generateObject, type ModelMessage } from "ai"
|
import { generateObject, type ModelMessage } from "ai"
|
||||||
import { SystemPrompt } from "../session/system"
|
import { SystemPrompt } from "../session/system"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Truncate } from "../tool/truncation"
|
|
||||||
|
|
||||||
import PROMPT_GENERATE from "./generate.txt"
|
import PROMPT_GENERATE from "./generate.txt"
|
||||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||||
@@ -47,11 +46,7 @@ export namespace Agent {
|
|||||||
const defaults = PermissionNext.fromConfig({
|
const defaults = PermissionNext.fromConfig({
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
doom_loop: "ask",
|
doom_loop: "ask",
|
||||||
external_directory: {
|
external_directory: "ask",
|
||||||
"*": "ask",
|
|
||||||
[Truncate.DIR]: "allow",
|
|
||||||
},
|
|
||||||
question: "deny",
|
|
||||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||||
read: {
|
read: {
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
@@ -66,13 +61,7 @@ export namespace Agent {
|
|||||||
build: {
|
build: {
|
||||||
name: "build",
|
name: "build",
|
||||||
options: {},
|
options: {},
|
||||||
permission: PermissionNext.merge(
|
permission: PermissionNext.merge(defaults, user),
|
||||||
defaults,
|
|
||||||
PermissionNext.fromConfig({
|
|
||||||
question: "allow",
|
|
||||||
}),
|
|
||||||
user,
|
|
||||||
),
|
|
||||||
mode: "primary",
|
mode: "primary",
|
||||||
native: true,
|
native: true,
|
||||||
},
|
},
|
||||||
@@ -82,7 +71,6 @@ export namespace Agent {
|
|||||||
permission: PermissionNext.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
PermissionNext.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
question: "allow",
|
|
||||||
edit: {
|
edit: {
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
".opencode/plan/*.md": "allow",
|
".opencode/plan/*.md": "allow",
|
||||||
@@ -122,9 +110,6 @@ export namespace Agent {
|
|||||||
websearch: "allow",
|
websearch: "allow",
|
||||||
codesearch: "allow",
|
codesearch: "allow",
|
||||||
read: "allow",
|
read: "allow",
|
||||||
external_directory: {
|
|
||||||
[Truncate.DIR]: "allow",
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
),
|
),
|
||||||
@@ -209,21 +194,6 @@ export namespace Agent {
|
|||||||
item.options = mergeDeep(item.options, value.options ?? {})
|
item.options = mergeDeep(item.options, value.options ?? {})
|
||||||
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
|
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
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { Global } from "../global"
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
export const Oauth = z
|
export const Oauth = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -341,6 +341,8 @@ export const AuthLoginCommand = cmd({
|
|||||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
|
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
|
||||||
)
|
)
|
||||||
|
prompts.outro("Done")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === "opencode") {
|
if (provider === "opencode") {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { cmd } from "../cmd"
|
|||||||
|
|
||||||
export const AgentCommand = cmd({
|
export const AgentCommand = cmd({
|
||||||
command: "agent <name>",
|
command: "agent <name>",
|
||||||
describe: "show agent configuration details",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.positional("name", {
|
yargs.positional("name", {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { cmd } from "../cmd"
|
|||||||
|
|
||||||
export const ConfigCommand = cmd({
|
export const ConfigCommand = cmd({
|
||||||
command: "config",
|
command: "config",
|
||||||
describe: "show resolved configuration",
|
|
||||||
builder: (yargs) => yargs,
|
builder: (yargs) => yargs,
|
||||||
async handler() {
|
async handler() {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Ripgrep } from "@/file/ripgrep"
|
|||||||
|
|
||||||
const FileSearchCommand = cmd({
|
const FileSearchCommand = cmd({
|
||||||
command: "search <query>",
|
command: "search <query>",
|
||||||
describe: "search files by query",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.positional("query", {
|
yargs.positional("query", {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -23,7 +22,6 @@ const FileSearchCommand = cmd({
|
|||||||
|
|
||||||
const FileReadCommand = cmd({
|
const FileReadCommand = cmd({
|
||||||
command: "read <path>",
|
command: "read <path>",
|
||||||
describe: "read file contents as JSON",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.positional("path", {
|
yargs.positional("path", {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -40,7 +38,6 @@ const FileReadCommand = cmd({
|
|||||||
|
|
||||||
const FileStatusCommand = cmd({
|
const FileStatusCommand = cmd({
|
||||||
command: "status",
|
command: "status",
|
||||||
describe: "show file status information",
|
|
||||||
builder: (yargs) => yargs,
|
builder: (yargs) => yargs,
|
||||||
async handler() {
|
async handler() {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
@@ -52,7 +49,6 @@ const FileStatusCommand = cmd({
|
|||||||
|
|
||||||
const FileListCommand = cmd({
|
const FileListCommand = cmd({
|
||||||
command: "list <path>",
|
command: "list <path>",
|
||||||
describe: "list files in a directory",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.positional("path", {
|
yargs.positional("path", {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -69,7 +65,6 @@ const FileListCommand = cmd({
|
|||||||
|
|
||||||
const FileTreeCommand = cmd({
|
const FileTreeCommand = cmd({
|
||||||
command: "tree [dir]",
|
command: "tree [dir]",
|
||||||
describe: "show directory tree",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.positional("dir", {
|
yargs.positional("dir", {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -84,7 +79,6 @@ const FileTreeCommand = cmd({
|
|||||||
|
|
||||||
export const FileCommand = cmd({
|
export const FileCommand = cmd({
|
||||||
command: "file",
|
command: "file",
|
||||||
describe: "file system debugging utilities",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs
|
yargs
|
||||||
.command(FileReadCommand)
|
.command(FileReadCommand)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { AgentCommand } from "./agent"
|
|||||||
|
|
||||||
export const DebugCommand = cmd({
|
export const DebugCommand = cmd({
|
||||||
command: "debug",
|
command: "debug",
|
||||||
describe: "debugging and troubleshooting tools",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs
|
yargs
|
||||||
.command(ConfigCommand)
|
.command(ConfigCommand)
|
||||||
@@ -26,7 +25,6 @@ export const DebugCommand = cmd({
|
|||||||
.command(PathsCommand)
|
.command(PathsCommand)
|
||||||
.command({
|
.command({
|
||||||
command: "wait",
|
command: "wait",
|
||||||
describe: "wait indefinitely (for debugging)",
|
|
||||||
async handler() {
|
async handler() {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
|
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
|
||||||
@@ -39,7 +37,6 @@ export const DebugCommand = cmd({
|
|||||||
|
|
||||||
const PathsCommand = cmd({
|
const PathsCommand = cmd({
|
||||||
command: "paths",
|
command: "paths",
|
||||||
describe: "show global paths (data, config, cache, state)",
|
|
||||||
handler() {
|
handler() {
|
||||||
for (const [key, value] of Object.entries(Global.Path)) {
|
for (const [key, value] of Object.entries(Global.Path)) {
|
||||||
console.log(key.padEnd(10), value)
|
console.log(key.padEnd(10), value)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { EOL } from "os"
|
|||||||
|
|
||||||
export const LSPCommand = cmd({
|
export const LSPCommand = cmd({
|
||||||
command: "lsp",
|
command: "lsp",
|
||||||
describe: "LSP debugging utilities",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
|
yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
|
||||||
async handler() {},
|
async handler() {},
|
||||||
@@ -14,7 +13,6 @@ export const LSPCommand = cmd({
|
|||||||
|
|
||||||
const DiagnosticsCommand = cmd({
|
const DiagnosticsCommand = cmd({
|
||||||
command: "diagnostics <file>",
|
command: "diagnostics <file>",
|
||||||
describe: "get diagnostics for a file",
|
|
||||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
@@ -27,7 +25,6 @@ const DiagnosticsCommand = cmd({
|
|||||||
|
|
||||||
export const SymbolsCommand = cmd({
|
export const SymbolsCommand = cmd({
|
||||||
command: "symbols <query>",
|
command: "symbols <query>",
|
||||||
describe: "search workspace symbols",
|
|
||||||
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
|
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
@@ -40,7 +37,6 @@ export const SymbolsCommand = cmd({
|
|||||||
|
|
||||||
export const DocumentSymbolsCommand = cmd({
|
export const DocumentSymbolsCommand = cmd({
|
||||||
command: "document-symbols <uri>",
|
command: "document-symbols <uri>",
|
||||||
describe: "get symbols from a document",
|
|
||||||
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
|
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import { cmd } from "../cmd"
|
|||||||
|
|
||||||
export const RipgrepCommand = cmd({
|
export const RipgrepCommand = cmd({
|
||||||
command: "rg",
|
command: "rg",
|
||||||
describe: "ripgrep debugging utilities",
|
|
||||||
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
|
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
|
||||||
async handler() {},
|
async handler() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const TreeCommand = cmd({
|
const TreeCommand = cmd({
|
||||||
command: "tree",
|
command: "tree",
|
||||||
describe: "show file tree using ripgrep",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.option("limit", {
|
yargs.option("limit", {
|
||||||
type: "number",
|
type: "number",
|
||||||
@@ -27,7 +25,6 @@ const TreeCommand = cmd({
|
|||||||
|
|
||||||
const FilesCommand = cmd({
|
const FilesCommand = cmd({
|
||||||
command: "files",
|
command: "files",
|
||||||
describe: "list files using ripgrep",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs
|
yargs
|
||||||
.option("query", {
|
.option("query", {
|
||||||
@@ -59,7 +56,6 @@ const FilesCommand = cmd({
|
|||||||
|
|
||||||
const SearchCommand = cmd({
|
const SearchCommand = cmd({
|
||||||
command: "search <pattern>",
|
command: "search <pattern>",
|
||||||
describe: "search file contents using ripgrep",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs
|
yargs
|
||||||
.positional("pattern", {
|
.positional("pattern", {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { cmd } from "../cmd"
|
|||||||
|
|
||||||
export const ScrapCommand = cmd({
|
export const ScrapCommand = cmd({
|
||||||
command: "scrap",
|
command: "scrap",
|
||||||
describe: "list all known projects",
|
|
||||||
builder: (yargs) => yargs,
|
builder: (yargs) => yargs,
|
||||||
async handler() {
|
async handler() {
|
||||||
const timer = Log.Default.time("scrap")
|
const timer = Log.Default.time("scrap")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { cmd } from "../cmd"
|
|||||||
|
|
||||||
export const SkillCommand = cmd({
|
export const SkillCommand = cmd({
|
||||||
command: "skill",
|
command: "skill",
|
||||||
describe: "list all available skills",
|
|
||||||
builder: (yargs) => yargs,
|
builder: (yargs) => yargs,
|
||||||
async handler() {
|
async handler() {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ import { cmd } from "../cmd"
|
|||||||
|
|
||||||
export const SnapshotCommand = cmd({
|
export const SnapshotCommand = cmd({
|
||||||
command: "snapshot",
|
command: "snapshot",
|
||||||
describe: "snapshot debugging utilities",
|
|
||||||
builder: (yargs) => yargs.command(TrackCommand).command(PatchCommand).command(DiffCommand).demandCommand(),
|
builder: (yargs) => yargs.command(TrackCommand).command(PatchCommand).command(DiffCommand).demandCommand(),
|
||||||
async handler() {},
|
async handler() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const TrackCommand = cmd({
|
const TrackCommand = cmd({
|
||||||
command: "track",
|
command: "track",
|
||||||
describe: "track current snapshot state",
|
|
||||||
async handler() {
|
async handler() {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
console.log(await Snapshot.track())
|
console.log(await Snapshot.track())
|
||||||
@@ -21,7 +19,6 @@ const TrackCommand = cmd({
|
|||||||
|
|
||||||
const PatchCommand = cmd({
|
const PatchCommand = cmd({
|
||||||
command: "patch <hash>",
|
command: "patch <hash>",
|
||||||
describe: "show patch for a snapshot hash",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.positional("hash", {
|
yargs.positional("hash", {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -37,7 +34,6 @@ const PatchCommand = cmd({
|
|||||||
|
|
||||||
const DiffCommand = cmd({
|
const DiffCommand = cmd({
|
||||||
command: "diff <hash>",
|
command: "diff <hash>",
|
||||||
describe: "show diff for a snapshot hash",
|
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.positional("hash", {
|
yargs.positional("hash", {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|||||||
@@ -515,15 +515,7 @@ export const GithubRunCommand = cmd({
|
|||||||
|
|
||||||
// Setup opencode session
|
// Setup opencode session
|
||||||
const repoData = await fetchRepo()
|
const repoData = await fetchRepo()
|
||||||
session = await Session.create({
|
session = await Session.create({})
|
||||||
permission: [
|
|
||||||
{
|
|
||||||
permission: "question",
|
|
||||||
action: "deny",
|
|
||||||
pattern: "*",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
subscribeSessionEvents()
|
subscribeSessionEvents()
|
||||||
shareId = await (async () => {
|
shareId = await (async () => {
|
||||||
if (share === false) return
|
if (share === false) return
|
||||||
|
|||||||
@@ -292,28 +292,7 @@ export const RunCommand = cmd({
|
|||||||
: args.title
|
: args.title
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const result = await sdk.session.create(
|
const result = await sdk.session.create(title ? { title } : {})
|
||||||
title
|
|
||||||
? {
|
|
||||||
title,
|
|
||||||
permission: [
|
|
||||||
{
|
|
||||||
permission: "question",
|
|
||||||
action: "deny",
|
|
||||||
pattern: "*",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
permission: [
|
|
||||||
{
|
|
||||||
permission: "question",
|
|
||||||
action: "deny",
|
|
||||||
pattern: "*",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return result.data?.id
|
return result.data?.id
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|||||||
@@ -653,17 +653,9 @@ function ErrorComponent(props: {
|
|||||||
mode?: "dark" | "light"
|
mode?: "dark" | "light"
|
||||||
}) {
|
}) {
|
||||||
const term = useTerminalDimensions()
|
const term = useTerminalDimensions()
|
||||||
const renderer = useRenderer()
|
|
||||||
|
|
||||||
const handleExit = async () => {
|
|
||||||
renderer.setTerminalTitle("")
|
|
||||||
renderer.destroy()
|
|
||||||
props.onExit()
|
|
||||||
}
|
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (evt.ctrl && evt.name === "c") {
|
if (evt.ctrl && evt.name === "c") {
|
||||||
handleExit()
|
props.onExit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
@@ -716,7 +708,7 @@ function ErrorComponent(props: {
|
|||||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||||
<text fg={colors.bg}>Reset TUI</text>
|
<text fg={colors.bg}>Reset TUI</text>
|
||||||
</box>
|
</box>
|
||||||
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
|
<box onMouseUp={props.onExit} backgroundColor={colors.primary} padding={1}>
|
||||||
<text fg={colors.bg}>Exit</text>
|
<text fg={colors.bg}>Exit</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -33,82 +33,76 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
const q = query()
|
const q = query()
|
||||||
const needle = q.trim()
|
const favorites = showExtra() ? local.model.favorite() : []
|
||||||
const showSections = showExtra() && needle.length === 0
|
|
||||||
const favorites = connected() ? local.model.favorite() : []
|
|
||||||
const recents = local.model.recent()
|
const recents = local.model.recent()
|
||||||
|
|
||||||
const recentList = showSections
|
const recentList = showExtra()
|
||||||
? recents.filter(
|
? recents.filter(
|
||||||
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
|
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const favoriteOptions = showSections
|
const favoriteOptions = favorites.flatMap((item) => {
|
||||||
? favorites.flatMap((item) => {
|
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
if (!provider) return []
|
||||||
if (!provider) return []
|
const model = provider.models[item.modelID]
|
||||||
const model = provider.models[item.modelID]
|
if (!model) return []
|
||||||
if (!model) return []
|
return [
|
||||||
return [
|
{
|
||||||
{
|
key: item,
|
||||||
key: item,
|
value: {
|
||||||
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(
|
||||||
|
{
|
||||||
providerID: provider.id,
|
providerID: provider.id,
|
||||||
modelID: model.id,
|
modelID: model.id,
|
||||||
},
|
},
|
||||||
title: model.name ?? item.modelID,
|
{ recent: true },
|
||||||
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 = showSections
|
const recentOptions = recentList.flatMap((item) => {
|
||||||
? recentList.flatMap((item) => {
|
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
if (!provider) return []
|
||||||
if (!provider) return []
|
const model = provider.models[item.modelID]
|
||||||
const model = provider.models[item.modelID]
|
if (!model) return []
|
||||||
if (!model) return []
|
return [
|
||||||
return [
|
{
|
||||||
{
|
key: item,
|
||||||
key: item,
|
value: {
|
||||||
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(
|
||||||
|
{
|
||||||
providerID: provider.id,
|
providerID: provider.id,
|
||||||
modelID: model.id,
|
modelID: model.id,
|
||||||
},
|
},
|
||||||
title: model.name ?? item.modelID,
|
{ recent: true },
|
||||||
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(
|
const providerOptions = pipe(
|
||||||
sync.data.provider,
|
sync.data.provider,
|
||||||
@@ -151,7 +145,6 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
filter((x) => {
|
filter((x) => {
|
||||||
if (!showSections) return true
|
|
||||||
const value = x.value
|
const value = x.value
|
||||||
const inFavorites = favorites.some(
|
const inFavorites = favorites.some(
|
||||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||||
@@ -184,11 +177,16 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
// Search shows a single merged list (favorites inline)
|
// Apply fuzzy filtering to each section separately, maintaining section order
|
||||||
if (needle) {
|
if (q) {
|
||||||
const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
|
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj)
|
||||||
const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj)
|
const filteredRecents = fuzzysort
|
||||||
return [...filteredProviders, ...filteredPopular]
|
.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]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
|
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export type AutocompleteRef = {
|
|||||||
|
|
||||||
export type AutocompleteOption = {
|
export type AutocompleteOption = {
|
||||||
display: string
|
display: string
|
||||||
value?: string
|
|
||||||
aliases?: string[]
|
aliases?: string[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
description?: string
|
description?: string
|
||||||
@@ -222,7 +221,6 @@ export function Autocomplete(props: {
|
|||||||
const isDir = item.endsWith("/")
|
const isDir = item.endsWith("/")
|
||||||
return {
|
return {
|
||||||
display: Locale.truncateMiddle(filename, width),
|
display: Locale.truncateMiddle(filename, width),
|
||||||
value: filename,
|
|
||||||
isDirectory: isDir,
|
isDirectory: isDir,
|
||||||
path: item,
|
path: item,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
@@ -261,10 +259,8 @@ export function Autocomplete(props: {
|
|||||||
const width = props.anchor().width - 4
|
const width = props.anchor().width - 4
|
||||||
|
|
||||||
for (const res of Object.values(sync.data.mcp_resource)) {
|
for (const res of Object.values(sync.data.mcp_resource)) {
|
||||||
const text = `${res.name} (${res.uri})`
|
|
||||||
options.push({
|
options.push({
|
||||||
display: Locale.truncateMiddle(text, width),
|
display: Locale.truncateMiddle(`${res.name} (${res.uri})`, width),
|
||||||
value: text,
|
|
||||||
description: res.description,
|
description: res.description,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
insertPart(res.name, {
|
insertPart(res.name, {
|
||||||
@@ -489,11 +485,7 @@ export function Autocomplete(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
|
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
|
||||||
keys: [
|
keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""],
|
||||||
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
|
|
||||||
"description",
|
|
||||||
(obj) => obj.aliases?.join(" ") ?? "",
|
|
||||||
],
|
|
||||||
limit: 10,
|
limit: 10,
|
||||||
scoreFn: (objResults) => {
|
scoreFn: (objResults) => {
|
||||||
const displayResult = objResults[0]
|
const displayResult = objResults[0]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
|||||||
return ready()
|
return ready()
|
||||||
},
|
},
|
||||||
signal<T>(name: string, defaultValue: T) {
|
signal<T>(name: string, defaultValue: T) {
|
||||||
if (kvStore[name] === undefined) setKvStore(name, defaultValue)
|
if (!kvStore[name]) setKvStore(name, defaultValue)
|
||||||
return [
|
return [
|
||||||
function () {
|
function () {
|
||||||
return result.get(name)
|
return result.get(name)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
Todo,
|
Todo,
|
||||||
Command,
|
Command,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
|
||||||
LspStatus,
|
LspStatus,
|
||||||
McpStatus,
|
McpStatus,
|
||||||
McpResource,
|
McpResource,
|
||||||
@@ -43,9 +42,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
permission: {
|
permission: {
|
||||||
[sessionID: string]: PermissionRequest[]
|
[sessionID: string]: PermissionRequest[]
|
||||||
}
|
}
|
||||||
question: {
|
|
||||||
[sessionID: string]: QuestionRequest[]
|
|
||||||
}
|
|
||||||
config: Config
|
config: Config
|
||||||
session: Session[]
|
session: Session[]
|
||||||
session_status: {
|
session_status: {
|
||||||
@@ -84,7 +80,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
status: "loading",
|
status: "loading",
|
||||||
agent: [],
|
agent: [],
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
|
||||||
command: [],
|
command: [],
|
||||||
provider: [],
|
provider: [],
|
||||||
provider_default: {},
|
provider_default: {},
|
||||||
@@ -147,44 +142,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "question.replied":
|
|
||||||
case "question.rejected": {
|
|
||||||
const requests = store.question[event.properties.sessionID]
|
|
||||||
if (!requests) break
|
|
||||||
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
|
|
||||||
if (!match.found) break
|
|
||||||
setStore(
|
|
||||||
"question",
|
|
||||||
event.properties.sessionID,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(match.index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "question.asked": {
|
|
||||||
const request = event.properties
|
|
||||||
const requests = store.question[request.sessionID]
|
|
||||||
if (!requests) {
|
|
||||||
setStore("question", request.sessionID, [request])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const match = Binary.search(requests, request.id, (r) => r.id)
|
|
||||||
if (match.found) {
|
|
||||||
setStore("question", request.sessionID, match.index, reconcile(request))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
setStore(
|
|
||||||
"question",
|
|
||||||
request.sessionID,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(match.index, 0, request)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "todo.updated":
|
case "todo.updated":
|
||||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -102,16 +102,15 @@ type Theme = ThemeColors & {
|
|||||||
thinkingOpacity: number
|
thinkingOpacity: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
export function selectedForeground(theme: Theme): RGBA {
|
||||||
// If theme explicitly defines selectedListItemText, use it
|
// If theme explicitly defines selectedListItemText, use it
|
||||||
if (theme._hasSelectedListItemText) {
|
if (theme._hasSelectedListItemText) {
|
||||||
return theme.selectedListItemText
|
return theme.selectedListItemText
|
||||||
}
|
}
|
||||||
|
|
||||||
// For transparent backgrounds, calculate contrast based on the actual bg (or fallback to primary)
|
// For transparent backgrounds, calculate contrast based on primary color
|
||||||
if (theme.background.a === 0) {
|
if (theme.background.a === 0) {
|
||||||
const targetColor = bg ?? theme.primary
|
const { r, g, b } = theme.primary
|
||||||
const { r, g, b } = targetColor
|
|
||||||
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
const luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
||||||
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255)
|
return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { useRouteData } from "@tui/context/route"
|
|||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { pipe, sumBy } from "remeda"
|
import { pipe, sumBy } from "remeda"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder, EmptyBorder } from "@tui/component/border"
|
||||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
||||||
|
import { useDirectory } from "../../context/directory"
|
||||||
import { useKeybind } from "../../context/keybind"
|
import { useKeybind } from "../../context/keybind"
|
||||||
|
|
||||||
const Title = (props: { session: Accessor<Session> }) => {
|
const Title = (props: { session: Accessor<Session> }) => {
|
||||||
@@ -32,6 +33,7 @@ export function Header() {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||||
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
const total = pipe(
|
const total = pipe(
|
||||||
@@ -97,6 +99,24 @@ export function Header() {
|
|||||||
<Title session={session} />
|
<Title session={session} />
|
||||||
<ContextInfo context={context} cost={cost} />
|
<ContextInfo context={context} cost={cost} />
|
||||||
</box>
|
</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>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
batch,
|
|
||||||
createContext,
|
createContext,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
@@ -42,7 +41,6 @@ import type { EditTool } from "@/tool/edit"
|
|||||||
import type { PatchTool } from "@/tool/patch"
|
import type { PatchTool } from "@/tool/patch"
|
||||||
import type { WebFetchTool } from "@/tool/webfetch"
|
import type { WebFetchTool } from "@/tool/webfetch"
|
||||||
import type { TaskTool } from "@/tool/task"
|
import type { TaskTool } from "@/tool/task"
|
||||||
import type { QuestionTool } from "@/tool/question"
|
|
||||||
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||||
@@ -53,6 +51,7 @@ import { useDialog } from "../../ui/dialog"
|
|||||||
import { TodoItem } from "../../component/todo-item"
|
import { TodoItem } from "../../component/todo-item"
|
||||||
import { DialogMessage } from "./dialog-message"
|
import { DialogMessage } from "./dialog-message"
|
||||||
import type { PromptInfo } from "../../component/prompt/history"
|
import type { PromptInfo } from "../../component/prompt/history"
|
||||||
|
import { iife } from "@/util/iife"
|
||||||
import { DialogConfirm } from "@tui/ui/dialog-confirm"
|
import { DialogConfirm } from "@tui/ui/dialog-confirm"
|
||||||
import { DialogTimeline } from "./dialog-timeline"
|
import { DialogTimeline } from "./dialog-timeline"
|
||||||
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
|
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
|
||||||
@@ -70,7 +69,6 @@ import { usePromptRef } from "../../context/prompt"
|
|||||||
import { useExit } from "../../context/exit"
|
import { useExit } from "../../context/exit"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { PermissionPrompt } from "./permission"
|
import { PermissionPrompt } from "./permission"
|
||||||
import { QuestionPrompt } from "./question"
|
|
||||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||||
import { formatTranscript } from "../../util/transcript"
|
import { formatTranscript } from "../../util/transcript"
|
||||||
|
|
||||||
@@ -92,6 +90,7 @@ const context = createContext<{
|
|||||||
conceal: () => boolean
|
conceal: () => boolean
|
||||||
showThinking: () => boolean
|
showThinking: () => boolean
|
||||||
showTimestamps: () => boolean
|
showTimestamps: () => boolean
|
||||||
|
usernameVisible: () => boolean
|
||||||
showDetails: () => boolean
|
showDetails: () => boolean
|
||||||
diffWrapMode: () => "word" | "none"
|
diffWrapMode: () => "word" | "none"
|
||||||
sync: ReturnType<typeof useSync>
|
sync: ReturnType<typeof useSync>
|
||||||
@@ -119,13 +118,9 @@ export function Session() {
|
|||||||
})
|
})
|
||||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||||
const permissions = createMemo(() => {
|
const permissions = createMemo(() => {
|
||||||
if (session()?.parentID) return []
|
if (session()?.parentID) return sync.data.permission[route.sessionID] ?? []
|
||||||
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
|
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
|
||||||
})
|
})
|
||||||
const questions = createMemo(() => {
|
|
||||||
if (session()?.parentID) return []
|
|
||||||
return children().flatMap((x) => sync.data.question[x.id] ?? [])
|
|
||||||
})
|
|
||||||
|
|
||||||
const pending = createMemo(() => {
|
const pending = createMemo(() => {
|
||||||
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
||||||
@@ -136,25 +131,24 @@ export function Session() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "hide")
|
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
|
||||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
|
||||||
const [conceal, setConceal] = createSignal(true)
|
const [conceal, setConceal] = createSignal(true)
|
||||||
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
|
const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true))
|
||||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
|
||||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
|
||||||
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
|
||||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true))
|
||||||
|
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
|
||||||
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
|
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
|
||||||
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
|
||||||
|
|
||||||
const wide = createMemo(() => dimensions().width > 120)
|
const wide = createMemo(() => dimensions().width > 120)
|
||||||
const sidebarVisible = createMemo(() => {
|
const sidebarVisible = createMemo(() => {
|
||||||
if (session()?.parentID) return false
|
if (session()?.parentID) return false
|
||||||
if (sidebarOpen()) return true
|
if (sidebar() === "show") return true
|
||||||
if (sidebar() === "auto" && wide()) return true
|
if (sidebar() === "auto" && wide()) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
const showTimestamps = createMemo(() => timestamps() === "show")
|
|
||||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
||||||
|
|
||||||
const scrollAcceleration = createMemo(() => {
|
const scrollAcceleration = createMemo(() => {
|
||||||
@@ -455,10 +449,26 @@ export function Session() {
|
|||||||
keybind: "sidebar_toggle",
|
keybind: "sidebar_toggle",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
onSelect: (dialog) => {
|
onSelect: (dialog) => {
|
||||||
batch(() => {
|
setSidebar((prev) => {
|
||||||
const isVisible = sidebarVisible()
|
if (prev === "auto") return sidebarVisible() ? "hide" : "show"
|
||||||
setSidebar(() => (isVisible ? "hide" : "auto"))
|
if (prev === "show") return "hide"
|
||||||
setSidebarOpen(!isVisible)
|
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
|
||||||
})
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -478,7 +488,11 @@ export function Session() {
|
|||||||
value: "session.toggle.timestamps",
|
value: "session.toggle.timestamps",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
onSelect: (dialog) => {
|
onSelect: (dialog) => {
|
||||||
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
|
setShowTimestamps((prev) => {
|
||||||
|
const next = !prev
|
||||||
|
kv.set("timestamps", next ? "show" : "hide")
|
||||||
|
return next
|
||||||
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -487,7 +501,11 @@ export function Session() {
|
|||||||
value: "session.toggle.thinking",
|
value: "session.toggle.thinking",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
onSelect: (dialog) => {
|
onSelect: (dialog) => {
|
||||||
setShowThinking((prev) => !prev)
|
setShowThinking((prev) => {
|
||||||
|
const next = !prev
|
||||||
|
kv.set("thinking_visibility", next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -506,7 +524,9 @@ export function Session() {
|
|||||||
keybind: "tool_details",
|
keybind: "tool_details",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
onSelect: (dialog) => {
|
onSelect: (dialog) => {
|
||||||
setShowDetails((prev) => !prev)
|
const newValue = !showDetails()
|
||||||
|
setShowDetails(newValue)
|
||||||
|
kv.set("tool_details_visibility", newValue)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -516,7 +536,11 @@ export function Session() {
|
|||||||
keybind: "scrollbar_toggle",
|
keybind: "scrollbar_toggle",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
onSelect: (dialog) => {
|
onSelect: (dialog) => {
|
||||||
setShowScrollbar((prev) => !prev)
|
setShowScrollbar((prev) => {
|
||||||
|
const next = !prev
|
||||||
|
kv.set("scrollbar_visible", next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -525,7 +549,11 @@ export function Session() {
|
|||||||
value: "session.toggle.animations",
|
value: "session.toggle.animations",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
onSelect: (dialog) => {
|
onSelect: (dialog) => {
|
||||||
setAnimationsEnabled((prev) => !prev)
|
setAnimationsEnabled((prev) => {
|
||||||
|
const next = !prev
|
||||||
|
kv.set("animations_enabled", next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -879,6 +907,7 @@ export function Session() {
|
|||||||
conceal,
|
conceal,
|
||||||
showThinking,
|
showThinking,
|
||||||
showTimestamps,
|
showTimestamps,
|
||||||
|
usernameVisible,
|
||||||
showDetails,
|
showDetails,
|
||||||
diffWrapMode,
|
diffWrapMode,
|
||||||
sync,
|
sync,
|
||||||
@@ -887,7 +916,7 @@ export function Session() {
|
|||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
|
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
<Show when={session()}>
|
<Show when={session()}>
|
||||||
<Show when={!sidebarVisible() || !wide()}>
|
<Show when={!sidebarVisible()}>
|
||||||
<Header />
|
<Header />
|
||||||
</Show>
|
</Show>
|
||||||
<scrollbox
|
<scrollbox
|
||||||
@@ -1008,48 +1037,27 @@ export function Session() {
|
|||||||
<Show when={permissions().length > 0}>
|
<Show when={permissions().length > 0}>
|
||||||
<PermissionPrompt request={permissions()[0]} />
|
<PermissionPrompt request={permissions()[0]} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={permissions().length === 0 && questions().length > 0}>
|
|
||||||
<QuestionPrompt request={questions()[0]} />
|
|
||||||
</Show>
|
|
||||||
<Prompt
|
<Prompt
|
||||||
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
|
visible={!session()?.parentID && permissions().length === 0}
|
||||||
ref={(r) => {
|
ref={(r) => {
|
||||||
prompt = r
|
prompt = r
|
||||||
promptRef.set(r)
|
promptRef.set(r)
|
||||||
// Apply initial prompt when prompt component mounts (e.g., from fork)
|
|
||||||
if (route.initialPrompt) {
|
|
||||||
r.set(route.initialPrompt)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={permissions().length > 0 || questions().length > 0}
|
disabled={permissions().length > 0}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
toBottom()
|
toBottom()
|
||||||
}}
|
}}
|
||||||
sessionID={route.sessionID}
|
sessionID={route.sessionID}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
<Show when={!sidebarVisible()}>
|
||||||
|
<Footer />
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
<Toast />
|
<Toast />
|
||||||
</box>
|
</box>
|
||||||
<Show when={sidebarVisible()}>
|
<Show when={sidebarVisible()}>
|
||||||
<Switch>
|
<Sidebar sessionID={route.sessionID} />
|
||||||
<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>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</context.Provider>
|
</context.Provider>
|
||||||
@@ -1082,7 +1090,6 @@ function UserMessage(props: {
|
|||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
const queued = createMemo(() => props.pending && props.message.id > props.pending)
|
const queued = createMemo(() => props.pending && props.message.id > props.pending)
|
||||||
const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
|
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"))
|
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
|
||||||
|
|
||||||
@@ -1112,7 +1119,7 @@ function UserMessage(props: {
|
|||||||
>
|
>
|
||||||
<text fg={theme.text}>{text()?.text}</text>
|
<text fg={theme.text}>{text()?.text}</text>
|
||||||
<Show when={files().length}>
|
<Show when={files().length}>
|
||||||
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
{(file) => {
|
{(file) => {
|
||||||
const bg = createMemo(() => {
|
const bg = createMemo(() => {
|
||||||
@@ -1130,22 +1137,23 @@ function UserMessage(props: {
|
|||||||
</For>
|
</For>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<text fg={theme.textMuted}>
|
||||||
when={queued()}
|
{ctx.usernameVisible() ? `${sync.data.config.username ?? "You "}` : "You "}
|
||||||
fallback={
|
<Show
|
||||||
<Show when={ctx.showTimestamps()}>
|
when={queued()}
|
||||||
<text fg={theme.textMuted}>
|
fallback={
|
||||||
|
<Show when={ctx.showTimestamps()}>
|
||||||
<span style={{ fg: theme.textMuted }}>
|
<span style={{ fg: theme.textMuted }}>
|
||||||
|
{ctx.usernameVisible() ? " · " : " "}
|
||||||
{Locale.todayTimeOrDateTime(props.message.time.created)}
|
{Locale.todayTimeOrDateTime(props.message.time.created)}
|
||||||
</span>
|
</span>
|
||||||
</text>
|
</Show>
|
||||||
</Show>
|
}
|
||||||
}
|
>
|
||||||
>
|
<span> </span>
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
|
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
|
||||||
</text>
|
</Show>
|
||||||
</Show>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1303,16 +1311,8 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
|||||||
// Pending messages moved to individual tool pending functions
|
// Pending messages moved to individual tool pending functions
|
||||||
|
|
||||||
function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
|
function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
|
||||||
const ctx = use()
|
|
||||||
const sync = useSync()
|
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 = {
|
const toolprops = {
|
||||||
get metadata() {
|
get metadata() {
|
||||||
return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
|
return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
|
||||||
@@ -1337,55 +1337,50 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={!shouldHide()}>
|
<Switch>
|
||||||
<Switch>
|
<Match when={props.part.tool === "bash"}>
|
||||||
<Match when={props.part.tool === "bash"}>
|
<Bash {...toolprops} />
|
||||||
<Bash {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "glob"}>
|
||||||
<Match when={props.part.tool === "glob"}>
|
<Glob {...toolprops} />
|
||||||
<Glob {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "read"}>
|
||||||
<Match when={props.part.tool === "read"}>
|
<Read {...toolprops} />
|
||||||
<Read {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "grep"}>
|
||||||
<Match when={props.part.tool === "grep"}>
|
<Grep {...toolprops} />
|
||||||
<Grep {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "list"}>
|
||||||
<Match when={props.part.tool === "list"}>
|
<List {...toolprops} />
|
||||||
<List {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "webfetch"}>
|
||||||
<Match when={props.part.tool === "webfetch"}>
|
<WebFetch {...toolprops} />
|
||||||
<WebFetch {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "codesearch"}>
|
||||||
<Match when={props.part.tool === "codesearch"}>
|
<CodeSearch {...toolprops} />
|
||||||
<CodeSearch {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "websearch"}>
|
||||||
<Match when={props.part.tool === "websearch"}>
|
<WebSearch {...toolprops} />
|
||||||
<WebSearch {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "write"}>
|
||||||
<Match when={props.part.tool === "write"}>
|
<Write {...toolprops} />
|
||||||
<Write {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "edit"}>
|
||||||
<Match when={props.part.tool === "edit"}>
|
<Edit {...toolprops} />
|
||||||
<Edit {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "task"}>
|
||||||
<Match when={props.part.tool === "task"}>
|
<Task {...toolprops} />
|
||||||
<Task {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "patch"}>
|
||||||
<Match when={props.part.tool === "patch"}>
|
<Patch {...toolprops} />
|
||||||
<Patch {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={props.part.tool === "todowrite"}>
|
||||||
<Match when={props.part.tool === "todowrite"}>
|
<TodoWrite {...toolprops} />
|
||||||
<TodoWrite {...toolprops} />
|
</Match>
|
||||||
</Match>
|
<Match when={true}>
|
||||||
<Match when={props.part.tool === "question"}>
|
<GenericTool {...toolprops} />
|
||||||
<Question {...toolprops} />
|
</Match>
|
||||||
</Match>
|
</Switch>
|
||||||
<Match when={true}>
|
|
||||||
<GenericTool {...toolprops} />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Show>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1443,12 +1438,7 @@ function InlineTool(props: {
|
|||||||
|
|
||||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
|
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
|
||||||
|
|
||||||
const denied = createMemo(
|
const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule"))
|
||||||
() =>
|
|
||||||
error()?.includes("rejected permission") ||
|
|
||||||
error()?.includes("specified a rule") ||
|
|
||||||
error()?.includes("user dismissed"),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
@@ -1524,30 +1514,15 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Bash(props: ToolProps<typeof BashTool>) {
|
function Bash(props: ToolProps<typeof BashTool>) {
|
||||||
const { theme } = useTheme()
|
|
||||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const { theme } = useTheme()
|
||||||
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 (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.metadata.output !== undefined}>
|
<Match when={props.metadata.output !== undefined}>
|
||||||
<BlockTool
|
<BlockTool title={"# " + (props.input.description ?? "Shell")} part={props.part}>
|
||||||
title={"# " + (props.input.description ?? "Shell")}
|
|
||||||
part={props.part}
|
|
||||||
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
|
|
||||||
>
|
|
||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<text fg={theme.text}>$ {props.input.command}</text>
|
<text fg={theme.text}>$ {props.input.command}</text>
|
||||||
<text fg={theme.text}>{limited()}</text>
|
<text fg={theme.text}>{output()}</text>
|
||||||
<Show when={overflow()}>
|
|
||||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
</box>
|
||||||
</BlockTool>
|
</BlockTool>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -1837,40 +1812,6 @@ 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}>
|
|
||||||
<BlockTool title="# Questions" part={props.part}>
|
|
||||||
<box>
|
|
||||||
<For each={props.input.questions ?? []}>
|
|
||||||
{(q, i) => (
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={theme.textMuted}>{q.question}</text>
|
|
||||||
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</BlockTool>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
|
|
||||||
Asked {count()} question{count() !== 1 ? "s" : ""}
|
|
||||||
</InlineTool>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePath(input?: string) {
|
function normalizePath(input?: string) {
|
||||||
if (!input) return ""
|
if (!input) return ""
|
||||||
if (path.isAbsolute(input)) {
|
if (path.isAbsolute(input)) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
|
|||||||
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
import type { TextareaRenderable } from "@opentui/core"
|
||||||
import { useKeybind } from "../../context/keybind"
|
import { useKeybind } from "../../context/keybind"
|
||||||
import { useTheme, selectedForeground } from "../../context/theme"
|
import { useTheme } from "../../context/theme"
|
||||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { useSDK } from "../../context/sdk"
|
import { useSDK } from "../../context/sdk"
|
||||||
import { SplitBorder } from "../../component/border"
|
import { SplitBorder } from "../../component/border"
|
||||||
@@ -395,7 +395,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
|
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
|
||||||
>
|
>
|
||||||
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
|
<text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
|
||||||
{props.options[option]}
|
{props.options[option]}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createMemo, For, Show } from "solid-js"
|
|
||||||
import { useKeyboard } from "@opentui/solid"
|
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
|
||||||
import { useKeybind } from "../../context/keybind"
|
|
||||||
import { useTheme } from "../../context/theme"
|
|
||||||
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"
|
|
||||||
import { useDialog } from "../../ui/dialog"
|
|
||||||
|
|
||||||
export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|
||||||
const sdk = useSDK()
|
|
||||||
const { theme } = useTheme()
|
|
||||||
const keybind = useKeybind()
|
|
||||||
const bindings = useTextareaKeybindings()
|
|
||||||
|
|
||||||
const questions = createMemo(() => props.request.questions)
|
|
||||||
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 QuestionAnswer[],
|
|
||||||
custom: [] as string[],
|
|
||||||
selected: 0,
|
|
||||||
editing: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
let textarea: TextareaRenderable | undefined
|
|
||||||
|
|
||||||
const question = createMemo(() => questions()[store.tab])
|
|
||||||
const confirm = createMemo(() => !single() && store.tab === questions().length)
|
|
||||||
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() {
|
|
||||||
const answers = questions().map((_, i) => store.answers[i] ?? [])
|
|
||||||
sdk.client.question.reply({
|
|
||||||
requestID: props.request.id,
|
|
||||||
answers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function reject() {
|
|
||||||
sdk.client.question.reject({
|
|
||||||
requestID: props.request.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(answer: string, custom: boolean = false) {
|
|
||||||
const answers = [...store.answers]
|
|
||||||
answers[store.tab] = [answer]
|
|
||||||
setStore("answers", answers)
|
|
||||||
if (custom) {
|
|
||||||
const inputs = [...store.custom]
|
|
||||||
inputs[store.tab] = answer
|
|
||||||
setStore("custom", inputs)
|
|
||||||
}
|
|
||||||
if (single()) {
|
|
||||||
sdk.client.question.reply({
|
|
||||||
requestID: props.request.id,
|
|
||||||
answers: [[answer]],
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("tab", store.tab + 1)
|
|
||||||
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) => {
|
|
||||||
// When editing "Other" textarea
|
|
||||||
if (store.editing && !confirm()) {
|
|
||||||
if (evt.name === "escape") {
|
|
||||||
evt.preventDefault()
|
|
||||||
setStore("editing", false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (evt.name === "return") {
|
|
||||||
evt.preventDefault()
|
|
||||||
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
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (evt.name === "left" || evt.name === "h") {
|
|
||||||
evt.preventDefault()
|
|
||||||
const next = (store.tab - 1 + tabs()) % tabs()
|
|
||||||
setStore("tab", next)
|
|
||||||
setStore("selected", 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (evt.name === "right" || evt.name === "l") {
|
|
||||||
evt.preventDefault()
|
|
||||||
const next = (store.tab + 1) % tabs()
|
|
||||||
setStore("tab", next)
|
|
||||||
setStore("selected", 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (confirm()) {
|
|
||||||
if (evt.name === "return") {
|
|
||||||
evt.preventDefault()
|
|
||||||
submit()
|
|
||||||
}
|
|
||||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
reject()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const opts = options()
|
|
||||||
const total = opts.length + 1 // options + "Other"
|
|
||||||
|
|
||||||
if (evt.name === "up" || evt.name === "k") {
|
|
||||||
evt.preventDefault()
|
|
||||||
setStore("selected", (store.selected - 1 + total) % total)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (evt.name === "down" || evt.name === "j") {
|
|
||||||
evt.preventDefault()
|
|
||||||
setStore("selected", (store.selected + 1) % total)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (evt.name === "return") {
|
|
||||||
evt.preventDefault()
|
|
||||||
if (other()) {
|
|
||||||
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)) {
|
|
||||||
evt.preventDefault()
|
|
||||||
reject()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
backgroundColor={theme.backgroundPanel}
|
|
||||||
border={["left"]}
|
|
||||||
borderColor={theme.accent}
|
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
|
||||||
>
|
|
||||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
|
||||||
<Show when={!single()}>
|
|
||||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
|
||||||
<For each={questions()}>
|
|
||||||
{(q, index) => {
|
|
||||||
const isActive = () => index() === store.tab
|
|
||||||
const isAnswered = () => {
|
|
||||||
return (store.answers[index()]?.length ?? 0) > 0
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
|
|
||||||
>
|
|
||||||
<text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
|
|
||||||
{q.header}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<box paddingLeft={1} paddingRight={1} backgroundColor={confirm() ? theme.accent : theme.backgroundElement}>
|
|
||||||
<text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!confirm()}>
|
|
||||||
<box paddingLeft={1} gap={1}>
|
|
||||||
<box>
|
|
||||||
<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]?.includes(opt.label) ?? false
|
|
||||||
return (
|
|
||||||
<box>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
|
|
||||||
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
|
|
||||||
{i() + 1}. {opt.label}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
|
|
||||||
</box>
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<text fg={theme.textMuted}>{opt.description}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<box>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
|
|
||||||
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
|
|
||||||
{options().length + 1}. Type your own answer
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
|
|
||||||
</box>
|
|
||||||
<Show when={store.editing}>
|
|
||||||
<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()}>
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<text fg={theme.textMuted}>{input()}</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={confirm() && !single()}>
|
|
||||||
<box paddingLeft={1}>
|
|
||||||
<text fg={theme.text}>Review</text>
|
|
||||||
</box>
|
|
||||||
<For each={questions()}>
|
|
||||||
{(q, 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={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
flexShrink={0}
|
|
||||||
gap={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
paddingRight={3}
|
|
||||||
paddingBottom={1}
|
|
||||||
justifyContent="space-between"
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<Show when={!single()}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={!confirm()}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
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>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import { useDirectory } from "../../context/directory"
|
|||||||
import { useKV } from "../../context/kv"
|
import { useKV } from "../../context/kv"
|
||||||
import { TodoItem } from "../../component/todo-item"
|
import { TodoItem } from "../../component/todo-item"
|
||||||
|
|
||||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
export function Sidebar(props: { sessionID: string }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const session = createMemo(() => sync.session.get(props.sessionID)!)
|
const session = createMemo(() => sync.session.get(props.sessionID)!)
|
||||||
@@ -77,7 +77,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
paddingRight={2}
|
paddingRight={2}
|
||||||
position={props.overlay ? "absolute" : "relative"}
|
|
||||||
>
|
>
|
||||||
<scrollbox flexGrow={1}>
|
<scrollbox flexGrow={1}>
|
||||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ function init() {
|
|||||||
current.onClose?.()
|
current.onClose?.()
|
||||||
setStore("stack", store.stack.slice(0, -1))
|
setStore("stack", store.stack.slice(0, -1))
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
evt.stopPropagation()
|
|
||||||
refocus()
|
refocus()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function getNetworkIPs() {
|
|||||||
export const WebCommand = cmd({
|
export const WebCommand = cmd({
|
||||||
command: "web",
|
command: "web",
|
||||||
builder: (yargs) => withNetworkOptions(yargs),
|
builder: (yargs) => withNetworkOptions(yargs),
|
||||||
describe: "start opencode server and open web interface",
|
describe: "starts a headless opencode server",
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = Server.listen(opts)
|
const server = Server.listen(opts)
|
||||||
|
|||||||
@@ -178,8 +178,6 @@ export namespace Config {
|
|||||||
result.compaction = { ...result.compaction, prune: false }
|
result.compaction = { ...result.compaction, prune: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
result.plugin = deduplicatePlugins(result.plugin ?? [])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: result,
|
config: result,
|
||||||
directories,
|
directories,
|
||||||
@@ -334,58 +332,6 @@ export namespace Config {
|
|||||||
return plugins
|
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
|
export const McpLocal = z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("local").describe("Type of MCP server connection"),
|
type: z.literal("local").describe("Type of MCP server connection"),
|
||||||
@@ -504,7 +450,6 @@ export namespace Config {
|
|||||||
external_directory: PermissionRule.optional(),
|
external_directory: PermissionRule.optional(),
|
||||||
todowrite: PermissionAction.optional(),
|
todowrite: PermissionAction.optional(),
|
||||||
todoread: PermissionAction.optional(),
|
todoread: PermissionAction.optional(),
|
||||||
question: PermissionAction.optional(),
|
|
||||||
webfetch: PermissionAction.optional(),
|
webfetch: PermissionAction.optional(),
|
||||||
websearch: PermissionAction.optional(),
|
websearch: PermissionAction.optional(),
|
||||||
codesearch: PermissionAction.optional(),
|
codesearch: PermissionAction.optional(),
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ export namespace Flag {
|
|||||||
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
|
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
|
||||||
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
|
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
|
||||||
export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH")
|
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_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
|
||||||
export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
|
export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
|
||||||
|
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ export const rustfmt: Info = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const cargofmt: Info = {
|
export const cargofmt: Info = {
|
||||||
name: "cargofmt",
|
name: "cargo fmt",
|
||||||
command: ["cargo", "fmt", "--", "$FILE"],
|
command: ["cargo", "fmt", "--", "$FILE"],
|
||||||
extensions: [".rs"],
|
extensions: [".rs"],
|
||||||
async enabled() {
|
async enabled() {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ await Promise.all([
|
|||||||
fs.mkdir(Global.Path.bin, { recursive: true }),
|
fs.mkdir(Global.Path.bin, { recursive: true }),
|
||||||
])
|
])
|
||||||
|
|
||||||
const CACHE_VERSION = "16"
|
const CACHE_VERSION = "14"
|
||||||
|
|
||||||
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
||||||
.text()
|
.text()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user