Compare commits

...
110 Commits
Author SHA1 Message Date
Aiden Cline ffa2a61003 fix: model dialog issue when searching for models in fav/recents list, also ensure that deprecated models dont appear in list 2026-01-08 23:38:16 -06:00
Aleksandr BagatkaandAiden Cline f6fc693c1f fix(ui): use full file path for fuzzy matching in autocomplete (#6705)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-08 16:43:05 -06:00
GitHub Action 50d8396c9a chore: generate 2026-01-08 22:33:10 +00:00
Dax 22dd70b75b feat(question): support multi-select questions (#7386) 2026-01-08 17:32:21 -05:00
GitHub Action b4f8de0c0a chore: generate 2026-01-08 22:03:23 +00:00
Marc Espin 768e0553bd fix(docs): Document cargofmt (#7383) 2026-01-08 16:00:03 -06:00
opencode f3e79235fb release: v1.1.7
Update Nix Hashes / update (push) Has been cancelled
2026-01-08 20:37:24 +00:00
ryanwyler eacf3ad361 fix(tui): restore showDetails check removed in Permission rework (#7285) 2026-01-08 14:30:41 -06:00
Mani Sundararajan 72062d22a0 fix: cleanly restore terminal state on fatal error exit (#7359) 2026-01-08 14:22:24 -06:00
Aiden Cline 9f90f0e8ed ci: update fork_repo var 2026-01-08 14:19:44 -06:00
Aiden Cline c74bc323b6 ci: tweak enforcement of titles 2026-01-08 14:12:38 -06:00
zerone0x c7b825a42a fix(app): show custom models without valid release_date in web UI mod… (#7349) 2026-01-08 14:06:18 -06:00
Dax Raad b1a613b3b9 ci: add retry logic to desktop build to handle intermittent failures 2026-01-08 14:57:55 -05:00
Noam Bresslerandnoam-v 958f1edfef fix: Add kind, title, rawInput to ACP tool_call_update events (#7368)
Co-authored-by: noam-v <noam@bespo.ai>
2026-01-08 13:56:16 -06:00
Dax 2bb299d741 tui: improve responsive layout by hiding header/footer when sidebar is visible (#7365) 2026-01-08 13:57:23 -05:00
Aiden Cline 9930ac6929 ci: adjust workflow 2026-01-08 12:50:17 -06:00
Aiden Cline 1906a347f3 docs: update pr workflows 2026-01-08 12:44:04 -06:00
Dax Raad e5d0c63b29 docs: simplify tools configuration by consolidating under permission field 2026-01-08 12:39:40 -05:00
Aiden Cline 970796b832 docs: add PR title guidelines and workflow to enforce conventional commits 2026-01-08 11:23:52 -06:00
Aiden Cline 3c5043497c ignore: ensure new file truncation stuff still works even if external_directoy is set to deny 2026-01-08 11:07:33 -06:00
Max Stevens 4d09c5618e refactor: session settings simplification (#7342) 2026-01-08 11:06:06 -06:00
Sercan Sagman adae0d1853 fix(cli): add help text to debug command and subcommands (#7328)
Signed-off-by: assagman <ahmetsercansagman@gmail.com>
2026-01-08 09:42:12 -06:00
Aaron Iker 61aeb2a2a7 feat: Polish dialog, popover shadows & borders, add missing provider logos (#7326) 2026-01-08 09:02:04 -06:00
GitHub Action 4b0f7b82ba ignore: update download stats 2026-01-08 2026-01-08 12:05:02 +00:00
GitHub Action 9fb24074c8 chore: generate 2026-01-08 06:11:00 +00:00
freespace8 542c9d5346 feat(flags): allow disabling .claude prompt and skills loading (#7205) 2026-01-08 00:10:25 -06:00
Aiden Cline d5f0e3fccc test: add larger image test for read tool 2026-01-07 23:01:02 -06:00
Dax Raad 7d2bb5cb2b tui: remove unused imports and variables from header component to clean up code 2026-01-07 23:54:54 -05:00
Dax ca7a70b628 tui: add overlay sidebar for narrow screens to improve mobile experience (#7288) 2026-01-07 23:51:49 -05:00
Dax Raad b3a2f9fb4e tui: add expandable bash output for long commands to improve readability 2026-01-07 23:11:46 -05:00
Dax Raad 8be5a29870 tui: remove username visibility toggle and simplify user message metadata display 2026-01-07 23:02:19 -05:00
Dax Raad 68092f22e1 tui: improve other answer option layout with better indentation and clearer label 2026-01-07 22:34:31 -05:00
GitHub Action 83f3c729e9 chore: generate 2026-01-08 03:30:18 +00:00
Dax e37fd9c105 core: add interactive question tool for gathering user preferences and clarifying instructions (#7268) 2026-01-07 22:29:42 -05:00
Aiden Cline 2e4fe973c9 fix: issue w/ normal transform options conflicting w/ small model options when gen-ing title 2026-01-07 17:32:38 -06:00
Aiden Cline 1b82511fbd feat: write truncated tool outputs to files (#7239) 2026-01-07 17:25:00 -06:00
Ariane Emory f24314438b fix(tui): ensure forked message text is inserted in prompt (resolves #7257) (#7259) 2026-01-07 16:12:03 -06:00
Andrew Thal 361a962673 fix(desktop): open external links in default browser (#7221) 2026-01-07 16:04:27 -06:00
Adam fa9c283fcf fix(app): user message text wrap 2026-01-07 15:36:08 -06:00
Frank 947b864d96 wip: zen 2026-01-07 15:49:52 -05:00
Ariane Emory 03eabb10e4 fix: use selectedForeground's computer colour (or theme's selectedForeground value) for the colour of text in permission selection (resolves #7246) (#7251) 2026-01-07 14:45:30 -06:00
M. Adel AlhashemiandAiden Cline 34c9d106ee refactor: simplify task tool subagent filtering (#7165)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-01-07 13:28:13 -06:00
Aiden Cline fe57d7bb38 tweak: title gen prompt & temp to avoid repetative 'Analyzing ...' titles 2026-01-07 12:56:41 -06:00
Ravi Kumar 68cf6b04a0 fix(tui): constrain autocomplete height to available screen space (#7181) 2026-01-07 19:24:44 +01:00
Github Action 9ffaf81fb3 Update Nix flake.lock and hashes 2026-01-07 18:07:10 +00:00
GitHub Action 50530b1ea7 chore: generate 2026-01-07 18:06:30 +00:00
Rodolfo Carvalho a160eee499 fix(mcp): use correct authorization server URL for OAuth discovery (#7234) 2026-01-07 12:05:54 -06:00
Thanh Nguyen d9aef1d73d fix(theme): add selectedListItemText to orng themes for button text visibility (#7169) 2026-01-07 12:05:46 -06:00
Matt Silverlock 4ba0b22b04 fix: config precedence now correctly allows local config to override remote (#7141) 2026-01-07 12:07:21 -05:00
Aiden Cline 662d2b205a docs: update brew formula stuff to recommend our tap 2026-01-07 11:06:54 -06:00
GitHub Action 75960ae00c chore: generate 2026-01-07 17:03:43 +00:00
Aiden Cline 528f198c39 tweak: update formula for anomalyco tap 2026-01-07 11:02:37 -06:00
opencode 184834da98 release: v1.1.6
Update Nix Hashes / update (push) Has been cancelled
2026-01-07 16:38:32 +00:00
Dax Raad 008a5c10cc oops 2026-01-07 11:35:11 -05:00
Daniel Polito 2d5b9a5cc6 Desktop: Fix Paste image on empty input (#7130) 2026-01-07 09:40:21 -06:00
Andrew Thal fb3ca895d6 fix(ui): prevent iOS Safari auto-zoom on input focus (#7214) 2026-01-07 09:22:48 -06:00
Brendan Allan d3d379fe2e desktop: bundleMediaFramework 2026-01-07 22:55:29 +08:00
GitHub Action b41626049c chore: generate 2026-01-07 14:50:57 +00:00
Dax Raad e59be27810 theme 2026-01-07 09:50:04 -05:00
opencode 1e2992244f release: v1.1.5
Update Nix Hashes / update (push) Has been cancelled
2026-01-07 14:37:04 +00:00
Dax Raad fd22b26478 theme reload 2026-01-07 09:34:11 -05:00
Dax Raad ea2ee46f45 clear palette cache 2026-01-07 08:49:35 -05:00
Adam 4e1b6b3417 fix(app): select first item when filtering 2026-01-07 07:15:06 -06:00
jwaterwaterandClaude 2d52a461a0 fix: encode non-ASCII directory paths in HTTP headers (#7145)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-07 06:46:17 -06:00
shuvandshuv 9cce0cf4f4 feat: add Night Owl theme (desktop) (#7151)
Co-authored-by: shuv <shuv@shuv.dev>
2026-01-07 06:44:27 -06:00
Brendan Allan a41c8508da desktop: go back to regular tauri cli 2026-01-07 20:42:10 +08:00
GitHub Action 4f7458b47d ignore: update download stats 2026-01-07 2026-01-07 12:04:44 +00:00
Brendan Allan 270cd05195 fix entitlements 2026-01-07 20:03:38 +08:00
Saeed VaziryandBrendan Allan 24c933ae60 Add audio input and camera permissions to entitlements (#7117)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-01-07 17:56:46 +08:00
Brendan Allan 2b7a021ba3 desktop: appimage bundleMediaFramework 2026-01-07 17:39:35 +08:00
GitHub Action cbf87c50b9 chore: generate 2026-01-07 08:48:34 +00:00
Brendan Allan 3c375b971e desktop: use Show instead of Suspense 2026-01-07 16:47:45 +08:00
Aiden Cline 6590c1641f add truncation for all tools 2026-01-07 02:01:32 -06:00
GitHub Action 0ffe496869 chore: generate 2026-01-07 07:46:40 +00:00
Frank ce4e595881 wip: black 2026-01-07 02:45:57 -05:00
Frank e91cc7e514 wip: black 2026-01-07 02:45:57 -05:00
GitHub Action c961072d20 chore: generate 2026-01-07 07:43:43 +00:00
Aiden Cline 429240f439 ignore: add truncation funcs (#7178) 2026-01-07 01:43:06 -06:00
Github Action a0dc90bfcc Update Nix flake.lock and hashes 2026-01-07 07:36:49 +00:00
Brendan Allan 6bac501be5 add ui package to desktop 2026-01-07 15:35:32 +08:00
Daniel Polito b5be883758 Make General SubAgent not hidden (#6752) 2026-01-07 01:31:47 -06:00
Brendan Allan 0021a09ba8 try using forked tauri-cli again 2026-01-07 15:29:14 +08:00
usvimalandBrendan Allan a8c2928a87 perf: show window immediately during desktop startup (#6734)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-01-07 15:19:01 +08:00
Brendan Allan 79f6910697 desktop: use correct tauri-cli branch 2026-01-07 14:36:43 +08:00
Aiden Cline 04cea9cf11 tweak: unserializable error handling 2026-01-07 00:29:41 -06:00
Brendan Allan 61c334f1fb desktop: use regular tauri cli again 2026-01-07 14:25:25 +08:00
Spoon 85ed329318 add agent color to inline task tool ascii char and highlight agent name (#7142) 2026-01-07 00:10:00 -06:00
Brendan Allan 37decee795 use custom tauri-cli on linux 2026-01-07 13:34:18 +08:00
Frank 5d0007ade4 wip: black 2026-01-07 00:08:49 -05:00
GitHub Action 31dd9fd13a chore: generate 2026-01-07 04:35:15 +00:00
Frank 23fc675ad5 wip: black 2026-01-06 23:34:12 -05:00
Frank 22b058a33d wip: black 2026-01-06 23:34:12 -05:00
GitHub Action 939c0940aa chore: generate 2026-01-07 04:29:50 +00:00
fd7b7eacd3 Added: Ability to hide subagents from primary agents system prompt. (#4773)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-06 22:29:17 -06:00
GitHub Action eaa0826e7f chore: generate 2026-01-07 03:42:44 +00:00
Adam f6055ad3d2 chore: fix types 2026-01-06 21:42:04 -06:00
Adam 761863ae35 chore(app): rework storage approach 2026-01-06 21:42:03 -06:00
NSPC911 dadc08ddc7 fix: escape backticks when passing to powershell (#7157) 2026-01-06 21:41:33 -06:00
Github Action d7afb01d13 Update Nix flake.lock and hashes 2026-01-07 03:20:32 +00:00
GitHub Action 5168988156 chore: generate 2026-01-07 03:19:47 +00:00
Adam 9a3bd0ade1 fix(app): hide line numbers in diffs on mobile 2026-01-06 21:18:50 -06:00
Adam 4e5b0b00b0 fix(app): session navigation with shortcuts 2026-01-06 21:18:41 -06:00
Adam 7672b722ca fix(app): permission auto-accept should hide when not needed 2026-01-06 21:18:29 -06:00
Adam 488c3502a7 fix(app): terminal selection color contrast 2026-01-06 21:18:17 -06:00
Aiden Cline a2f80f7c0d fix: add --use-system-ca flag in execArgv 2026-01-06 21:01:03 -06:00
Rafi Khardalian dee0226741 fix(tui): restore attach session lookup behavior (#7150) 2026-01-06 20:59:12 -06:00
kamkm 58aba1c797 chore: Refactor style guide to use consistent language (#7139) 2026-01-06 17:54:16 -06:00
Sebastian Herrlinger 9906d42e1c remove hallucinated keybinding 2026-01-07 00:19:56 +01:00
GitHub Action 43eefbc349 chore: generate 2026-01-06 23:06:08 +00:00
Aiden Cline 625c9dae5c fix: ensure commands listing subagent work 2026-01-06 17:05:20 -06:00
176 changed files with 46499 additions and 3504 deletions
+139
View File
@@ -0,0 +1,139 @@
name: PR Standards
on:
pull_request_target:
types: [opened, edited, synchronize]
jobs:
check-standards:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Check PR standards
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const title = pr.title;
async function addLabel(label) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label]
});
}
async function removeLabel(label) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label
});
} catch (e) {
// Label wasn't present, ignore
}
}
async function comment(marker, body) {
const markerText = `<!-- pr-standards:${marker} -->`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const existing = comments.find(c => c.body.includes(markerText));
if (existing) return;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: markerText + '\n' + body
});
}
// Step 1: Check title format
// Matches: feat:, feat(scope):, feat (scope):, etc.
const titlePattern = /^(feat|fix|docs|chore|refactor|test)\s*(\([a-zA-Z0-9-]+\))?\s*:/;
const hasValidTitle = titlePattern.test(title);
if (!hasValidTitle) {
await addLabel('needs:title');
await comment('title', `Hey! Your PR title \`${title}\` doesn't follow conventional commit format.
Please update it to start with one of:
- \`feat:\` or \`feat(scope):\` new feature
- \`fix:\` or \`fix(scope):\` bug fix
- \`docs:\` or \`docs(scope):\` documentation changes
- \`chore:\` or \`chore(scope):\` maintenance tasks
- \`refactor:\` or \`refactor(scope):\` code refactoring
- \`test:\` or \`test(scope):\` adding or updating tests
Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`).
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`);
return;
}
await removeLabel('needs:title');
// Step 2: Check for linked issue (skip for docs/refactor PRs)
const skipIssueCheck = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
if (skipIssueCheck) {
await removeLabel('needs:issue');
console.log('Skipping issue check for docs/refactor PR');
return;
}
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 1) {
totalCount
}
}
}
}
`;
const result = await github.graphql(query, {
owner: context.repo.owner,
repo: context.repo.repo,
number: pr.number
});
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
if (linkedIssues === 0) {
await addLabel('needs:issue');
await comment('issue', `Thanks for your contribution!
This PR doesn't have a linked issue. All PRs must reference an existing issue.
Please:
1. Open an issue describing the bug/feature (if one doesn't exist)
2. Add \`Fixes #<number>\` or \`Closes #<number>\` to this PR description
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`);
return;
}
await removeLabel('needs:issue');
console.log('PR meets all standards');
+16 -12
View File
@@ -177,8 +177,22 @@ jobs:
cargo tauri --version cargo tauri --version
- name: Build and upload artifacts - name: Build and upload artifacts
timeout-minutes: 20 uses: Wandalen/wretry.action@v3
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a timeout-minutes: 60
with:
attempt_limit: 3
attempt_delay: 10000
action: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
with: |
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.publish.outputs.release }}
tagName: ${{ needs.publish.outputs.tag }}
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
releaseDraft: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
@@ -190,16 +204,6 @@ jobs:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
with:
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.publish.outputs.release }}
tagName: ${{ needs.publish.outputs.tag }}
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
releaseDraft: true
publish-release: publish-release:
needs: needs:
+56 -4
View File
@@ -149,11 +149,63 @@ With that said, you may want to try these methods, as they might work for you.
## Pull Request Expectations ## Pull Request Expectations
- Try to keep pull requests small and focused. ### Issue First Policy
- Link relevant issue(s) in the description
**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review.
- Use `Fixes #123` or `Closes #123` in your PR description to link the issue
- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem
### General Requirements
- Keep pull requests small and focused
- Explain the issue and why your change fixes it - Explain the issue and why your change fixes it
- Avoid having verbose LLM generated PR descriptions - Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase
- Before adding new functions or functionality, ensure that such behavior doesn't already exist elsewhere in the codebase.
### UI Changes
If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback.
### Logic Changes
For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**:
- What did you test?
- How can a reviewer reproduce/confirm the fix?
### No AI-Generated Walls of Text
Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time:
- Write short, focused descriptions
- Explain what changed and why in your own words
- If you can't explain it briefly, your PR might be too large
### PR Titles
PR titles should follow conventional commit standards:
- `feat:` new feature or functionality
- `fix:` bug fix
- `docs:` documentation or README changes
- `chore:` maintenance tasks, dependency updates, etc.
- `refactor:` code refactoring without changing behavior
- `test:` adding or updating tests
You can optionally include a scope to indicate which package is affected:
- `feat(app):` feature in the app package
- `fix(desktop):` bug fix in the desktop package
- `chore(opencode):` maintenance in the opencode package
Examples:
- `docs: update contributing guidelines`
- `fix: resolve crash on startup`
- `feat: add dark mode support`
- `feat(app): add dark mode support`
- `fix(desktop): resolve crash on startup`
- `chore: bump dependency versions`
### Style Preferences ### Style Preferences
+2 -1
View File
@@ -28,7 +28,8 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # or bun/pnpm/yarn npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install opencode # macOS and Linux brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less frequently)
paru -S opencode-bin # Arch Linux paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
+2 -1
View File
@@ -28,7 +28,8 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install opencode # macOS 和 Linux brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低)
paru -S opencode-bin # Arch Linux paru -S opencode-bin # Arch Linux
mise use -g opencode # 任意系统 mise use -g opencode # 任意系统
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支 nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
+2 -1
View File
@@ -28,7 +28,8 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows choco install opencode # Windows
brew install opencode # macOS 與 Linux brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
paru -S opencode-bin # Arch Linux paru -S opencode-bin # Arch Linux
mise use -g github:anomalyco/opencode # 任何作業系統 mise use -g github:anomalyco/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支 nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
+2
View File
@@ -193,3 +193,5 @@
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) | | 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) | | 2026-01-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-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
+2 -3
View File
@@ -1,9 +1,8 @@
## Style Guide ## Style Guide
- Try to keep things in one function unless composable or reusable - Try to keep things in one function unless composable or reusable
- DO NOT do unnecessary destructuring of variables - AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
- DO NOT use `else` statements unless necessary = obj` just reference it as obj.a and obj.b. this preserves context
- DO NOT use `try`/`catch` if it can be avoided
- AVOID `try`/`catch` where possible - AVOID `try`/`catch` where possible
- AVOID `else` statements - AVOID `else` statements
- AVOID using `any` type - AVOID using `any` type
+33 -25
View File
@@ -22,7 +22,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.4", "version": "1.1.7",
"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.4", "version": "1.1.7",
"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.4", "version": "1.1.7",
"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.4", "version": "1.1.7",
"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.4", "version": "1.1.7",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -173,9 +173,10 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.1.4", "version": "1.1.7",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
@@ -201,7 +202,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.4", "version": "1.1.7",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -230,7 +231,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.4", "version": "1.1.7",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -246,7 +247,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.1.4", "version": "1.1.7",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -276,7 +277,7 @@
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.15.1", "@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
@@ -349,7 +350,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.4", "version": "1.1.7",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -369,7 +370,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.4", "version": "1.1.7",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.88.1", "@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -380,7 +381,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.4", "version": "1.1.7",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -393,7 +394,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.4", "version": "1.1.7",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -401,6 +402,7 @@
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3", "@solid-primitives/bounds": "0.1.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@@ -431,7 +433,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.1.4", "version": "1.1.7",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -442,7 +444,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.1.4", "version": "1.1.7",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -909,6 +911,8 @@
"@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.88.1", "", { "dependencies": { "@hey-api/codegen-core": "^0.3.3", "@hey-api/json-schema-ref-parser": "1.2.2", "ansi-colors": "4.1.3", "c12": "3.3.2", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-x/nDTupOnV9VuSeNIiJpgIpc915GHduhyseJeMTnI0JMsXaObmpa0rgPr3ASVEYMLgpvqozIEG1RTOOnal6zLQ=="], "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.88.1", "", { "dependencies": { "@hey-api/codegen-core": "^0.3.3", "@hey-api/json-schema-ref-parser": "1.2.2", "ansi-colors": "4.1.3", "c12": "3.3.2", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-x/nDTupOnV9VuSeNIiJpgIpc915GHduhyseJeMTnI0JMsXaObmpa0rgPr3ASVEYMLgpvqozIEG1RTOOnal6zLQ=="],
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="], "@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
@@ -1093,7 +1097,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.15.1", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="], "@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -1901,7 +1905,9 @@
"ai": ["ai@5.0.97", "", { "dependencies": { "@ai-sdk/gateway": "2.0.12", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8zBx0b/owis4eJI2tAlV8a1Rv0BANmLxontcAelkLNwEHhgfgXeKpDkhNB6OgV+BJSwboIUDkgd9312DdJnCOQ=="], "ai": ["ai@5.0.97", "", { "dependencies": { "@ai-sdk/gateway": "2.0.12", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8zBx0b/owis4eJI2tAlV8a1Rv0BANmLxontcAelkLNwEHhgfgXeKpDkhNB6OgV+BJSwboIUDkgd9312DdJnCOQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -2405,7 +2411,7 @@
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="], "fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="],
@@ -2781,7 +2787,9 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
@@ -3383,6 +3391,8 @@
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="], "remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
@@ -3757,8 +3767,6 @@
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="], "update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="], "url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
@@ -4057,9 +4065,11 @@
"@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], "@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="], "@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="],
@@ -4403,8 +4413,6 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
+3
View File
@@ -76,6 +76,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
"checkout.session.completed", "checkout.session.completed",
"checkout.session.expired", "checkout.session.expired",
"charge.refunded", "charge.refunded",
"invoice.payment_succeeded",
"customer.created", "customer.created",
"customer.deleted", "customer.deleted",
"customer.updated", "customer.updated",
@@ -119,6 +120,7 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS6"), new sst.Secret("ZEN_MODELS6"),
new sst.Secret("ZEN_MODELS7"), new sst.Secret("ZEN_MODELS7"),
] ]
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) }, properties: { value: auth.url.apply((url) => url!) },
@@ -160,6 +162,7 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY, EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID, AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK,
...ZEN_MODELS, ...ZEN_MODELS,
...($dev ...($dev
? [ ? [
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"nodeModules": "sha256-mZGKIkOLmesEhCpEZTLiPbBisZOxdZ1NgqnRnVHJlLU=" "nodeModules": "sha256-rNGq0yjL5ZHYVg+zyV4nFPug4gqhKhyOnfebaufyd34="
} }
+6 -1
View File
@@ -60,7 +60,12 @@ const result = await Bun.build({
compile: { compile: {
target, target,
outfile: "opencode", outfile: "opencode",
execArgv: ["--user-agent=opencode/" + version, '--env-file=""', "--"], autoloadBunfig: false,
autoloadDotenv: false,
//@ts-ignore (bun types aren't up to date)
autoloadTsconfig: true,
autoloadPackageJson: true,
execArgv: ["--user-agent=opencode/" + version, "--use-system-ca", "--"],
windows: {}, windows: {},
}, },
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.4", "version": "1.1.7",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+73 -67
View File
@@ -1,5 +1,5 @@
import "@/index.css" import "@/index.css"
import { ErrorBoundary, Show, Suspense, lazy, type ParentProps } from "solid-js" import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router" import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
@@ -20,10 +20,12 @@ import { FileProvider } from "@/context/file"
import { NotificationProvider } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { Logo } from "@opencode-ai/ui/logo"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { iife } from "@opencode-ai/util/iife" import { iife } from "@opencode-ai/util/iife"
import { Suspense } from "solid-js"
const Home = lazy(() => import("@/pages/home")) const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
@@ -31,7 +33,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; port?: number } __OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
} }
} }
@@ -47,6 +49,25 @@ const defaultServerUrl = iife(() => {
return window.location.origin return window.location.origin
}) })
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
)
}
function ServerKey(props: ParentProps) { function ServerKey(props: ParentProps) {
const server = useServer() const server = useServer()
return ( return (
@@ -56,71 +77,56 @@ function ServerKey(props: ParentProps) {
) )
} }
export function App() { export function AppInterface() {
return ( return (
<MetaProvider> <ServerProvider defaultUrl={defaultServerUrl}>
<Font /> <ServerKey>
<ThemeProvider> <GlobalSDKProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <GlobalSyncProvider>
<DialogProvider> <Router
<MarkedProvider> root={(props) => (
<DiffComponentProvider component={Diff}> <PermissionProvider>
<CodeComponentProvider component={Code}> <LayoutProvider>
<ServerProvider defaultUrl={defaultServerUrl}> <NotificationProvider>
<ServerKey> <CommandProvider>
<GlobalSDKProvider> <Layout>{props.children}</Layout>
<GlobalSyncProvider> </CommandProvider>
<Router </NotificationProvider>
root={(props) => ( </LayoutProvider>
<PermissionProvider> </PermissionProvider>
<LayoutProvider> )}
<NotificationProvider> >
<CommandProvider> <Route
<Layout>{props.children}</Layout> path="/"
</CommandProvider> component={() => (
</NotificationProvider> <Suspense fallback={<Loading />}>
</LayoutProvider> <Home />
</PermissionProvider> </Suspense>
)} )}
> />
<Route <Route path="/:dir" component={DirectoryLayout}>
path="/" <Route path="/" component={() => <Navigate href="session" />} />
component={() => ( <Route
<Suspense fallback={<Loading />}> path="/session/:id?"
<Home /> component={(p) => (
</Suspense> <Show when={p.params.id ?? "new"} keyed>
)} <TerminalProvider>
/> <FileProvider>
<Route path="/:dir" component={DirectoryLayout}> <PromptProvider>
<Route path="/" component={() => <Navigate href="session" />} /> <Suspense fallback={<Loading />}>
<Route <Session />
path="/session/:id?" </Suspense>
component={(p) => ( </PromptProvider>
<Show when={p.params.id ?? "new"} keyed> </FileProvider>
<TerminalProvider> </TerminalProvider>
<FileProvider> </Show>
<PromptProvider> )}
<Suspense fallback={<Loading />}> />
<Session /> </Route>
</Suspense> </Router>
</PromptProvider> </GlobalSyncProvider>
</FileProvider> </GlobalSDKProvider>
</TerminalProvider> </ServerKey>
</Show> </ServerProvider>
)}
/>
</Route>
</Router>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ServerProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
) )
} }
+12 -13
View File
@@ -42,7 +42,7 @@ import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
@@ -189,7 +189,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const MAX_HISTORY = 100 const MAX_HISTORY = 100
const [history, setHistory] = persisted( const [history, setHistory] = persisted(
"prompt-history.v1", Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{ createStore<{
entries: Prompt[] entries: Prompt[]
}>({ }>({
@@ -197,7 +197,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}), }),
) )
const [shellHistory, setShellHistory] = persisted( const [shellHistory, setShellHistory] = persisted(
"prompt-history-shell.v1", Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<{ createStore<{
entries: Prompt[] entries: Prompt[]
}>({ }>({
@@ -248,6 +248,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const isFocused = createFocusSignal(() => editorRef)
createEffect(() => { createEffect(() => {
params.id params.id
editorRef.focus() editorRef.focus()
@@ -258,7 +260,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onCleanup(() => clearInterval(interval)) onCleanup(() => clearInterval(interval))
}) })
const isFocused = createFocusSignal(() => editorRef)
const [composing, setComposing] = createSignal(false) const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
@@ -292,12 +293,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const clipboardData = event.clipboardData const clipboardData = event.clipboardData
if (!clipboardData) return if (!clipboardData) return
event.preventDefault()
event.stopPropagation()
const items = Array.from(clipboardData.items) const items = Array.from(clipboardData.items)
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) { if (imageItems.length > 0) {
event.preventDefault()
event.stopPropagation()
for (const item of imageItems) { for (const item of imageItems) {
const file = item.getAsFile() const file = item.getAsFile()
if (file) await addImageAttachment(file) if (file) await addImageAttachment(file)
@@ -305,8 +307,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return return
} }
event.preventDefault()
event.stopPropagation()
const plainText = clipboardData.getData("text/plain") ?? "" const plainText = clipboardData.getData("text/plain") ?? ""
addPart({ type: "text", content: plainText, start: 0, end: 0 }) addPart({ type: "text", content: plainText, start: 0, end: 0 })
} }
@@ -347,13 +347,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
onMount(() => { onMount(() => {
editorRef.addEventListener("paste", handlePaste)
document.addEventListener("dragover", handleGlobalDragOver) document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave) document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop) document.addEventListener("drop", handleGlobalDrop)
}) })
onCleanup(() => { onCleanup(() => {
editorRef.removeEventListener("paste", handlePaste)
document.removeEventListener("dragover", handleGlobalDragOver) document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave) document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop) document.removeEventListener("drop", handleGlobalDrop)
@@ -1508,6 +1506,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
contenteditable="true" contenteditable="true"
onInput={handleInput} onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)} onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)} onCompositionEnd={() => setComposing(false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -1593,14 +1592,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{ classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!), "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!), "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}} }}
> >
<Icon <Icon
name="chevron-double-right" name="chevron-double-right"
size="small" size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }} classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/> />
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
+8 -1
View File
@@ -3,7 +3,7 @@ import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitPr
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize" import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal" import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme" import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
export interface TerminalProps extends ComponentProps<"div"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
@@ -16,6 +16,7 @@ type TerminalColors = {
background: string background: string
foreground: string foreground: string
cursor: string cursor: string
selectionBackground: string
} }
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
@@ -23,11 +24,13 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
background: "#fcfcfc", background: "#fcfcfc",
foreground: "#211e1e", foreground: "#211e1e",
cursor: "#211e1e", cursor: "#211e1e",
selectionBackground: withAlpha("#211e1e", 0.2),
}, },
dark: { dark: {
background: "#191515", background: "#191515",
foreground: "#d4d4d4", foreground: "#d4d4d4",
cursor: "#d4d4d4", cursor: "#d4d4d4",
selectionBackground: withAlpha("#d4d4d4", 0.25),
}, },
} }
@@ -55,10 +58,14 @@ export const Terminal = (props: TerminalProps) => {
const resolved = resolveThemeVariant(variant, mode === "dark") const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background const background = resolved["background-stronger"] ?? fallback.background
const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
const selectionBackground = withAlpha(base, alpha)
return { return {
background, background,
foreground: text, foreground: text,
cursor: text, cursor: text,
selectionBackground,
} }
} }
+33 -4
View File
@@ -1,4 +1,4 @@
import { createMemo, 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"
@@ -7,7 +7,7 @@ import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
export type FileSelection = { export type FileSelection = {
startLine: number startLine: number
@@ -134,10 +134,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {}, file: {},
}) })
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`) const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const [view, setView, _, ready] = persisted( const [view, setView, _, ready] = persisted(
viewKey(), Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]),
createStore<{ createStore<{
file: Record<string, FileViewState> file: Record<string, FileViewState>
}>({ }>({
@@ -145,6 +145,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}), }),
) )
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]
}
}),
)
}
createEffect(() => {
if (!ready()) return
if (viewMeta.pruned) return
viewMeta.pruned = true
pruneView()
})
function ensure(path: string) { function ensure(path: string) {
if (!path) return if (!path) return
if (store.file[path]) return if (store.file[path]) return
@@ -233,6 +259,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scrollTop: top, scrollTop: top,
} }
}) })
pruneView(path)
} }
const setScrollLeft = (input: string, left: number) => { const setScrollLeft = (input: string, left: number) => {
@@ -244,6 +271,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scrollLeft: left, scrollLeft: left,
} }
}) })
pruneView(path)
} }
const setSelectedLines = (input: string, range: SelectedLineRange | null) => { const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
@@ -256,6 +284,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
selectedLines: next, selectedLines: next,
} }
}) })
pruneView(path)
} }
onCleanup(() => stop()) onCleanup(() => stop())
+26 -2
View File
@@ -5,7 +5,7 @@ import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server" import { useServer } from "./server"
import { Project } from "@opencode-ai/sdk/v2" import { Project } from "@opencode-ai/sdk/v2"
import { persisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
@@ -46,7 +46,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const server = useServer() const server = useServer()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"layout.v6", Persist.global("layout", ["layout.v6"]),
createStore({ createStore({
sidebar: { sidebar: {
opened: false, opened: false,
@@ -75,6 +75,29 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const meta = { active: undefined as string | undefined, pruned: false } const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>() const used = new Map<string, number>()
const SESSION_STATE_KEYS = [
{ key: "prompt", legacy: "prompt", version: "v2" },
{ key: "terminal", legacy: "terminal", version: "v1" },
{ key: "file-view", legacy: "file", version: "v1" },
] as const
const dropSessionState = (keys: string[]) => {
for (const key of keys) {
const parts = key.split("/")
const dir = parts[0]
const session = parts[1]
if (!dir) continue
for (const entry of SESSION_STATE_KEYS) {
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
void removePersisted(target)
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey })
}
}
}
function prune(keep?: string) { function prune(keep?: string) {
if (!keep) return if (!keep) return
@@ -102,6 +125,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
) )
scroll.drop(drop) scroll.drop(drop)
dropSessionState(drop)
for (const key of drop) { for (const key of drop) {
used.delete(key) used.delete(key)
+9 -3
View File
@@ -8,7 +8,7 @@ import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
export type LocalFile = FileNode & export type LocalFile = FileNode &
@@ -111,7 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const model = (() => { const model = (() => {
const [store, setStore, _, modelReady] = persisted( const [store, setStore, _, modelReady] = persisted(
"model.v1", Persist.global("model", ["model.v1"]),
createStore<{ createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[] user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[] recent: ModelKey[]
@@ -276,7 +276,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
visible(model: ModelKey) { visible(model: ModelKey) {
const key = `${model.providerID}:${model.modelID}` const key = `${model.providerID}:${model.modelID}`
const visibility = userVisibilityMap().get(key) const visibility = userVisibilityMap().get(key)
return visibility !== "hide" && (latestSet().has(key) || visibility === "show") if (visibility === "hide") return false
if (visibility === "show") return true
if (latestSet().has(key)) return true
// For models without valid release_date (e.g. custom models), show by default
const m = find(model)
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
return false
}, },
setVisibility(model: ModelKey, visible: boolean) { setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide") updateVisibility(model, visible ? "show" : "hide")
+28 -5
View File
@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { onCleanup } from "solid-js" import { createEffect, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
@@ -10,7 +10,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio" import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
import errorSound from "@opencode-ai/ui/audio/nope-03.aac" import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
type NotificationBase = { type NotificationBase = {
directory?: string directory?: string
@@ -31,6 +31,16 @@ type ErrorNotification = NotificationBase & {
export type Notification = TurnCompleteNotification | ErrorNotification export type Notification = TurnCompleteNotification | ErrorNotification
const MAX_NOTIFICATIONS = 500
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
function pruneNotifications(list: Notification[]) {
const cutoff = Date.now() - NOTIFICATION_TTL_MS
const pruned = list.filter((n) => n.time >= cutoff)
if (pruned.length <= MAX_NOTIFICATIONS) return pruned
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
}
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
init: () => { init: () => {
@@ -49,12 +59,25 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const platform = usePlatform() const platform = usePlatform()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"notification.v1", Persist.global("notification", ["notification.v1"]),
createStore({ createStore({
list: [] as Notification[], list: [] as Notification[],
}), }),
) )
const meta = { pruned: false }
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
setStore("list", pruneNotifications(store.list))
})
const append = (notification: Notification) => {
setStore("list", (list) => pruneNotifications([...list, notification]))
}
const unsub = globalSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const event = e.details const event = e.details
@@ -73,7 +96,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
try { try {
idlePlayer?.play() idlePlayer?.play()
} catch {} } catch {}
setStore("list", store.list.length, { append({
...base, ...base,
type: "turn-complete", type: "turn-complete",
session: sessionID, session: sessionID,
@@ -92,7 +115,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
errorPlayer?.play() errorPlayer?.play()
} catch {} } catch {}
const error = "error" in event.properties ? event.properties.error : undefined const error = "error" in event.properties ? event.properties.error : undefined
setStore("list", store.list.length, { append({
...base, ...base,
type: "error", type: "error",
session: sessionID ?? "global", session: sessionID ?? "global",
+65 -20
View File
@@ -1,12 +1,12 @@
import { createMemo, onCleanup } from "solid-js" import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
type PermissionRespondFn = (input: { type PermissionRespondFn = (input: {
sessionID: string sessionID: string
@@ -19,6 +19,32 @@ function shouldAutoAccept(perm: PermissionRequest) {
return perm.permission === "edit" return perm.permission === "edit"
} }
function isNonAllowRule(rule: unknown) {
if (!rule) return false
if (typeof rule === "string") return rule !== "allow"
if (typeof rule !== "object") return false
if (Array.isArray(rule)) return false
for (const action of Object.values(rule)) {
if (action !== "allow") return true
}
return false
}
function hasAutoAcceptPermissionConfig(permission: unknown) {
if (!permission) return false
if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false
if (Array.isArray(permission)) return false
const config = permission as Record<string, unknown>
if (isNonAllowRule(config.edit)) return true
if (isNonAllowRule(config.write)) return true
return false
}
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission", name: "Permission",
init: () => { init: () => {
@@ -27,13 +53,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const permissionsEnabled = createMemo(() => { const permissionsEnabled = createMemo(() => {
if (!params.dir || !base64Decode(params.dir)) return false const directory = params.dir ? base64Decode(params.dir) : undefined
const [store] = globalSync.child(base64Decode(params.dir)) if (!directory) return false
return store.config.permission !== undefined const [store] = globalSync.child(directory)
return hasAutoAcceptPermissionConfig(store.config.permission)
}) })
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"permission.v3", Persist.global("permission", ["permission.v3"]),
createStore({ createStore({
autoAcceptEdits: {} as Record<string, boolean>, autoAcceptEdits: {} as Record<string, boolean>,
}), }),
@@ -58,8 +85,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}) })
} }
function isAutoAccepting(sessionID: string) { function acceptKey(sessionID: string, directory?: string) {
return store.autoAcceptEdits[sessionID] ?? false if (!directory) return sessionID
return `${base64Encode(directory)}/${sessionID}`
}
function isAutoAccepting(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
} }
const unsubscribe = globalSDK.event.listen((e) => { const unsubscribe = globalSDK.event.listen((e) => {
@@ -67,7 +100,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (event?.type !== "permission.asked") return if (event?.type !== "permission.asked") return
const perm = event.properties const perm = event.properties
if (!isAutoAccepting(perm.sessionID)) return if (!isAutoAccepting(perm.sessionID, e.name)) return
if (!shouldAutoAccept(perm)) return if (!shouldAutoAccept(perm)) return
respondOnce(perm, e.name) respondOnce(perm, e.name)
@@ -75,7 +108,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
onCleanup(unsubscribe) onCleanup(unsubscribe)
function enable(sessionID: string, directory: string) { function enable(sessionID: string, directory: string) {
setStore("autoAcceptEdits", sessionID, true) const key = acceptKey(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAcceptEdits[key] = true
delete draft.autoAcceptEdits[sessionID]
}),
)
globalSDK.client.permission globalSDK.client.permission
.list({ directory }) .list({ directory })
@@ -90,31 +129,37 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
.catch(() => undefined) .catch(() => undefined)
} }
function disable(sessionID: string) { function disable(sessionID: string, directory?: string) {
setStore("autoAcceptEdits", sessionID, false) const key = directory ? acceptKey(sessionID, directory) : undefined
setStore(
produce((draft) => {
if (key) delete draft.autoAcceptEdits[key]
delete draft.autoAcceptEdits[sessionID]
}),
)
} }
return { return {
ready, ready,
respond, respond,
autoResponds(permission: PermissionRequest) { autoResponds(permission: PermissionRequest, directory?: string) {
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission) return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
}, },
isAutoAccepting, isAutoAccepting,
toggleAutoAccept(sessionID: string, directory: string) { toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID)) { if (isAutoAccepting(sessionID, directory)) {
disable(sessionID) disable(sessionID, directory)
return return
} }
enable(sessionID, directory) enable(sessionID, directory)
}, },
enableAutoAccept(sessionID: string, directory: string) { enableAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID)) return if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory) enable(sessionID, directory)
}, },
disableAutoAccept(sessionID: string) { disableAutoAccept(sessionID: string, directory?: string) {
disable(sessionID) disable(sessionID, directory)
}, },
permissionsEnabled, permissionsEnabled,
} }
+3 -3
View File
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js" import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file" import type { FileSelection } from "@/context/file"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
interface PartBase { interface PartBase {
content: string content: string
@@ -103,10 +103,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
name: "Prompt", name: "Prompt",
init: () => { init: () => {
const params = useParams() const params = useParams()
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`) const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
name(), Persist.scoped(params.dir!, params.id, "prompt", [legacy()]),
createStore<{ createStore<{
prompt: Prompt prompt: Prompt
cursor?: number cursor?: number
+2 -2
View File
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
type StoredProject = { worktree: string; expanded: boolean } type StoredProject = { worktree: string; expanded: boolean }
@@ -35,7 +35,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const platform = usePlatform() const platform = usePlatform()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"server.v3", Persist.global("server", ["server.v3"]),
createStore({ createStore({
list: [] as string[], list: [] as string[],
projects: {} as Record<string, StoredProject[]>, projects: {} as Record<string, StoredProject[]>,
+3 -3
View File
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js" import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
export type LocalPTY = { export type LocalPTY = {
id: string id: string
@@ -19,10 +19,10 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const params = useParams() const params = useParams()
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`) const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
name(), Persist.scoped(params.dir!, params.id, "terminal", [legacy()]),
createStore<{ createStore<{
active?: string active?: string
all: LocalPTY[] all: LocalPTY[]
+4 -2
View File
@@ -1,6 +1,6 @@
// @refresh reload // @refresh reload
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { App } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform" import { Platform, PlatformProvider } from "@/context/platform"
import pkg from "../package.json" import pkg from "../package.json"
@@ -55,7 +55,9 @@ const platform: Platform = {
render( render(
() => ( () => (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<App /> <AppBaseProviders>
<AppInterface />
</AppBaseProviders>
</PlatformProvider> </PlatformProvider>
), ),
root!, root!,
+1 -1
View File
@@ -1,2 +1,2 @@
export { PlatformProvider, type Platform } from "./context/platform" export { PlatformProvider, type Platform } from "./context/platform"
export { App } from "./app" export { AppBaseProviders, AppInterface } from "./app"
+23 -16
View File
@@ -170,7 +170,7 @@ export default function Layout(props: ParentProps) {
if (e.details?.type !== "permission.asked") return if (e.details?.type !== "permission.asked") return
const directory = e.name const directory = e.name
const perm = e.details.properties const perm = e.details.properties
if (permission.autoResponds(perm)) return if (permission.autoResponds(perm, directory)) return
const [store] = globalSync.child(directory) const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === perm.sessionID) const session = store.session.find((s) => s.id === perm.sessionID)
@@ -266,24 +266,30 @@ export default function Layout(props: ParentProps) {
} }
} }
function projectSessions(directory: string) { const currentProject = createMemo(() => {
if (!directory) return [] const directory = params.dir ? base64Decode(params.dir) : undefined
const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions) if (!directory) return
return (sessions ?? []).filter((s) => !s.parentID) return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
function projectSessions(project: LocalProject | undefined) {
if (!project) return []
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const stores = dirs.map((dir) => globalSync.child(dir)[0])
const sessions = stores
.flatMap((store) => store.session.filter((session) => session.directory === store.path.directory))
.toSorted(sortSessions)
return sessions.filter((s) => !s.parentID)
} }
const currentSessions = createMemo(() => { const currentSessions = createMemo(() => projectSessions(currentProject()))
if (!params.dir) return []
const directory = base64Decode(params.dir)
return projectSessions(directory)
})
function navigateSessionByOffset(offset: number) { function navigateSessionByOffset(offset: number) {
const projects = layout.projects.list() const projects = layout.projects.list()
if (projects.length === 0) return if (projects.length === 0) return
const currentDirectory = params.dir ? base64Decode(params.dir) : undefined const project = currentProject()
const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1 const projectIndex = project ? projects.findIndex((p) => p.worktree === project.worktree) : -1
if (projectIndex === -1) { if (projectIndex === -1) {
const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1] const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
@@ -312,14 +318,14 @@ export default function Layout(props: ParentProps) {
const nextProject = projects[nextProjectIndex] const nextProject = projects[nextProjectIndex]
if (!nextProject) return if (!nextProject) return
const nextProjectSessions = projectSessions(nextProject.worktree) const nextProjectSessions = projectSessions(nextProject)
if (nextProjectSessions.length === 0) { if (nextProjectSessions.length === 0) {
navigateToProject(nextProject.worktree) navigateToProject(nextProject.worktree)
return return
} }
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1] const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`) navigateToSession(targetSession)
queueMicrotask(() => scrollToSession(targetSession.id)) queueMicrotask(() => scrollToSession(targetSession.id))
} }
@@ -465,7 +471,7 @@ export default function Layout(props: ParentProps) {
function navigateToSession(session: Session | undefined) { function navigateToSession(session: Session | undefined) {
if (!session) return if (!session) return
navigate(`/${params.dir}/session/${session?.id}`) navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
layout.mobileSidebar.hide() layout.mobileSidebar.hide()
} }
@@ -514,7 +520,8 @@ export default function Layout(props: ParentProps) {
const id = params.id const id = params.id
setStore("lastSession", directory, id) setStore("lastSession", directory, id)
notification.session.markViewed(id) notification.session.markViewed(id)
untrack(() => layout.projects.expand(directory)) const project = currentProject()
untrack(() => layout.projects.expand(project?.worktree ?? directory))
requestAnimationFrame(() => scrollToSession(id)) requestAnimationFrame(() => scrollToSession(id))
}) })
+8 -3
View File
@@ -467,7 +467,10 @@ export default function Page() {
}, },
{ {
id: "permissions.autoaccept", id: "permissions.autoaccept",
title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits", title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? "Stop auto-accepting edits"
: "Auto-accept edits",
category: "Permissions", category: "Permissions",
keybind: "mod+shift+a", keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(), disabled: !params.id || !permission.permissionsEnabled(),
@@ -476,8 +479,10 @@ export default function Page() {
if (!sessionID) return if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory) permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({ showToast({
title: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits" : "Stopped auto-accepting edits", title: permission.isAutoAccepting(sessionID, sdk.directory)
description: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits"
: "Stopped auto-accepting edits",
description: permission.isAutoAccepting(sessionID, sdk.directory)
? "Edit and write permissions will be automatically approved" ? "Edit and write permissions will be automatically approved"
: "Edit and write permissions will require approval", : "Edit and write permissions will require approval",
}) })
+223 -5
View File
@@ -1,17 +1,235 @@
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { makePersisted } from "@solid-primitives/storage" import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, type Accessor } from "solid-js" import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store" import type { SetStoreFunction, Store } from "solid-js/store"
type InitType = Promise<string> | string | null type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>] type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> { type PersistTarget = {
storage?: string
key: string
legacy?: string[]
migrate?: (value: unknown) => unknown
}
const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
function snapshot(value: unknown) {
return JSON.parse(JSON.stringify(value)) as unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function merge(defaults: unknown, value: unknown): unknown {
if (value === undefined) return defaults
if (value === null) return value
if (Array.isArray(defaults)) {
if (Array.isArray(value)) return value
return defaults
}
if (isRecord(defaults)) {
if (!isRecord(value)) return defaults
const result: Record<string, unknown> = { ...defaults }
for (const key of Object.keys(value)) {
if (key in defaults) {
result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
} else {
result[key] = (value as Record<string, unknown>)[key]
}
}
return result
}
return value
}
function parse(value: string) {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
function workspaceStorage(dir: string) {
const head = dir.slice(0, 12) || "workspace"
const sum = checksum(dir) ?? "0"
return `opencode.workspace.${head}.${sum}.dat`
}
function localStorageWithPrefix(prefix: string): SyncStorage {
const base = `${prefix}:`
return {
getItem: (key) => localStorage.getItem(base + key),
setItem: (key, value) => localStorage.setItem(base + key, value),
removeItem: (key) => localStorage.removeItem(base + key),
}
}
export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy }
},
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
},
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
},
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
if (session) return Persist.session(dir, session, key, legacy)
return Persist.workspace(dir, key, legacy)
},
}
export function removePersisted(target: { storage?: string; key: string }) {
const platform = usePlatform() const platform = usePlatform()
const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage }) const isDesktop = platform.platform === "desktop" && !!platform.storage
if (isDesktop) {
return platform.storage?.(target.storage)?.removeItem(target.key)
}
if (!target.storage) {
localStorage.removeItem(target.key)
return
}
localStorageWithPrefix(target.storage).removeItem(target.key)
}
export function persisted<T>(
target: string | PersistTarget,
store: [Store<T>, SetStoreFunction<T>],
): PersistedWithReady<T> {
const platform = usePlatform()
const config: PersistTarget = typeof target === "string" ? { key: target } : target
const defaults = snapshot(store[0])
const legacy = config.legacy ?? []
const isDesktop = platform.platform === "desktop" && !!platform.storage
const currentStorage = (() => {
if (isDesktop) return platform.storage?.(config.storage)
if (!config.storage) return localStorage
return localStorageWithPrefix(config.storage)
})()
const legacyStorage = (() => {
if (!isDesktop) return localStorage
if (!config.storage) return platform.storage?.()
return platform.storage?.(LEGACY_STORAGE)
})()
const storage = (() => {
if (!isDesktop) {
const current = currentStorage as SyncStorage
const legacyStore = legacyStorage as SyncStorage
const api: SyncStorage = {
getItem: (key) => {
const raw = current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) current.setItem(key, next)
return next
}
for (const legacyKey of legacy) {
const legacyRaw = legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
current.setItem(key, legacyRaw)
legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) current.setItem(key, next)
return next
}
return null
},
setItem: (key, value) => {
current.setItem(key, value)
},
removeItem: (key) => {
current.removeItem(key)
},
}
return api
}
const current = currentStorage as AsyncStorage
const legacyStore = legacyStorage as AsyncStorage | undefined
const api: AsyncStorage = {
getItem: async (key) => {
const raw = await current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) await current.setItem(key, next)
return next
}
if (!legacyStore) return null
for (const legacyKey of legacy) {
const legacyRaw = await legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
await current.setItem(key, legacyRaw)
await legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) await current.setItem(key, next)
return next
}
return null
},
setItem: async (key, value) => {
await current.setItem(key, value)
},
removeItem: async (key) => {
await current.removeItem(key)
},
}
return api
})()
const [state, setState, init] = makePersisted(store, { name: config.key, storage })
// Create a resource that resolves when the store is initialized
// This integrates with Suspense and provides a ready signal
const isAsync = init instanceof Promise const isAsync = init instanceof Promise
const [ready] = createResource( const [ready] = createResource(
() => init, () => init,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.4", "version": "1.1.7",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -2,6 +2,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server" import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js" import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js" import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js" import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -146,6 +147,242 @@ export async function POST(input: APIEvent) {
.where(eq(BillingTable.workspaceID, workspaceID)) .where(eq(BillingTable.workspaceID, workspaceID))
}) })
} }
if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
const invoiceID = body.data.object.id as string
const amountInCents = body.data.object.amount_paid
const customerID = body.data.object.customer as string
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
if (!customerID) throw new Error("Customer ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) throw new Error("Payment ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for customer")
await Database.use((tx) =>
tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
paymentID,
invoiceID,
customerID,
}),
)
}
if (body.type === "customer.subscription.created") {
const data = {
id: "evt_1Smq802SrMQ2Fneksse5FMNV",
object: "event",
api_version: "2025-07-30.basil",
created: 1767766916,
data: {
object: {
id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
object: "subscription",
application: null,
application_fee_percent: null,
automatic_tax: {
disabled_reason: null,
enabled: false,
liability: null,
},
billing_cycle_anchor: 1770445200,
billing_cycle_anchor_config: null,
billing_mode: {
flexible: {
proration_discounts: "included",
},
type: "flexible",
updated_at: 1770445200,
},
billing_thresholds: null,
cancel_at: null,
cancel_at_period_end: false,
canceled_at: null,
cancellation_details: {
comment: null,
feedback: null,
reason: null,
},
collection_method: "charge_automatically",
created: 1770445200,
currency: "usd",
customer: "cus_TkKmZZvysJ2wej",
customer_account: null,
days_until_due: null,
default_payment_method: null,
default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
default_tax_rates: [],
description: null,
discounts: [],
ended_at: null,
invoice_settings: {
account_tax_ids: null,
issuer: {
type: "self",
},
},
items: {
object: "list",
data: [
{
id: "si_TkKnBKXFX76t0O",
object: "subscription_item",
billing_thresholds: null,
created: 1770445200,
current_period_end: 1772864400,
current_period_start: 1770445200,
discounts: [],
metadata: {},
plan: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "plan",
active: true,
amount: 20000,
amount_decimal: "20000",
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
interval: "month",
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: "licensed",
},
price: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "price",
active: true,
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
recurring: {
interval: "month",
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: "licensed",
},
tax_behavior: "unspecified",
tiers_mode: null,
transform_quantity: null,
type: "recurring",
unit_amount: 20000,
unit_amount_decimal: "20000",
},
quantity: 1,
subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
tax_rates: [],
},
],
has_more: false,
total_count: 1,
url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
},
latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
livemode: false,
metadata: {},
next_pending_invoice_item_invoice: null,
on_behalf_of: null,
pause_collection: null,
payment_settings: {
payment_method_options: null,
payment_method_types: null,
save_default_payment_method: "off",
},
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
plan: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "plan",
active: true,
amount: 20000,
amount_decimal: "20000",
billing_scheme: "per_unit",
created: 1767725082,
currency: "usd",
interval: "month",
interval_count: 1,
livemode: false,
metadata: {},
meter: null,
nickname: null,
product: "prod_Tk9LjWT1n0DgYm",
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
usage_type: "licensed",
},
quantity: 1,
schedule: null,
start_date: 1770445200,
status: "active",
test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
transfer_data: null,
trial_end: null,
trial_settings: {
end_behavior: {
missing_payment_method: "create_invoice",
},
},
trial_start: null,
},
},
livemode: false,
pending_webhooks: 0,
request: {
id: "req_6YO9stvB155WJD",
idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
},
type: "customer.subscription.created",
}
}
if (body.type === "customer.subscription.deleted") {
const subscriptionID = body.data.object.id
if (!subscriptionID) throw new Error("Subscription ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.subscriptionID, subscriptionID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
await Database.transaction(async (tx) => {
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
})
}
})() })()
.then((message) => { .then((message) => {
return Response.json({ message: message ?? "done" }, { status: 200 }) return Response.json({ message: message ?? "done" }, { status: 200 })
@@ -0,0 +1,8 @@
.root {
[data-slot="title-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
}
@@ -0,0 +1,58 @@
import { action, useParams, useAction, useSubmission, json } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css"
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateSessionUrl({ returnUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: queryBillingInfo.key },
)
}, "sessionUrl")
export function BlackSection() {
const params = useParams()
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const [store, setStore] = createStore({
sessionRedirecting: false,
})
async function onClickSession() {
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
setStore("sessionRedirecting", true)
window.location.href = result.data
}
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Subscription</h2>
<div data-slot="title-row">
<p>You are subscribed to OpenCode Black for $200 per month.</p>
<button
data-color="primary"
disabled={sessionSubmission.pending || store.sessionRedirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage Subscription"}
</button>
</div>
</div>
</section>
)
}
@@ -2,19 +2,23 @@ import { MonthlyLimitSection } from "./monthly-limit-section"
import { BillingSection } from "./billing-section" import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section" import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section" import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
import { Show } from "solid-js" import { Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router" import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common" import { queryBillingInfo, querySessionInfo } from "../../common"
export default function () { export default function () {
const params = useParams() const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id!)) const sessionInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!)) const billingInfo = createAsync(() => queryBillingInfo(params.id!))
return ( return (
<div data-page="workspace-[id]"> <div data-page="workspace-[id]">
<div data-slot="sections"> <div data-slot="sections">
<Show when={userInfo()?.isAdmin}> <Show when={sessionInfo()?.isAdmin}>
<Show when={billingInfo()?.subscriptionID}>
<BlackSection />
</Show>
<BillingSection /> <BillingSection />
<Show when={billingInfo()?.customerID}> <Show when={billingInfo()?.customerID}>
<ReloadSection /> <ReloadSection />
@@ -169,7 +169,9 @@ export function UsageSection() {
</Show> </Show>
</div> </div>
</td> </td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td> <td data-slot="usage-cost">
${usage.enrichment?.plan === "sub" ? "0.0000" : ((usage.cost ?? 0) / 100000000).toFixed(4)}
</td>
</tr> </tr>
) )
}} }}
@@ -1,6 +1,13 @@
export class AuthError extends Error {} export class AuthError extends Error {}
export class CreditsError extends Error {} export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {} export class MonthlyLimitError extends Error {}
export class SubscriptionError extends Error {
retryAfter?: number
constructor(message: string, retryAfter?: number) {
super(message)
this.retryAfter = retryAfter
}
}
export class UserLimitError extends Error {} export class UserLimitError extends Error {}
export class ModelError extends Error {} export class ModelError extends Error {}
export class RateLimitError extends Error {} export class RateLimitError extends Error {}
@@ -8,11 +8,20 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ZenData } from "@opencode-ai/console-core/model.js" import { ZenData } from "@opencode-ai/console-core/model.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js" import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js" import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
import { logger } from "./logger" import { logger } from "./logger"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error" import {
AuthError,
CreditsError,
MonthlyLimitError,
SubscriptionError,
UserLimitError,
ModelError,
RateLimitError,
} from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider" import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic" import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google" import { googleHelper } from "./provider/google"
@@ -73,9 +82,9 @@ export async function handler(
await rateLimiter?.check() await rateLimiter?.check()
const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId) const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId)
const stickyProvider = await stickyTracker?.get() const stickyProvider = await stickyTracker?.get()
const authInfo = await authenticate(modelInfo)
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => { const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const authInfo = await authenticate(modelInfo)
const providerInfo = selectProvider( const providerInfo = selectProvider(
zenData, zenData,
authInfo, authInfo,
@@ -135,10 +144,10 @@ export async function handler(
}) })
} }
return { providerInfo, authInfo, reqBody, res, startTimestamp } return { providerInfo, reqBody, res, startTimestamp }
} }
const { providerInfo, authInfo, reqBody, res, startTimestamp } = await retriableRequest() const { providerInfo, reqBody, res, startTimestamp } = await retriableRequest()
// Store model request // Store model request
dataDumper?.provideModel(providerInfo.storeModel) dataDumper?.provideModel(providerInfo.storeModel)
@@ -279,14 +288,19 @@ export async function handler(
{ status: 401 }, { status: 401 },
) )
if (error instanceof RateLimitError) if (error instanceof RateLimitError || error instanceof SubscriptionError) {
const headers = new Headers()
if (error instanceof SubscriptionError && error.retryAfter) {
headers.set("retry-after", String(error.retryAfter))
}
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
type: "error", type: "error",
error: { type: error.constructor.name, message: error.message }, error: { type: error.constructor.name, message: error.message },
}), }),
{ status: 429 }, { status: 429, headers },
) )
}
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@@ -400,6 +414,13 @@ export async function handler(
monthlyUsage: UserTable.monthlyUsage, monthlyUsage: UserTable.monthlyUsage,
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated, timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
}, },
subscription: {
timeSubscribed: UserTable.timeSubscribed,
subIntervalUsage: UserTable.subIntervalUsage,
subMonthlyUsage: UserTable.subMonthlyUsage,
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
},
provider: { provider: {
credentials: ProviderTable.credentials, credentials: ProviderTable.credentials,
}, },
@@ -427,6 +448,7 @@ export async function handler(
logger.metric({ logger.metric({
api_key: data.apiKey, api_key: data.apiKey,
workspace: data.workspaceID, workspace: data.workspaceID,
isSubscription: data.subscription.timeSubscribed ? true : false,
}) })
return { return {
@@ -434,6 +456,7 @@ export async function handler(
workspaceID: data.workspaceID, workspaceID: data.workspaceID,
billing: data.billing, billing: data.billing,
user: data.user, user: data.user,
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
provider: data.provider, provider: data.provider,
isFree: FREE_WORKSPACES.includes(data.workspaceID), isFree: FREE_WORKSPACES.includes(data.workspaceID),
isDisabled: !!data.timeDisabled, isDisabled: !!data.timeDisabled,
@@ -446,6 +469,64 @@ export async function handler(
if (authInfo.isFree) return if (authInfo.isFree) return
if (modelInfo.allowAnonymous) return if (modelInfo.allowAnonymous) return
// Validate subscription billing
if (authInfo.subscription) {
const black = BlackData.get()
const sub = authInfo.subscription
const now = new Date()
const formatRetryTime = (seconds: number) => {
const days = Math.floor(seconds / 86400)
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
const hours = Math.floor(seconds / 3600)
const minutes = Math.ceil((seconds % 3600) / 60)
if (hours >= 1) return `${hours}hr ${minutes}min`
return `${minutes}min`
}
// Check monthly limit (based on subscription billing cycle)
if (
sub.subMonthlyUsage &&
sub.timeSubMonthlyUsageUpdated &&
sub.subMonthlyUsage >= centsToMicroCents(black.monthlyLimit * 100)
) {
const subscribeDay = sub.timeSubscribed!.getUTCDate()
const cycleStart = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
subscribeDay,
),
)
const cycleEnd = new Date(Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay))
if (sub.timeSubMonthlyUsageUpdated >= cycleStart && sub.timeSubMonthlyUsageUpdated < cycleEnd) {
const retryAfter = Math.ceil((cycleEnd.getTime() - now.getTime()) / 1000)
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter,
)
}
}
// Check interval limit
const intervalMs = black.intervalLength * 3600 * 1000
if (sub.subIntervalUsage && sub.timeSubIntervalUsageUpdated) {
const currentInterval = Math.floor(now.getTime() / intervalMs)
const usageInterval = Math.floor(sub.timeSubIntervalUsageUpdated.getTime() / intervalMs)
if (currentInterval === usageInterval && sub.subIntervalUsage >= centsToMicroCents(black.intervalLimit * 100)) {
const nextInterval = (currentInterval + 1) * intervalMs
const retryAfter = Math.ceil((nextInterval - now.getTime()) / 1000)
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
retryAfter,
)
}
}
return
}
// Validate pay as you go billing
const billing = authInfo.billing const billing = authInfo.billing
if (!billing.paymentMethodID) if (!billing.paymentMethodID)
throw new CreditsError( throw new CreditsError(
@@ -463,29 +544,25 @@ export async function handler(
billing.monthlyLimit && billing.monthlyLimit &&
billing.monthlyUsage && billing.monthlyUsage &&
billing.timeMonthlyUsageUpdated && billing.timeMonthlyUsageUpdated &&
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) &&
) { currentYear === billing.timeMonthlyUsageUpdated.getUTCFullYear() &&
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear() currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth() )
if (currentYear === dateYear && currentMonth === dateMonth) throw new MonthlyLimitError(
throw new MonthlyLimitError( `Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`, )
)
}
if ( if (
authInfo.user.monthlyLimit && authInfo.user.monthlyLimit &&
authInfo.user.monthlyUsage && authInfo.user.monthlyUsage &&
authInfo.user.timeMonthlyUsageUpdated && authInfo.user.timeMonthlyUsageUpdated &&
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100) authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100) &&
) { currentYear === authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() &&
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth() )
if (currentYear === dateYear && currentMonth === dateMonth) throw new UserLimitError(
throw new UserLimitError( `You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`, )
)
}
} }
function validateModelSettings(authInfo: AuthInfo) { function validateModelSettings(authInfo: AuthInfo) {
@@ -560,7 +637,7 @@ export async function handler(
if (!authInfo) return if (!authInfo) return
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent) const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.use((db) => await Database.use((db) =>
Promise.all([ Promise.all([
db.insert(UsageTable).values({ db.insert(UsageTable).values({
@@ -576,36 +653,77 @@ export async function handler(
cacheWrite1hTokens, cacheWrite1hTokens,
cost, cost,
keyID: authInfo.apiKeyId, keyID: authInfo.apiKeyId,
enrichment: authInfo.subscription ? { plan: "sub" } : undefined,
}), }),
db db
.update(BillingTable) .update(KeyTable)
.set({ .set({ timeUsed: sql`now()` })
balance: sql`${BillingTable.balance} - ${cost}`, .where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
monthlyUsage: sql` ...(authInfo.subscription
? (() => {
const now = new Date()
const subscribeDay = authInfo.subscription.timeSubscribed!.getUTCDate()
const cycleStart = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
subscribeDay,
),
)
const cycleEnd = new Date(
Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay),
)
return [
db
.update(UserTable)
.set({
subMonthlyUsage: sql`
CASE
WHEN ${UserTable.timeSubMonthlyUsageUpdated} >= ${cycleStart} AND ${UserTable.timeSubMonthlyUsageUpdated} < ${cycleEnd} THEN ${UserTable.subMonthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeSubMonthlyUsageUpdated: sql`now()`,
subIntervalUsage: sql`
CASE
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 3600)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 3600)) THEN ${UserTable.subIntervalUsage} + ${cost}
ELSE ${cost}
END
`,
timeSubIntervalUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
]
})()
: [
db
.update(BillingTable)
.set({
balance: authInfo.isFree
? sql`${BillingTable.balance} - ${0}`
: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
CASE CASE
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost} WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost} ELSE ${cost}
END END
`, `,
timeMonthlyUsageUpdated: sql`now()`, timeMonthlyUsageUpdated: sql`now()`,
}) })
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)), .where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
db db
.update(UserTable) .update(UserTable)
.set({ .set({
monthlyUsage: sql` monthlyUsage: sql`
CASE CASE
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost} WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
ELSE ${cost} ELSE ${cost}
END END
`, `,
timeMonthlyUsageUpdated: sql`now()`, timeMonthlyUsageUpdated: sql`now()`,
}) })
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))), .where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
db ]),
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
]), ]),
) )
@@ -616,6 +734,7 @@ export async function handler(
if (!authInfo) return if (!authInfo) return
if (authInfo.isFree) return if (authInfo.isFree) return
if (authInfo.provider?.credentials) return if (authInfo.provider?.credentials) return
if (authInfo.subscription) return
if (!costInfo) return if (!costInfo) return
@@ -0,0 +1,7 @@
ALTER TABLE `billing` ADD `subscription_id` varchar(28);--> statement-breakpoint
ALTER TABLE `usage` ADD `data` json;--> statement-breakpoint
ALTER TABLE `user` ADD `time_subscribed` timestamp(3);--> statement-breakpoint
ALTER TABLE `user` ADD `sub_recent_usage` bigint;--> statement-breakpoint
ALTER TABLE `user` ADD `sub_monthly_usage` bigint;--> statement-breakpoint
ALTER TABLE `user` ADD `sub_time_recent_usage_updated` timestamp(3);--> statement-breakpoint
ALTER TABLE `user` ADD `sub_time_monthly_usage_updated` timestamp(3);
@@ -0,0 +1,2 @@
ALTER TABLE `user` RENAME COLUMN `sub_recent_usage` TO `sub_interval_usage`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `sub_time_recent_usage_updated` TO `sub_time_interval_usage_updated`;
@@ -0,0 +1 @@
ALTER TABLE `usage` RENAME COLUMN `data` TO `enrichment`;
@@ -0,0 +1 @@
ALTER TABLE `billing` ADD CONSTRAINT `global_subscription_id` UNIQUE(`subscription_id`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -295,6 +295,34 @@
"when": 1767732559197, "when": 1767732559197,
"tag": "0041_odd_misty_knight", "tag": "0041_odd_misty_knight",
"breakpoints": true "breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1767744077346,
"tag": "0042_flat_nightmare",
"breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1767752636118,
"tag": "0043_lame_calypso",
"breakpoints": true
},
{
"idx": 44,
"version": "5",
"when": 1767759322451,
"tag": "0044_tiny_captain_midlands",
"breakpoints": true
},
{
"idx": 45,
"version": "5",
"when": 1767765497502,
"tag": "0045_cuddly_diamondback",
"breakpoints": true
} }
] ]
} }
+4 -1
View File
@@ -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.4", "version": "1.1.7",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -32,6 +32,9 @@
"promote-models-to-dev": "script/promote-models.ts dev", "promote-models-to-dev": "script/promote-models.ts dev",
"promote-models-to-prod": "script/promote-models.ts production", "promote-models-to-prod": "script/promote-models.ts production",
"pull-models-from-dev": "script/pull-models.ts dev", "pull-models-from-dev": "script/pull-models.ts dev",
"update-black": "script/update-black.ts",
"promote-black-to-dev": "script/promote-black.ts dev",
"promote-black-to-prod": "script/promote-black.ts production",
"typecheck": "tsgo --noEmit" "typecheck": "tsgo --noEmit"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,166 @@
import { Billing } from "../src/billing.js"
import { Database, eq, and, sql } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js"
import { Identifier } from "../src/identifier.js"
import { centsToMicroCents } from "../src/util/price.js"
const workspaceID = process.argv[2]
const email = process.argv[3]
if (!workspaceID || !email) {
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
process.exit(1)
}
// Look up the Stripe customer by email
const customers = await Billing.stripe().customers.list({ email, limit: 1 })
const customer = customers.data[0]
if (!customer) {
console.error(`Error: No Stripe customer found for email ${email}`)
process.exit(1)
}
const customerID = customer.id
// Get the subscription id
const subscriptions = await Billing.stripe().subscriptions.list({ customer: customerID, limit: 1 })
const subscription = subscriptions.data[0]
if (!subscription) {
console.error(`Error: Customer ${customerID} does not have a subscription`)
process.exit(1)
}
const subscriptionID = subscription.id
// Validate the subscription is $200
const amountInCents = subscription.items.data[0]?.price.unit_amount ?? 0
if (amountInCents !== 20000) {
console.error(`Error: Subscription amount is $${amountInCents / 100}, expected $200`)
process.exit(1)
}
// Check if subscription is already tied to another workspace
const existingSubscription = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.subscriptionID, subscriptionID))
.then((rows) => rows[0]),
)
if (existingSubscription) {
console.error(
`Error: Subscription ${subscriptionID} is already tied to workspace ${existingSubscription.workspaceID}`,
)
process.exit(1)
}
// Look up the workspace billing and check if it already has a customer id or subscription
const billing = await Database.use((tx) =>
tx
.select({ customerID: BillingTable.customerID, subscriptionID: BillingTable.subscriptionID })
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (billing?.subscriptionID) {
console.error(`Error: Workspace ${workspaceID} already has a subscription: ${billing.subscriptionID}`)
process.exit(1)
}
if (billing?.customerID) {
console.warn(
`Warning: Workspace ${workspaceID} already has a customer id: ${billing.customerID}, replacing with ${customerID}`,
)
}
// Get the latest invoice and payment from the subscription
const invoices = await Billing.stripe().invoices.list({
subscription: subscriptionID,
limit: 1,
expand: ["data.payments"],
})
const invoice = invoices.data[0]
const invoiceID = invoice?.id
const paymentID = invoice?.payments?.data[0]?.payment.payment_intent as string | undefined
// Get the default payment method from the customer
const paymentMethodID = (customer.invoice_settings.default_payment_method ?? subscription.default_payment_method) as
| string
| null
const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.retrieve(paymentMethodID) : null
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
const paymentMethodType = paymentMethod?.type ?? null
// Look up the user by email via AuthTable
const auth = await Database.use((tx) =>
tx
.select({ accountID: AuthTable.accountID })
.from(AuthTable)
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
.then((rows) => rows[0]),
)
if (!auth) {
console.error(`Error: No user found with email ${email}`)
process.exit(1)
}
// Look up the user in the workspace
const user = await Database.use((tx) =>
tx
.select({ id: UserTable.id })
.from(UserTable)
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, auth.accountID)))
.then((rows) => rows[0]),
)
if (!user) {
console.error(`Error: User with email ${email} is not a member of workspace ${workspaceID}`)
process.exit(1)
}
// Set workspaceID in Stripe customer metadata
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
},
})
await Database.transaction(async (tx) => {
// Set customer id, subscription id, and payment method on workspace billing
await tx
.update(BillingTable)
.set({
customerID,
subscriptionID,
paymentMethodID,
paymentMethodLast4,
paymentMethodType,
})
.where(eq(BillingTable.workspaceID, workspaceID))
// Set current time as timeSubscribed on user
await tx
.update(UserTable)
.set({
timeSubscribed: sql`now()`,
})
.where(eq(UserTable.id, user.id))
// Create a row in payments table
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
customerID,
invoiceID,
paymentID,
})
})
console.log(`Successfully onboarded workspace ${workspaceID}`)
console.log(` Customer ID: ${customerID}`)
console.log(` Subscription ID: ${subscriptionID}`)
console.log(
` Payment Method: ${paymentMethodID ?? "(none)"} (${paymentMethodType ?? "unknown"} ending in ${paymentMethodLast4 ?? "????"})`,
)
console.log(` User ID: ${user.id}`)
console.log(` Invoice ID: ${invoiceID ?? "(none)"}`)
console.log(` Payment ID: ${paymentID ?? "(none)"}`)
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "path"
import { BlackData } from "../src/black"
const stage = process.argv[2]
if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const lines = ret.split("\n")
const value = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1]
if (!value) throw new Error("ZEN_BLACK not found")
// validate value
BlackData.validate(JSON.parse(value))
// update the secret
await $`bun sst secret set ZEN_BLACK ${value} --stage ${stage}`
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "path"
import os from "os"
import { BlackData } from "../src/black"
const root = path.resolve(process.cwd(), "..", "..", "..")
const secrets = await $`bun sst secret list`.cwd(root).text()
// read the line starting with "ZEN_BLACK"
const lines = secrets.split("\n")
const oldValue = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1]
if (!oldValue) throw new Error("ZEN_BLACK not found")
// store the prettified json to a temp file
const filename = `black-${Date.now()}.json`
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
console.log("tempFile", tempFile.name)
// open temp file in vim and read the file on close
await $`vim ${tempFile.name}`
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
BlackData.validate(JSON.parse(newValue))
// update the secret
await $`bun sst secret set ZEN_BLACK ${newValue}`
+1
View File
@@ -27,6 +27,7 @@ export namespace Billing {
tx tx
.select({ .select({
customerID: BillingTable.customerID, customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
paymentMethodID: BillingTable.paymentMethodID, paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType, paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4, paymentMethodLast4: BillingTable.paymentMethodLast4,
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { Resource } from "@opencode-ai/console-resource"
export namespace BlackData {
const Schema = z.object({
monthlyLimit: z.number().int(),
intervalLimit: z.number().int(),
intervalLength: z.number().int(),
})
export const validate = fn(Schema, (input) => {
return input
})
export const get = fn(z.void(), () => {
const json = JSON.parse(Resource.ZEN_BLACK.value)
return Schema.parse(json)
})
}
@@ -1,4 +1,4 @@
import { bigint, boolean, index, int, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" import { bigint, boolean, index, int, json, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types" import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql" import { workspaceIndexes } from "./workspace.sql"
@@ -21,8 +21,13 @@ export const BillingTable = mysqlTable(
reloadError: varchar("reload_error", { length: 255 }), reloadError: varchar("reload_error", { length: 255 }),
timeReloadError: utc("time_reload_error"), timeReloadError: utc("time_reload_error"),
timeReloadLockedTill: utc("time_reload_locked_till"), timeReloadLockedTill: utc("time_reload_locked_till"),
subscriptionID: varchar("subscription_id", { length: 28 }),
}, },
(table) => [...workspaceIndexes(table), uniqueIndex("global_customer_id").on(table.customerID)], (table) => [
...workspaceIndexes(table),
uniqueIndex("global_customer_id").on(table.customerID),
uniqueIndex("global_subscription_id").on(table.subscriptionID),
],
) )
export const PaymentTable = mysqlTable( export const PaymentTable = mysqlTable(
@@ -54,6 +59,9 @@ export const UsageTable = mysqlTable(
cacheWrite1hTokens: int("cache_write_1h_tokens"), cacheWrite1hTokens: int("cache_write_1h_tokens"),
cost: bigint("cost", { mode: "number" }).notNull(), cost: bigint("cost", { mode: "number" }).notNull(),
keyID: ulid("key_id"), keyID: ulid("key_id"),
enrichment: json("enrichment").$type<{
plan: "sub"
}>(),
}, },
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)], (table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
) )
@@ -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),
+4
View File
@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite" "type": "sst.cloudflare.StaticSite"
"url": string "url": string
} }
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": { "ZEN_MODELS1": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.4", "version": "1.1.7",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+4
View File
@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite" "type": "sst.cloudflare.StaticSite"
"url": string "url": string
} }
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": { "ZEN_MODELS1": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.4", "version": "1.1.7",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+4
View File
@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite" "type": "sst.cloudflare.StaticSite"
"url": string "url": string
} }
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": { "ZEN_MODELS1": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.1.4", "version": "1.1.7",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -14,6 +14,7 @@
}, },
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
+18
View File
@@ -1177,6 +1177,21 @@ dependencies = [
"new_debug_unreachable", "new_debug_unreachable",
] ]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -1184,6 +1199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -1251,6 +1267,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro", "futures-macro",
@@ -2775,6 +2792,7 @@ dependencies = [
name = "opencode-desktop" name = "opencode-desktop"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"futures",
"gtk", "gtk",
"listeners", "listeners",
"semver", "semver",
+1
View File
@@ -36,6 +36,7 @@ serde_json = "1"
tokio = "1.48.0" tokio = "1.48.0"
listeners = "0.3" listeners = "0.3"
tauri-plugin-os = "2" tauri-plugin-os = "2"
futures = "0.3.31"
semver = "1.0.27" semver = "1.0.27"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
@@ -12,5 +12,19 @@
<true/> <true/>
<key>com.apple.security.cs.disable-library-validation</key> <key>com.apple.security.cs.disable-library-validation</key>
<true/> <true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.personal-information.addressbook</key>
<true/>
<key>com.apple.security.personal-information.calendars</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>
</dict> </dict>
</plist> </plist>
+183 -99
View File
@@ -2,6 +2,7 @@ mod cli;
mod window_customizer; mod window_customizer;
use cli::{get_sidecar_path, install_cli, sync_cli}; use cli::{get_sidecar_path, install_cli, sync_cli};
use futures::FutureExt;
use std::{ use std::{
collections::VecDeque, collections::VecDeque,
net::{SocketAddr, TcpListener}, net::{SocketAddr, TcpListener},
@@ -9,23 +10,101 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tauri::{ use tauri::{
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
WebviewWindow,
}; };
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
use tauri_plugin_store::StoreExt;
use tokio::net::TcpSocket; use tokio::net::TcpSocket;
use crate::window_customizer::PinchZoomDisablePlugin; use crate::window_customizer::PinchZoomDisablePlugin;
#[derive(Clone)] #[derive(Clone)]
struct ServerState(Arc<Mutex<Option<CommandChild>>>); struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>,
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
}
impl ServerState {
pub fn new(
child: Option<CommandChild>,
status: tokio::sync::oneshot::Receiver<Result<(), String>>,
) -> Self {
Self {
child: Arc::new(Mutex::new(child)),
status: status.shared(),
}
}
pub fn set_child(&self, child: Option<CommandChild>) {
*self.child.lock().unwrap() = child;
}
}
#[derive(Clone)] #[derive(Clone)]
struct LogState(Arc<Mutex<VecDeque<String>>>); struct LogState(Arc<Mutex<VecDeque<String>>>);
const MAX_LOG_ENTRIES: usize = 200; const MAX_LOG_ENTRIES: usize = 200;
const GLOBAL_STORAGE: &str = "opencode.global.dat";
/// Check if a URL's origin matches any configured server in the store.
/// Returns true if the URL should be allowed for internal navigation.
fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool {
// Always allow localhost and 127.0.0.1
if let Some(host) = url.host_str() {
if host == "localhost" || host == "127.0.0.1" {
return true;
}
}
// Try to read the server list from the store
let Ok(store) = app.store(GLOBAL_STORAGE) else {
return false;
};
let Some(server_data) = store.get("server") else {
return false;
};
// Parse the server list from the stored JSON
let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else {
return false;
};
// Get the origin of the navigation URL (scheme + host + port)
let url_origin = format!(
"{}://{}{}",
url.scheme(),
url.host_str().unwrap_or(""),
url.port().map(|p| format!(":{}", p)).unwrap_or_default()
);
// Check if any configured server matches the URL's origin
for server in list {
let Some(server_url) = server.as_str() else {
continue;
};
// Parse the server URL to extract its origin
let Ok(parsed) = tauri::Url::parse(server_url) else {
continue;
};
let server_origin = format!(
"{}://{}{}",
parsed.scheme(),
parsed.host_str().unwrap_or(""),
parsed.port().map(|p| format!(":{}", p)).unwrap_or_default()
);
if url_origin == server_origin {
return true;
}
}
false
}
#[tauri::command] #[tauri::command]
fn kill_sidecar(app: AppHandle) { fn kill_sidecar(app: AppHandle) {
@@ -35,7 +114,7 @@ fn kill_sidecar(app: AppHandle) {
}; };
let Some(server_state) = server_state let Some(server_state) = server_state
.0 .child
.lock() .lock()
.expect("Failed to acquire mutex lock") .expect("Failed to acquire mutex lock")
.take() .take()
@@ -49,25 +128,6 @@ fn kill_sidecar(app: AppHandle) {
println!("Killed server"); println!("Killed server");
} }
#[tauri::command]
async fn copy_logs_to_clipboard(app: AppHandle) -> Result<(), String> {
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
let logs = log_state
.0
.lock()
.map_err(|_| "Failed to acquire log lock")?;
let log_text = logs.iter().cloned().collect::<Vec<_>>().join("");
app.clipboard()
.write_text(log_text)
.map_err(|e| format!("Failed to copy to clipboard: {}", e))?;
Ok(())
}
#[tauri::command]
async fn get_logs(app: AppHandle) -> Result<String, String> { async fn get_logs(app: AppHandle) -> Result<String, String> {
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?; let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
@@ -79,6 +139,15 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
Ok(logs.iter().cloned().collect::<Vec<_>>().join("")) Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
} }
#[tauri::command]
async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
state
.status
.clone()
.await
.map_err(|_| "Failed to get server status".to_string())?
}
fn get_sidecar_port() -> u32 { fn get_sidecar_port() -> u32 {
option_env!("OPENCODE_PORT") option_env!("OPENCODE_PORT")
.map(|s| s.to_string()) .map(|s| s.to_string())
@@ -130,7 +199,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
.args([ .args([
"-il", "-il",
"-c", "-c",
&format!("{} serve --port={}", sidecar.display(), port), &format!("\"{}\" serve --port={}", sidecar.display(), port),
]) ])
.spawn() .spawn()
.expect("Failed to spawn opencode") .expect("Failed to spawn opencode")
@@ -209,9 +278,8 @@ pub fn run() {
.plugin(PinchZoomDisablePlugin) .plugin(PinchZoomDisablePlugin)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
kill_sidecar, kill_sidecar,
copy_logs_to_clipboard, install_cli,
get_logs, ensure_server_started
install_cli
]) ])
.setup(move |app| { .setup(move |app| {
let app = app.handle().clone(); let app = app.handle().clone();
@@ -219,94 +287,110 @@ pub fn run() {
// Initialize log state // Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new())))); app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
// Get port and create window immediately for faster perceived startup
let port = get_sidecar_port();
let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080));
// Create window immediately with serverReady = false
let app_for_nav = app.clone();
let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode")
.inner_size(size.width as f64, size.height as f64)
.decorations(true)
.zoom_hotkeys_enabled(true)
.disable_drag_drop_handler()
.on_navigation(move |url| {
// Allow internal navigation (tauri:// scheme)
if url.scheme() == "tauri" {
return true;
}
// Allow navigation to configured servers (localhost, 127.0.0.1, or remote)
if is_allowed_server(&app_for_nav, url) {
return true;
}
// Open external http/https URLs in default browser
if url.scheme() == "http" || url.scheme() == "https" {
let _ = app_for_nav.shell().open(url.as_str(), None);
return false; // Cancel internal navigation
}
true
})
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port};
"#
));
#[cfg(target_os = "macos")]
{ {
let app = app.clone(); window_builder = window_builder
tauri::async_runtime::spawn(async move { .title_bar_style(tauri::TitleBarStyle::Overlay)
let port = get_sidecar_port(); .hidden_title(true);
}
let should_spawn_sidecar = !is_server_running(port).await; let window = window_builder.build().expect("Failed to create window");
let child = if should_spawn_sidecar { let (tx, rx) = tokio::sync::oneshot::channel();
let child = spawn_sidecar(&app, port); app.manage(ServerState::new(None, rx));
let timestamp = Instant::now(); {
loop { let app = app.clone();
if timestamp.elapsed() > Duration::from_secs(7) { tauri::async_runtime::spawn(async move {
let res = app.dialog() let should_spawn_sidecar = !is_server_running(port).await;
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
.title("Startup Failed")
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
.blocking_show_with_result();
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") { let (child, res) = if should_spawn_sidecar {
match copy_logs_to_clipboard(app.clone()).await { let child = spawn_sidecar(&app, port);
Ok(()) => println!("Logs copied to clipboard successfully"),
Err(e) => println!("Failed to copy logs to clipboard: {}", e),
}
}
app.exit(1); let timestamp = Instant::now();
let res = loop {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
}
return; tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(10)).await; if is_server_running(port).await {
// give the server a little bit more time to warm up
tokio::time::sleep(Duration::from_millis(10)).await;
if is_server_running(port).await { break Ok(());
// give the server a little bit more time to warm up }
tokio::time::sleep(Duration::from_millis(10)).await; };
break; println!("Server ready after {:?}", timestamp.elapsed());
}
}
println!("Server ready after {:?}", timestamp.elapsed()); (Some(child), res)
} else {
(None, Ok(()))
};
Some(child) app.state::<ServerState>().set_child(child);
} else {
None
};
let primary_monitor = app.primary_monitor().ok().flatten(); if res.is_ok() {
let size = primary_monitor let _ = window.eval("window.__OPENCODE__.serverReady = true;");
.map(|m| m.size().to_logical(m.scale_factor())) }
.unwrap_or(LogicalSize::new(1920, 1080));
let mut window_builder = let _ = tx.send(res);
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) });
.title("OpenCode")
.inner_size(size.width as f64, size.height as f64)
.decorations(true)
.zoom_hotkeys_enabled(true)
.disable_drag_drop_handler()
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port};
"#
));
#[cfg(target_os = "macos")]
{
window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
}
window_builder.build().expect("Failed to create window");
app.manage(ServerState(Arc::new(Mutex::new(child))));
});
} }
{ {
let app = app.clone(); let app = app.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
if let Err(e) = sync_cli(app) { if let Err(e) = sync_cli(app) {
eprintln!("Failed to sync CLI: {e}"); eprintln!("Failed to sync CLI: {e}");
} }
}); });
} }
Ok(()) Ok(())
+149 -48
View File
@@ -1,21 +1,24 @@
// @refresh reload // @refresh reload
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { App, PlatformProvider, Platform } from "@opencode-ai/app" import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog" import { open, save } from "@tauri-apps/plugin-dialog"
import { open as shellOpen } from "@tauri-apps/plugin-shell" import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os" import { type as ostype } from "@tauri-apps/plugin-os"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
import { check, Update } from "@tauri-apps/plugin-updater" import { check, Update } from "@tauri-apps/plugin-updater"
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { getCurrentWindow } from "@tauri-apps/api/window" import { getCurrentWindow } from "@tauri-apps/api/window"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process" import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo"
import { Suspense, createResource, ParentProps } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
import pkg from "../package.json" import pkg from "../package.json"
import { Show } from "solid-js"
const root = document.getElementById("root") const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) { if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -60,7 +63,7 @@ const platform: Platform = {
void shellOpen(url).catch(() => undefined) void shellOpen(url).catch(() => undefined)
}, },
storage: (name = "default.dat") => { storage: (() => {
type StoreLike = { type StoreLike = {
get(key: string): Promise<string | null | undefined> get(key: string): Promise<string | null | undefined>
set(key: string, value: string): Promise<unknown> set(key: string, value: string): Promise<unknown>
@@ -70,7 +73,13 @@ const platform: Platform = {
length(): Promise<number> length(): Promise<number>
} }
const memory = () => { const WRITE_DEBOUNCE_MS = 250
const storeCache = new Map<string, Promise<StoreLike>>()
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
const memoryCache = new Map<string, StoreLike>()
const createMemoryStore = () => {
const data = new Map<string, string>() const data = new Map<string, string>()
const store: StoreLike = { const store: StoreLike = {
get: async (key) => data.get(key), get: async (key) => data.get(key),
@@ -89,45 +98,108 @@ const platform: Platform = {
return store return store
} }
const api: AsyncStorage & { _store: Promise<StoreLike> | null; _getStore: () => Promise<StoreLike> } = { const getStore = (name: string) => {
_store: null, const cached = storeCache.get(name)
_getStore: async () => { if (cached) return cached
if (api._store) return api._store
api._store = Store.load(name).catch(() => memory()) const store = Store.load(name).catch(() => {
return api._store const cached = memoryCache.get(name)
}, if (cached) return cached
getItem: async (key: string) => {
const store = await api._getStore() const memory = createMemoryStore()
const value = await store.get(key).catch(() => null) memoryCache.set(name, memory)
if (value === undefined) return null return memory
return value })
},
setItem: async (key: string, value: string) => { storeCache.set(name, store)
const store = await api._getStore() return store
await store.set(key, value).catch(() => undefined)
},
removeItem: async (key: string) => {
const store = await api._getStore()
await store.delete(key).catch(() => undefined)
},
clear: async () => {
const store = await api._getStore()
await store.clear().catch(() => undefined)
},
key: async (index: number) => {
const store = await api._getStore()
return (await store.keys().catch(() => []))[index]
},
getLength: async () => {
const store = await api._getStore()
return await store.length().catch(() => 0)
},
get length() {
return api.getLength()
},
} }
return api
}, const createStorage = (name: string) => {
const pending = new Map<string, string | null>()
let timer: ReturnType<typeof setTimeout> | undefined
let flushing: Promise<void> | undefined
const flush = async () => {
if (flushing) return flushing
flushing = (async () => {
const store = await getStore(name)
while (pending.size > 0) {
const batch = Array.from(pending.entries())
pending.clear()
for (const [key, value] of batch) {
if (value === null) {
await store.delete(key).catch(() => undefined)
} else {
await store.set(key, value).catch(() => undefined)
}
}
}
})().finally(() => {
flushing = undefined
})
return flushing
}
const schedule = () => {
if (timer) return
timer = setTimeout(() => {
timer = undefined
void flush()
}, WRITE_DEBOUNCE_MS)
}
const api: AsyncStorage & { flush: () => Promise<void> } = {
flush,
getItem: async (key: string) => {
const next = pending.get(key)
if (next !== undefined) return next
const store = await getStore(name)
const value = await store.get(key).catch(() => null)
if (value === undefined) return null
return value
},
setItem: async (key: string, value: string) => {
pending.set(key, value)
schedule()
},
removeItem: async (key: string) => {
pending.set(key, null)
schedule()
},
clear: async () => {
pending.clear()
const store = await getStore(name)
await store.clear().catch(() => undefined)
},
key: async (index: number) => {
const store = await getStore(name)
return (await store.keys().catch(() => []))[index]
},
getLength: async () => {
const store = await getStore(name)
return await store.length().catch(() => 0)
},
get length() {
return api.getLength()
},
}
return api
}
return (name = "default.dat") => {
const cached = apiCache.get(name)
if (cached) return cached
const api = createStorage(name)
apiCache.set(name, api)
return api
}
})(),
checkUpdate: async () => { checkUpdate: async () => {
if (!UPDATER_ENABLED) return { updateAvailable: false } if (!UPDATER_ENABLED) return { updateAvailable: false }
@@ -200,7 +272,36 @@ render(() => {
{ostype() === "macos" && ( {ostype() === "macos" && (
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region /> <div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)} )}
<App /> <AppBaseProviders>
<ServerGate>
<AppInterface />
</ServerGate>
</AppBaseProviders>
</PlatformProvider> </PlatformProvider>
) )
}, root!) }, root!)
// Gate component that waits for the server to be ready
function ServerGate(props: ParentProps) {
const [status] = createResource(async () => {
if (window.__OPENCODE__?.serverReady) return
return await invoke("ensure_server_started")
})
return (
// Not using suspense as not all components are compatible with it (undefined refs)
<Show
when={status.state !== "pending"}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Logo class="w-xl opacity-12 animate-pulse" />
<div class="mt-8 text-14-regular text-text-weak">Starting server...</div>
</div>
}
>
{/* Trigger error boundary without rendering the returned value */}
{(status(), null)}
{props.children}
</Show>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.4", "version": "1.1.7",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+4
View File
@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite" "type": "sst.cloudflare.StaticSite"
"url": string "url": string
} }
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": { "ZEN_MODELS1": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
+6 -6
View File
@@ -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.4" version = "1.1.7"
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.4/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/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.4/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/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.4/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/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.4/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/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.4/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.4", "version": "1.1.7",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+4
View File
@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite" "type": "sst.cloudflare.StaticSite"
"url": string "url": string
} }
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": { "ZEN_MODELS1": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.1.4", "version": "1.1.7",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -72,7 +72,7 @@
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.15.1", "@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
+1 -1
View File
@@ -135,7 +135,7 @@ for (const item of targets) {
autoloadPackageJson: true, autoloadPackageJson: true,
target: name.replace(pkg.name, "bun") as any, target: name.replace(pkg.name, "bun") as any,
outfile: `dist/${name}/bin/opencode`, outfile: `dist/${name}/bin/opencode`,
execArgv: [`--user-agent=opencode/${Script.version}`, "--"], execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"],
windows: {}, windows: {},
}, },
entrypoints: ["./src/index.ts", parserWorker, workerPath], entrypoints: ["./src/index.ts", parserWorker, workerPath],
+12
View File
@@ -167,6 +167,8 @@ 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,
}, },
@@ -242,6 +244,7 @@ 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,
@@ -260,6 +263,9 @@ 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",
@@ -491,6 +497,8 @@ 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,
}, },
@@ -566,6 +574,7 @@ 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,
@@ -584,6 +593,9 @@ 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",
+34 -3
View File
@@ -4,6 +4,7 @@ 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"
@@ -46,7 +47,11 @@ export namespace Agent {
const defaults = PermissionNext.fromConfig({ const defaults = PermissionNext.fromConfig({
"*": "allow", "*": "allow",
doom_loop: "ask", doom_loop: "ask",
external_directory: "ask", external_directory: {
"*": "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",
@@ -61,7 +66,13 @@ export namespace Agent {
build: { build: {
name: "build", name: "build",
options: {}, options: {},
permission: PermissionNext.merge(defaults, user), permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
question: "allow",
}),
user,
),
mode: "primary", mode: "primary",
native: true, native: true,
}, },
@@ -71,6 +82,7 @@ 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",
@@ -95,7 +107,6 @@ export namespace Agent {
options: {}, options: {},
mode: "subagent", mode: "subagent",
native: true, native: true,
hidden: true,
}, },
explore: { explore: {
name: "explore", name: "explore",
@@ -111,6 +122,9 @@ export namespace Agent {
websearch: "allow", websearch: "allow",
codesearch: "allow", codesearch: "allow",
read: "allow", read: "allow",
external_directory: {
[Truncate.DIR]: "allow",
},
}), }),
user, user,
), ),
@@ -141,6 +155,7 @@ export namespace Agent {
options: {}, options: {},
native: true, native: true,
hidden: true, hidden: true,
temperature: 0.5,
permission: PermissionNext.merge( permission: PermissionNext.merge(
defaults, defaults,
PermissionNext.fromConfig({ PermissionNext.fromConfig({
@@ -188,11 +203,27 @@ export namespace Agent {
item.topP = value.top_p ?? item.topP item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps item.steps = value.steps ?? item.steps
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
}) })
+11 -4
View File
@@ -12,8 +12,11 @@ Your output must be:
</task> </task>
<rules> <rules>
- Title must be grammatically correct and read naturally - no word salad
- Never include tool names in the title (e.g. "read tool", "bash tool", "edit tool")
- Focus on the main topic or question the user needs to retrieve - Focus on the main topic or question the user needs to retrieve
- Use -ing verbs for actions (Debugging, Implementing, Analyzing) - Vary your phrasing - avoid repetitive patterns like always starting with "Analyzing"
- When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it
- Keep exact: technical terms, numbers, filenames, HTTP codes - Keep exact: technical terms, numbers, filenames, HTTP codes
- Remove: the, this, my, a, an - Remove: the, this, my, a, an
- Never assume tech stack - Never assume tech stack
@@ -29,8 +32,12 @@ Your output must be:
<examples> <examples>
"debug 500 errors in production" → Debugging production 500 errors "debug 500 errors in production" → Debugging production 500 errors
"refactor user service" → Refactoring user service "refactor user service" → Refactoring user service
"why is app.js failing" → Analyzing app.js failure "why is app.js failing" → app.js failure investigation
"implement rate limiting" → Implementing rate limiting "implement rate limiting" → Rate limiting implementation
"how do I connect postgres to my API" → Connecting Postgres to API "how do I connect postgres to my API" → Postgres API connection
"best practices for React hooks" → React hooks best practices "best practices for React hooks" → React hooks best practices
"@src/auth.ts can you add refresh token support" → Auth refresh token support
"@utils/parser.ts this is broken" → Parser bug fix
"look at @config.json" → Config review
"@App.tsx add dark mode toggle" → Dark mode toggle in App
</examples> </examples>
@@ -6,6 +6,7 @@ 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,6 +5,7 @@ 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,6 +6,7 @@ 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",
@@ -22,6 +23,7 @@ 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",
@@ -38,6 +40,7 @@ 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 () => {
@@ -49,6 +52,7 @@ 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",
@@ -65,6 +69,7 @@ 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",
@@ -79,6 +84,7 @@ 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,6 +12,7 @@ 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)
@@ -25,6 +26,7 @@ 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))
@@ -37,6 +39,7 @@ 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,6 +6,7 @@ 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() {},
@@ -13,6 +14,7 @@ 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 () => {
@@ -25,6 +27,7 @@ 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 () => {
@@ -37,6 +40,7 @@ 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,12 +6,14 @@ 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",
@@ -25,6 +27,7 @@ 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", {
@@ -56,6 +59,7 @@ 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,6 +5,7 @@ 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,6 +5,7 @@ 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,12 +4,14 @@ 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())
@@ -19,6 +21,7 @@ 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",
@@ -34,6 +37,7 @@ 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",
+9 -1
View File
@@ -515,7 +515,15 @@ 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
+22 -1
View File
@@ -292,7 +292,28 @@ export const RunCommand = cmd({
: args.title : args.title
: undefined : undefined
const result = await sdk.session.create(title ? { title } : {}) const result = await sdk.session.create(
title
? {
title,
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}
: {
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
},
)
return result.data?.id return result.data?.id
})() })()
+10 -2
View File
@@ -653,9 +653,17 @@ 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") {
props.onExit() handleExit()
} }
}) })
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
@@ -708,7 +716,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={props.onExit} backgroundColor={colors.primary} padding={1}> <box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Exit</text> <text fg={colors.bg}>Exit</text>
</box> </box>
</box> </box>
+1 -2
View File
@@ -22,11 +22,10 @@ export const AttachCommand = cmd({
}), }),
handler: async (args) => { handler: async (args) => {
if (args.dir) process.chdir(args.dir) if (args.dir) process.chdir(args.dir)
const directory = process.cwd()
await tui({ await tui({
url: args.url, url: args.url,
args: { sessionID: args.session }, args: { sessionID: args.session },
directory, directory: args.dir ? process.cwd() : undefined,
}) })
}, },
}) })
@@ -33,76 +33,82 @@ export function DialogModel(props: { providerID?: string }) {
const options = createMemo(() => { const options = createMemo(() => {
const q = query() const q = query()
const favorites = showExtra() ? local.model.favorite() : [] const needle = q.trim()
const showSections = showExtra() && needle.length === 0
const favorites = connected() ? local.model.favorite() : []
const recents = local.model.recent() const recents = local.model.recent()
const recentList = showExtra() const recentList = showSections
? 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 = favorites.flatMap((item) => { const favoriteOptions = showSections
const provider = sync.data.provider.find((x) => x.id === item.providerID) ? favorites.flatMap((item) => {
if (!provider) return [] const provider = sync.data.provider.find((x) => x.id === item.providerID)
const model = provider.models[item.modelID] if (!provider) return []
if (!model) return [] const model = provider.models[item.modelID]
return [ if (!model) return []
{ return [
key: item, {
value: { key: item,
providerID: provider.id, value: {
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,
}, },
{ recent: true }, title: model.name ?? item.modelID,
) description: provider.name,
}, category: "Favorites",
}, disabled: provider.id === "opencode" && model.id.includes("-nano"),
] footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
}) onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []
const recentOptions = recentList.flatMap((item) => { const recentOptions = showSections
const provider = sync.data.provider.find((x) => x.id === item.providerID) ? recentList.flatMap((item) => {
if (!provider) return [] const provider = sync.data.provider.find((x) => x.id === item.providerID)
const model = provider.models[item.modelID] if (!provider) return []
if (!model) return [] const model = provider.models[item.modelID]
return [ if (!model) return []
{ return [
key: item, {
value: { key: item,
providerID: provider.id, value: {
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,
}, },
{ recent: true }, title: model.name ?? item.modelID,
) description: provider.name,
}, category: "Recent",
}, disabled: provider.id === "opencode" && model.id.includes("-nano"),
] footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
}) onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []
const providerOptions = pipe( const providerOptions = pipe(
sync.data.provider, sync.data.provider,
@@ -145,6 +151,7 @@ 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,
@@ -177,16 +184,11 @@ export function DialogModel(props: { providerID?: string }) {
) )
: [] : []
// Apply fuzzy filtering to each section separately, maintaining section order // Search shows a single merged list (favorites inline)
if (q) { if (needle) {
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj) const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredRecents = fuzzysort const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj)
.go(q, recentOptions, { keys: ["title"] }) return [...filteredProviders, ...filteredPopular]
.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,6 +53,7 @@ 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
@@ -221,6 +222,7 @@ 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: () => {
@@ -259,8 +261,10 @@ 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(`${res.name} (${res.uri})`, width), display: Locale.truncateMiddle(text, width),
value: text,
description: res.description, description: res.description,
onSelect: () => { onSelect: () => {
insertPart(res.name, { insertPart(res.name, {
@@ -485,7 +489,11 @@ export function Autocomplete(props: {
} }
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, { const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""], keys: [
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
"description",
(obj) => obj.aliases?.join(" ") ?? "",
],
limit: 10, limit: 10,
scoreFn: (objResults) => { scoreFn: (objResults) => {
const displayResult = objResults[0] const displayResult = objResults[0]
@@ -653,8 +661,10 @@ export function Autocomplete(props: {
}) })
const height = createMemo(() => { const height = createMemo(() => {
if (options().length) return Math.min(10, options().length) const count = options().length || 1
return 1 if (!store.visible) return Math.min(10, count)
positionTick()
return Math.min(10, count, Math.max(1, props.anchor().y))
}) })
let scroll: ScrollBoxRenderable let scroll: ScrollBoxRenderable
@@ -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]) setKvStore(name, defaultValue) if (kvStore[name] === undefined) setKvStore(name, defaultValue)
return [ return [
function () { function () {
return result.get(name) return result.get(name)
@@ -75,9 +75,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
}, },
color(name: string) { color(name: string) {
const agent = agents().find((x) => x.name === name) const all = sync.data.agent
const agent = all.find((x) => x.name === name)
if (agent?.color) return RGBA.fromHex(agent.color) if (agent?.color) return RGBA.fromHex(agent.color)
const index = agents().findIndex((x) => x.name === name) const index = all.findIndex((x) => x.name === name)
if (index === -1) return colors()[0] if (index === -1) return colors()[0]
return colors()[index % colors().length] return colors()[index % colors().length]
}, },
@@ -8,6 +8,7 @@ import type {
Todo, Todo,
Command, Command,
PermissionRequest, PermissionRequest,
QuestionRequest,
LspStatus, LspStatus,
McpStatus, McpStatus,
McpResource, McpResource,
@@ -42,6 +43,9 @@ 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: {
@@ -80,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
status: "loading", status: "loading",
agent: [], agent: [],
permission: {}, permission: {},
question: {},
command: [], command: [],
provider: [], provider: [],
provider_default: {}, provider_default: {},
@@ -142,6 +147,44 @@ 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,15 +102,16 @@ type Theme = ThemeColors & {
thinkingOpacity: number thinkingOpacity: number
} }
export function selectedForeground(theme: Theme): RGBA { export function selectedForeground(theme: Theme, bg?: RGBA): 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 primary color // For transparent backgrounds, calculate contrast based on the actual bg (or fallback to primary)
if (theme.background.a === 0) { if (theme.background.a === 0) {
const { r, g, b } = theme.primary const targetColor = bg ?? 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)
} }
@@ -288,11 +289,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
createEffect(() => { createEffect(() => {
const theme = sync.data.config.theme const theme = sync.data.config.theme
console.log("theme", theme)
if (theme) setStore("active", theme) if (theme) setStore("active", theme)
}) })
createEffect(() => { function init() {
resolveSystemTheme()
getCustomThemes() getCustomThemes()
.then((custom) => { .then((custom) => {
setStore( setStore(
@@ -309,15 +310,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("ready", true) setStore("ready", true)
} }
}) })
}) }
onMount(init)
function resolveSystemTheme() { function resolveSystemTheme() {
console.log("resolved system theme") console.log("resolveSystemTheme")
renderer renderer
.getPalette({ .getPalette({
size: 16, size: 16,
}) })
.then((colors) => { .then((colors) => {
console.log(colors.palette)
if (!colors.palette[0]) { if (!colors.palette[0]) {
if (store.active === "system") { if (store.active === "system") {
setStore( setStore(
@@ -341,11 +345,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
} }
const renderer = useRenderer() const renderer = useRenderer()
resolveSystemTheme() process.on("SIGUSR2", async () => {
renderer.clearPaletteCache()
const sdk = useSDK() init()
sdk.event.on("server.instance.disposed", () => {
resolveSystemTheme()
}) })
const values = createMemo(() => { const values = createMemo(() => {
@@ -61,6 +61,10 @@
"dark": "darkStep11", "dark": "darkStep11",
"light": "lightStep11" "light": "lightStep11"
}, },
"selectedListItemText": {
"dark": "#0a0a0a",
"light": "#ffffff"
},
"background": { "background": {
"dark": "transparent", "dark": "transparent",
"light": "transparent" "light": "transparent"
@@ -77,6 +77,10 @@
"dark": "darkStep11", "dark": "darkStep11",
"light": "lightStep11" "light": "lightStep11"
}, },
"selectedListItemText": {
"dark": "#0a0a0a",
"light": "#ffffff"
},
"background": { "background": {
"dark": "darkStep1", "dark": "darkStep1",
"light": "lightStep1" "light": "lightStep1"
@@ -3,9 +3,8 @@ 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, EmptyBorder } from "@tui/component/border" import { SplitBorder } 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> }) => {
@@ -33,7 +32,6 @@ 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(
@@ -99,24 +97,6 @@ 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,4 +1,5 @@
import { import {
batch,
createContext, createContext,
createEffect, createEffect,
createMemo, createMemo,
@@ -23,6 +24,7 @@ import {
MacOSScrollAccel, MacOSScrollAccel,
type ScrollAcceleration, type ScrollAcceleration,
TextAttributes, TextAttributes,
RGBA,
} from "@opentui/core" } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt" import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
@@ -40,6 +42,7 @@ 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"
@@ -50,7 +53,6 @@ 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"
@@ -68,6 +70,7 @@ 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"
@@ -89,7 +92,6 @@ 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>
@@ -117,9 +119,13 @@ 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 sync.data.permission[route.sessionID] ?? [] if (session()?.parentID) return []
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
@@ -130,24 +136,25 @@ export function Session() {
}) })
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto")) const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "hide")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [conceal, setConceal] = createSignal(true) const [conceal, setConceal] = createSignal(true)
const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true)) const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show") const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true)) const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true)) const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true)) const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
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] = createSignal(kv.get("animations_enabled", true)) const [animationsEnabled, setAnimationsEnabled] = kv.signal("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 (sidebar() === "show") return true if (sidebarOpen()) 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(() => {
@@ -448,26 +455,10 @@ export function Session() {
keybind: "sidebar_toggle", keybind: "sidebar_toggle",
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: (dialog) => {
setSidebar((prev) => { batch(() => {
if (prev === "auto") return sidebarVisible() ? "hide" : "show" const isVisible = sidebarVisible()
if (prev === "show") return "hide" setSidebar(() => (isVisible ? "hide" : "auto"))
return "show" setSidebarOpen(!isVisible)
})
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()
}, },
@@ -487,11 +478,7 @@ export function Session() {
value: "session.toggle.timestamps", value: "session.toggle.timestamps",
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: (dialog) => {
setShowTimestamps((prev) => { setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
const next = !prev
kv.set("timestamps", next ? "show" : "hide")
return next
})
dialog.clear() dialog.clear()
}, },
}, },
@@ -500,11 +487,7 @@ export function Session() {
value: "session.toggle.thinking", value: "session.toggle.thinking",
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: (dialog) => {
setShowThinking((prev) => { setShowThinking((prev) => !prev)
const next = !prev
kv.set("thinking_visibility", next)
return next
})
dialog.clear() dialog.clear()
}, },
}, },
@@ -523,9 +506,7 @@ export function Session() {
keybind: "tool_details", keybind: "tool_details",
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: (dialog) => {
const newValue = !showDetails() setShowDetails((prev) => !prev)
setShowDetails(newValue)
kv.set("tool_details_visibility", newValue)
dialog.clear() dialog.clear()
}, },
}, },
@@ -535,11 +516,7 @@ export function Session() {
keybind: "scrollbar_toggle", keybind: "scrollbar_toggle",
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: (dialog) => {
setShowScrollbar((prev) => { setShowScrollbar((prev) => !prev)
const next = !prev
kv.set("scrollbar_visible", next)
return next
})
dialog.clear() dialog.clear()
}, },
}, },
@@ -548,11 +525,7 @@ export function Session() {
value: "session.toggle.animations", value: "session.toggle.animations",
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: (dialog) => {
setAnimationsEnabled((prev) => { setAnimationsEnabled((prev) => !prev)
const next = !prev
kv.set("animations_enabled", next)
return next
})
dialog.clear() dialog.clear()
}, },
}, },
@@ -906,7 +879,6 @@ export function Session() {
conceal, conceal,
showThinking, showThinking,
showTimestamps, showTimestamps,
usernameVisible,
showDetails, showDetails,
diffWrapMode, diffWrapMode,
sync, sync,
@@ -915,7 +887,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()}> <Show when={!sidebarVisible() || !wide()}>
<Header /> <Header />
</Show> </Show>
<scrollbox <scrollbox
@@ -1036,27 +1008,48 @@ 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} visible={!session()?.parentID && permissions().length === 0 && questions().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} disabled={permissions().length > 0 || questions().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()}>
<Sidebar sessionID={route.sessionID} /> <Switch>
<Match when={wide()}>
<Sidebar sessionID={route.sessionID} />
</Match>
<Match when={!wide()}>
<box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
alignItems="flex-end"
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
>
<Sidebar sessionID={route.sessionID} />
</box>
</Match>
</Switch>
</Show> </Show>
</box> </box>
</context.Provider> </context.Provider>
@@ -1089,6 +1082,7 @@ 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"))
@@ -1118,7 +1112,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={1} paddingTop={1} gap={1} flexWrap="wrap"> <box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}> <For each={files()}>
{(file) => { {(file) => {
const bg = createMemo(() => { const bg = createMemo(() => {
@@ -1136,23 +1130,22 @@ function UserMessage(props: {
</For> </For>
</box> </box>
</Show> </Show>
<text fg={theme.textMuted}> <Show
{ctx.usernameVisible() ? `${sync.data.config.username ?? "You "}` : "You "} when={queued()}
<Show fallback={
when={queued()} <Show when={ctx.showTimestamps()}>
fallback={ <text fg={theme.textMuted}>
<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>
</Show> </text>
} </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>
</Show> </text>
</text> </Show>
</box> </box>
</box> </box>
</Show> </Show>
@@ -1310,8 +1303,16 @@ 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 ?? {})
@@ -1336,50 +1337,55 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
} }
return ( return (
<Switch> <Show when={!shouldHide()}>
<Match when={props.part.tool === "bash"}> <Switch>
<Bash {...toolprops} /> <Match when={props.part.tool === "bash"}>
</Match> <Bash {...toolprops} />
<Match when={props.part.tool === "glob"}> </Match>
<Glob {...toolprops} /> <Match when={props.part.tool === "glob"}>
</Match> <Glob {...toolprops} />
<Match when={props.part.tool === "read"}> </Match>
<Read {...toolprops} /> <Match when={props.part.tool === "read"}>
</Match> <Read {...toolprops} />
<Match when={props.part.tool === "grep"}> </Match>
<Grep {...toolprops} /> <Match when={props.part.tool === "grep"}>
</Match> <Grep {...toolprops} />
<Match when={props.part.tool === "list"}> </Match>
<List {...toolprops} /> <Match when={props.part.tool === "list"}>
</Match> <List {...toolprops} />
<Match when={props.part.tool === "webfetch"}> </Match>
<WebFetch {...toolprops} /> <Match when={props.part.tool === "webfetch"}>
</Match> <WebFetch {...toolprops} />
<Match when={props.part.tool === "codesearch"}> </Match>
<CodeSearch {...toolprops} /> <Match when={props.part.tool === "codesearch"}>
</Match> <CodeSearch {...toolprops} />
<Match when={props.part.tool === "websearch"}> </Match>
<WebSearch {...toolprops} /> <Match when={props.part.tool === "websearch"}>
</Match> <WebSearch {...toolprops} />
<Match when={props.part.tool === "write"}> </Match>
<Write {...toolprops} /> <Match when={props.part.tool === "write"}>
</Match> <Write {...toolprops} />
<Match when={props.part.tool === "edit"}> </Match>
<Edit {...toolprops} /> <Match when={props.part.tool === "edit"}>
</Match> <Edit {...toolprops} />
<Match when={props.part.tool === "task"}> </Match>
<Task {...toolprops} /> <Match when={props.part.tool === "task"}>
</Match> <Task {...toolprops} />
<Match when={props.part.tool === "patch"}> </Match>
<Patch {...toolprops} /> <Match when={props.part.tool === "patch"}>
</Match> <Patch {...toolprops} />
<Match when={props.part.tool === "todowrite"}> </Match>
<TodoWrite {...toolprops} /> <Match when={props.part.tool === "todowrite"}>
</Match> <TodoWrite {...toolprops} />
<Match when={true}> </Match>
<GenericTool {...toolprops} /> <Match when={props.part.tool === "question"}>
</Match> <Question {...toolprops} />
</Switch> </Match>
<Match when={true}>
<GenericTool {...toolprops} />
</Match>
</Switch>
</Show>
) )
} }
@@ -1410,7 +1416,14 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children:
) )
} }
function InlineTool(props: { icon: string; complete: any; pending: string; children: JSX.Element; part: ToolPart }) { function InlineTool(props: {
icon: string
iconColor?: RGBA
complete: any
pending: string
children: JSX.Element
part: ToolPart
}) {
const [margin, setMargin] = createSignal(0) const [margin, setMargin] = createSignal(0)
const { theme } = useTheme() const { theme } = useTheme()
const ctx = use() const ctx = use()
@@ -1430,7 +1443,12 @@ function InlineTool(props: { icon: string; complete: any; pending: string; child
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(() => error()?.includes("rejected permission") || error()?.includes("specified a rule")) const denied = createMemo(
() =>
error()?.includes("rejected permission") ||
error()?.includes("specified a rule") ||
error()?.includes("user dismissed"),
)
return ( return (
<box <box
@@ -1461,7 +1479,7 @@ function InlineTool(props: { icon: string; complete: any; pending: string; child
> >
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}> <text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}> <Show fallback={<>~ {props.pending}</>} when={props.complete}>
<span style={{ bold: true }}>{props.icon}</span> {props.children} <span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
</Show> </Show>
</text> </text>
<Show when={error() && !denied()}> <Show when={error() && !denied()}>
@@ -1506,15 +1524,30 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
} }
function Bash(props: ToolProps<typeof BashTool>) { function Bash(props: ToolProps<typeof BashTool>) {
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const { theme } = useTheme() const { theme } = useTheme()
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
})
return ( return (
<Switch> <Switch>
<Match when={props.metadata.output !== undefined}> <Match when={props.metadata.output !== undefined}>
<BlockTool title={"# " + (props.input.description ?? "Shell")} part={props.part}> <BlockTool
title={"# " + (props.input.description ?? "Shell")}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}> <box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text> <text fg={theme.text}>$ {props.input.command}</text>
<text fg={theme.text}>{output()}</text> <text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box> </box>
</BlockTool> </BlockTool>
</Match> </Match>
@@ -1644,8 +1677,10 @@ function Task(props: ToolProps<typeof TaskTool>) {
const { theme } = useTheme() const { theme } = useTheme()
const keybind = useKeybind() const keybind = useKeybind()
const { navigate } = useRoute() const { navigate } = useRoute()
const local = useLocal()
const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending")) const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending"))
const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown"))
return ( return (
<Switch> <Switch>
@@ -1679,11 +1714,13 @@ function Task(props: ToolProps<typeof TaskTool>) {
<Match when={true}> <Match when={true}>
<InlineTool <InlineTool
icon="◉" icon="◉"
iconColor={color()}
pending="Delegating..." pending="Delegating..."
complete={props.input.subagent_type ?? props.input.description} complete={props.input.subagent_type ?? props.input.description}
part={props.part} part={props.part}
> >
{Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}" <span style={{ fg: theme.text }}>{Locale.titlecase(props.input.subagent_type ?? "unknown")}</span> Task "
{props.input.description}"
</InlineTool> </InlineTool>
</Match> </Match>
</Switch> </Switch>
@@ -1800,6 +1837,40 @@ 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 } from "../../context/theme" import { useTheme, selectedForeground } 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 ? theme.selectedListItemText : theme.textMuted}> <text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
{props.options[option]} {props.options[option]}
</text> </text>
</box> </box>
@@ -0,0 +1,368 @@
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>
)
}

Some files were not shown because too many files have changed in this diff Show More