Compare commits
211 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 314f7c56e7 | |||
| 58ca434c78 | |||
| 70f14cccd6 | |||
| 86df4073d1 | |||
| 69117fa453 | |||
| dc01071498 | |||
| 57b04d9eb7 | |||
| 07dbc30c63 | |||
| 1ae38c90a3 | |||
| 9609c1803e | |||
| 6e0e87fb2a | |||
| c875d11959 | |||
| 08a83b7337 | |||
| 79a4e35a74 | |||
| 40ed73af17 | |||
| 74da6b1bef | |||
| c35e1a03d1 | |||
| 92d4366a20 | |||
| 17a7c824b8 | |||
| 0befc5d602 | |||
| 8355ee2061 | |||
| 62fed8d2ce | |||
| 6fbe28619c | |||
| 156cc6cffe | |||
| bcd1dddcbe | |||
| 6eaaaffcdd | |||
| 766fa521ea | |||
| ecafa40bcf | |||
| 25f4721c71 | |||
| a433766a31 | |||
| c93d50e8c7 | |||
| 3f879859d7 | |||
| ee62dc0745 | |||
| b7aefa715a | |||
| 796bc390db | |||
| 703ae49675 | |||
| 37c0570a9f | |||
| 4dea0209bb | |||
| bb4b24a05f | |||
| e789abec79 | |||
| 118617473e | |||
| a4beb60e19 | |||
| 3f0f910f7b | |||
| 5bf841ab7a | |||
| 49727e3eab | |||
| 592c6ef97f | |||
| 00579f0ec1 | |||
| 69d516c7fa | |||
| bedeb626b2 | |||
| a4c14dbb2d | |||
| 036b24791d | |||
| 93b71477e6 | |||
| 1357319f6f | |||
| e729eed34d | |||
| 2e5fdd8cef | |||
| 21f15f15c1 | |||
| c6344c5714 | |||
| 7505fa61b9 | |||
| 77bb5af092 | |||
| 0c4fe73cbf | |||
| e132f6183d | |||
| e06ebb6780 | |||
| 66d99ba527 | |||
| f2021a85d6 | |||
| 7d54f893c9 | |||
| e1f80c0067 | |||
| 4ff13d3290 | |||
| 832d8da453 | |||
| b5d61b77f7 | |||
| 790e9947bd | |||
| 2056781cf7 | |||
| ed5f76d849 | |||
| 93102dc84b | |||
| e2920ac262 | |||
| aa5e39e744 | |||
| 296cc41a07 | |||
| 482239b848 | |||
| 17b07877e5 | |||
| dedaa34dc1 | |||
| 5785ded6e2 | |||
| d1876e3031 | |||
| 9fa3e0a0ec | |||
| 47c327641b | |||
| 81583cddbd | |||
| d16ae1fc4e | |||
| 14e00a06b6 | |||
| 5cc44c872e | |||
| cadc5982f1 | |||
| 6aa157cfe6 | |||
| 9fff9a37d0 | |||
| b289fd9dc7 | |||
| 13d4a802ac | |||
| c4ae3e429c | |||
| e9cb360cb7 | |||
| 596d4e4490 | |||
| 45451095f7 | |||
| aae354c951 | |||
| 4cddda3e16 | |||
| 834aa036a4 | |||
| 6d1b6a6fb1 | |||
| e78fe8dcba | |||
| f62d826037 | |||
| 342f4239e4 | |||
| 5141fe8d7d | |||
| 3a9dd306db | |||
| e32b6e2cc2 | |||
| fab0e5de04 | |||
| 61105b487f | |||
| 6f584ec641 | |||
| 3a2b2f13f2 | |||
| be13e71fb9 | |||
| dd0c049119 | |||
| ee2b57958d | |||
| 4178b3c6ae | |||
| 2c2752ee02 | |||
| 5a17f44da4 | |||
| 354e55ecef | |||
| 10735f93ca | |||
| ccaebdcd16 | |||
| 2bbd7a167a | |||
| f12d470b33 | |||
| 0835170224 | |||
| 3530885f48 | |||
| a071a2b7f4 | |||
| b2f2c9ac37 | |||
| 631722213b | |||
| 80b25c79bb | |||
| 3d9c5b4adf | |||
| a3064e2c32 | |||
| 275bc4d2c8 | |||
| 39106718ed | |||
| 02cfdfbf5b | |||
| 1ec71e419b | |||
| 5fbbdcaf64 | |||
| 2b6afe90d0 | |||
| 5f34dcc792 | |||
| 681abcbf2d | |||
| 603e81ef5a | |||
| fb0a200ecf | |||
| 3ec670784d | |||
| e6f3cf0839 | |||
| 9437cf4ff6 | |||
| 7d095d19f6 | |||
| 0ca10ec2f5 | |||
| f03fae03e5 | |||
| bb14a955a0 | |||
| dac1506680 | |||
| 3946a08f40 | |||
| ee0519aacc | |||
| dec1e3fdda | |||
| f54e900716 | |||
| 7e8b5749fa | |||
| febf902dc4 | |||
| 04b51f2610 | |||
| b2a4f57d64 | |||
| 0ce7d92a8b | |||
| 7a67fe7dde | |||
| 00b4670b8b | |||
| 7633a951e6 | |||
| 4ff64c6209 | |||
| 22023fa9e7 | |||
| 85e0b53c33 | |||
| 6eaa231587 | |||
| befb7509de | |||
| 09bf0b86d8 | |||
| b5d45fa9f5 | |||
| a6a633d5c1 | |||
| e83e8001da | |||
| 0386898476 | |||
| 5e777fd2a2 | |||
| 3c71fda648 | |||
| 42329a038a | |||
| 10f3983f0b | |||
| e9de7f95a7 | |||
| a4113acd15 | |||
| 9c8e56fc96 | |||
| c78cb57c41 | |||
| eb15b2ba75 | |||
| 279edb6f24 | |||
| c51a34bf4b | |||
| e8d144d2a2 | |||
| a760e8364f | |||
| fa7cae59c0 | |||
| 8780fa6ccf | |||
| ab2df0ae33 | |||
| 23757f3ac0 | |||
| df7296cfe1 | |||
| 776276d5a4 | |||
| eea45a22fa | |||
| ddacb04f99 | |||
| 09561254a8 | |||
| 73a8356b10 | |||
| 8db75266d0 | |||
| 6c30565d40 | |||
| b223a29603 | |||
| 8ed72ae087 | |||
| 62b8c7aee0 | |||
| 6145dfcca0 | |||
| 4580c88c0b | |||
| 061ba65d20 | |||
| 457386ad08 | |||
| fce04dc48b | |||
| 81534ab387 | |||
| 409a6f93b2 | |||
| 55c294c013 | |||
| 70db372466 | |||
| 8cc427daba | |||
| 8fde772957 | |||
| d8dc23bde9 | |||
| 1c83ef75a2 | |||
| 95e410db88 |
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.2.17
|
||||
bun-version: 1.2.19
|
||||
|
||||
- run: bun install
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Duplicate Issue Detection
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Check for duplicate issues
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: |
|
||||
{
|
||||
"bash": {
|
||||
"gh issue*": "allow",
|
||||
"*": "deny"
|
||||
},
|
||||
"webfetch": "deny"
|
||||
}
|
||||
run: |
|
||||
opencode run -m anthropic/claude-sonnet-4-20250514 "A new issue has been created: '${{ github.event.issue.title }}'
|
||||
|
||||
Issue number:
|
||||
${{ github.event.issue.number }}
|
||||
Issue body:
|
||||
${{ github.event.issue.body }}
|
||||
|
||||
Please search through existing issues (excluding #${{ github.event.issue.number }}) in this repository to find any potential duplicates of this new issue.
|
||||
Consider:
|
||||
1. Similar titles or descriptions
|
||||
2. Same error messages or symptoms
|
||||
3. Related functionality or components
|
||||
4. Similar feature requests
|
||||
|
||||
If you find any potential duplicates, please comment on the new issue with:
|
||||
- A brief explanation of why it might be a duplicate
|
||||
- Links to the potentially duplicate issues
|
||||
- A suggestion to check those issues first
|
||||
|
||||
Use this format for the comment:
|
||||
'This issue might be a duplicate of existing issues. Please check:
|
||||
- #[issue_number]: [brief description of similarity]
|
||||
|
||||
Feel free to ignore if none of these address your specific case.'
|
||||
|
||||
If no clear duplicates are found, do not comment."
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Guidelines Check
|
||||
|
||||
on:
|
||||
# Disabled - uncomment to re-enable
|
||||
# pull_request_target:
|
||||
# types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
check-guidelines:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Check PR guidelines compliance
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "gh pr review*": "deny", "*": "deny" } }'
|
||||
run: |
|
||||
opencode run -m anthropic/claude-sonnet-4-20250514 "A new pull request has been created: '${{ github.event.pull_request.title }}'
|
||||
|
||||
<pr-number>
|
||||
${{ github.event.pull_request.number }}
|
||||
</pr-number>
|
||||
|
||||
<pr-description>
|
||||
${{ github.event.pull_request.body }}
|
||||
</pr-description>
|
||||
|
||||
Please check all the code changes in this pull request against the guidelines in AGENTS.md file in this repository. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
|
||||
|
||||
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
|
||||
|
||||
Command MUST be like this.
|
||||
```
|
||||
gh api \
|
||||
--method POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments \
|
||||
-f 'body=[summary of issue]' -f 'commit_id=${{ github.event.pull_request.head.sha }}' -f 'path=[path-to-file]' -F "line=[line]" -f 'side=RIGHT'
|
||||
```
|
||||
|
||||
Only create comments for actual violations. If the code follows all guidelines, don't run any gh commands."
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.17
|
||||
bun-version: 1.2.19
|
||||
|
||||
- run: git fetch --force --tags
|
||||
- run: bun install -g @vscode/vsce
|
||||
|
||||
@@ -52,16 +52,14 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pacman-package-manager
|
||||
|
||||
- name: Setup SSH for AUR
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
git config --global user.email "opencode@sst.dev"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
|
||||
@@ -6,3 +6,4 @@ node_modules
|
||||
.vscode
|
||||
openapi.json
|
||||
playground
|
||||
tmp
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
---
|
||||
model: openai/gpt-5
|
||||
reasoningEffort: medium
|
||||
description: ALWAYS use this when writing docs
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Use this agent when you are asked to commit and push code changes to a git repository.
|
||||
mode: subagent
|
||||
---
|
||||
|
||||
You commit and push to git
|
||||
|
||||
Commit messages should be brief since they are used to generate release notes.
|
||||
|
||||
Messages should say WHY the change was made and not WHAT was changed.
|
||||
@@ -1,46 +1,51 @@
|
||||
# Download Stats
|
||||
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | ---------------- | ---------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
|
||||
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
|
||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
||||
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
||||
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
||||
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
|
||||
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
|
||||
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
|
||||
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
|
||||
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
|
||||
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
|
||||
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
|
||||
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
|
||||
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
|
||||
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
|
||||
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
|
||||
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
|
||||
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
|
||||
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
|
||||
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
|
||||
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
|
||||
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
|
||||
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | ---------------- | ----------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
|
||||
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
|
||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
||||
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
||||
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
||||
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
|
||||
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
|
||||
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
|
||||
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
|
||||
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
|
||||
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
|
||||
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
|
||||
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
|
||||
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
|
||||
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
|
||||
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
|
||||
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
|
||||
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
|
||||
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
|
||||
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
|
||||
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
|
||||
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
|
||||
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
|
||||
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
|
||||
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
|
||||
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
|
||||
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
|
||||
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"cloud/core": {
|
||||
"name": "@opencode/cloud-core",
|
||||
"version": "0.0.0",
|
||||
"version": "0.4.19",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"drizzle-orm": "0.41.0",
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"cloud/function": {
|
||||
"name": "@opencode/cloud-function",
|
||||
"version": "0.3.130",
|
||||
"version": "0.4.19",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"cloud/web": {
|
||||
"name": "@opencode/cloud-web",
|
||||
"version": "0.0.0",
|
||||
"version": "0.4.19",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "0.13.9",
|
||||
"@openauthjs/solid": "0.0.0-20250322224806",
|
||||
@@ -66,7 +66,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.19",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.19",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -113,6 +113,7 @@
|
||||
"tree-sitter-bash": "0.23.3",
|
||||
"turndown": "7.2.0",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.22.6",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
@@ -120,7 +121,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "2.2.10",
|
||||
"@ai-sdk/anthropic": "1.2.12",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@tsconfig/bun": "1.0.7",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.19",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
},
|
||||
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.19",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.80.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -155,9 +155,9 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.19",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "^12.5.4",
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
"@astrojs/solid-js": "5.1.0",
|
||||
"@astrojs/starlight": "0.34.3",
|
||||
@@ -190,6 +190,9 @@
|
||||
"sharp",
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
"catalog": {
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
@@ -230,11 +233,11 @@
|
||||
|
||||
"@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="],
|
||||
|
||||
"@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-pQ8bokC59GEiXvyXpC4swBNoL7C/EknP+82KFzQwgR/Aeo5N1oPiAoPHgJbpPya/YF4E26WODdCQfBQDvLRfuw=="],
|
||||
"@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="],
|
||||
|
||||
"@astrojs/compiler": ["@astrojs/compiler@2.12.2", "", {}, "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw=="],
|
||||
|
||||
"@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
"@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.1", "", {}, "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ=="],
|
||||
|
||||
"@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.2.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.1", "remark-smartypants": "^3.0.2", "shiki": "^3.0.0", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-c5F5gGrkczUaTVgmMW9g1YMJGzOtRvjjhw6IfGuxarM6ct09MpwysP10US729dy07gg8y+ofVifezvP3BNsWZg=="],
|
||||
|
||||
@@ -908,7 +911,7 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
|
||||
"@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="],
|
||||
|
||||
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
|
||||
|
||||
@@ -2482,6 +2485,8 @@
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"web-tree-sitter": ["web-tree-sitter@0.22.6", "", {}, "sha512-hS87TH71Zd6mGAmYCvlgxeGDjqd9GTeqXNqTT+u0Gs51uIozNIaaq/kUAbV/Zf56jb2ZOyG8BxZs2GG9wbLi6Q=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
@@ -2570,6 +2575,8 @@
|
||||
|
||||
"@astrojs/cloudflare/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
||||
|
||||
"@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", "smol-toml": "^1.3.4", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-DDRtD1sPvAuA7ms2btc9A7/7DApKqgLMNrE6kh5tmkfy8utD0Z738gqd3p5aViYYdUtHIyEJ1X4mCMxfCfu15w=="],
|
||||
|
||||
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
@@ -2750,12 +2757,16 @@
|
||||
|
||||
"@tufjs/models/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@types/bun/bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.1", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-/iP1sKc6UdJgGH98OCly7sWJKv+J9G47PnTjIj40IJMUQKwDrUMyf7zOOfRtPwSuNifYhSoJQ4s1WltI65gJ/g=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
|
||||
"astro/diff": ["diff@5.2.0", "", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="],
|
||||
|
||||
"astro/esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="],
|
||||
@@ -2828,8 +2839,6 @@
|
||||
|
||||
"nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
|
||||
|
||||
"opencode/@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||
@@ -2934,6 +2943,8 @@
|
||||
|
||||
"@astrojs/cloudflare/vite/esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="],
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
||||
|
||||
"@astrojs/solid-js/vite/esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="],
|
||||
@@ -3060,10 +3071,6 @@
|
||||
|
||||
"local-pkg/pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
|
||||
|
||||
"opencode/@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
|
||||
|
||||
"opencode/@openauthjs/openauth/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cloud-core",
|
||||
"version": "0.0.0",
|
||||
"version": "0.5.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/cloud-function",
|
||||
"version": "0.3.130",
|
||||
"version": "0.5.3",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/cloud-web",
|
||||
"version": "0.0.0",
|
||||
"version": "0.5.3",
|
||||
"private": true,
|
||||
"description": "",
|
||||
"type": "module",
|
||||
|
||||
+6
-3
@@ -3,11 +3,11 @@
|
||||
"name": "opencode",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.2.14",
|
||||
"packageManager": "bun@1.2.19",
|
||||
"scripts": {
|
||||
"dev": "bun run --conditions=development packages/opencode/src/index.ts",
|
||||
"typecheck": "bun run --filter='*' typecheck",
|
||||
"stainless": "./scripts/stainless",
|
||||
"generate": "(cd packages/sdk && ./js/script/generate.ts) && (cd packages/sdk/stainless && ./generate.ts)",
|
||||
"postinstall": "./script/hooks"
|
||||
},
|
||||
"workspaces": {
|
||||
@@ -46,7 +46,10 @@
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
"sharp",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"web-tree-sitter"
|
||||
],
|
||||
"patchedDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.3",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.3",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
@@ -16,7 +16,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "2.2.10",
|
||||
"@ai-sdk/anthropic": "1.2.12",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@tsconfig/bun": "1.0.7",
|
||||
@@ -53,6 +52,7 @@
|
||||
"remeda": "catalog:",
|
||||
"tree-sitter": "0.22.4",
|
||||
"tree-sitter-bash": "0.23.3",
|
||||
"web-tree-sitter": "0.22.6",
|
||||
"turndown": "7.2.0",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
|
||||
@@ -39,6 +39,14 @@ for (const [os, arch] of targets) {
|
||||
"../tui",
|
||||
)
|
||||
await $`bun build --define OPENCODE_TUI_PATH="'../../../dist/${name}/bin/tui'" --define OPENCODE_VERSION="'${version}'" --compile --target=bun-${os}-${arch} --outfile=dist/${name}/bin/opencode ./src/index.ts`
|
||||
// Run the binary only if it matches current OS/arch
|
||||
if (
|
||||
process.platform === (os === "windows" ? "win32" : os) &&
|
||||
(process.arch === arch || (process.arch === "x64" && arch === "x64-baseline"))
|
||||
) {
|
||||
console.log(`smoke test: running dist/${name}/bin/opencode --version`)
|
||||
await $`./dist/${name}/bin/opencode --version`
|
||||
}
|
||||
await $`rm -rf ./dist/${name}/bin/tui`
|
||||
await Bun.file(`dist/${name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
@@ -79,51 +87,17 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
if (!dry) await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${npmTag}`
|
||||
|
||||
if (!snapshot) {
|
||||
// Github Release
|
||||
for (const key of Object.keys(optionalDependencies)) {
|
||||
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
|
||||
}
|
||||
|
||||
const previous = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(res.statusText)
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => data.tag_name)
|
||||
|
||||
console.log("finding commits between", previous, "and", "HEAD")
|
||||
const commits = await fetch(`https://api.github.com/repos/sst/opencode/compare/${previous}...HEAD`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.commits || [])
|
||||
|
||||
const raw = commits.map((commit: any) => `- ${commit.commit.message.split("\n").join(" ")}`)
|
||||
console.log(raw)
|
||||
|
||||
const notes =
|
||||
raw
|
||||
.filter((x: string) => {
|
||||
const lower = x.toLowerCase()
|
||||
return (
|
||||
!lower.includes("release:") &&
|
||||
!lower.includes("ignore:") &&
|
||||
!lower.includes("chore:") &&
|
||||
!lower.includes("ci:") &&
|
||||
!lower.includes("wip:") &&
|
||||
!lower.includes("docs:") &&
|
||||
!lower.includes("doc:")
|
||||
)
|
||||
})
|
||||
.join("\n") || "No notable changes"
|
||||
|
||||
if (!dry) await $`gh release create v${version} --title "v${version}" --notes ${notes} ./dist/*.zip`
|
||||
|
||||
// Calculate SHA values
|
||||
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
|
||||
// AUR package
|
||||
/* AUR package - commented out as AUR is down
|
||||
const pkgbuild = [
|
||||
"# Maintainer: dax",
|
||||
"# Maintainer: adam",
|
||||
@@ -152,7 +126,7 @@ if (!snapshot) {
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
for (const pkg of ["opencode", "opencode-bin"]) {
|
||||
for (const pkg of ["opencode-bin"]) {
|
||||
await $`rm -rf ./dist/aur-${pkg}`
|
||||
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
|
||||
await $`cd ./dist/aur-${pkg} && git checkout master`
|
||||
@@ -162,6 +136,7 @@ if (!snapshot) {
|
||||
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
|
||||
if (!dry) await $`cd ./dist/aur-${pkg} && git push`
|
||||
}
|
||||
*/
|
||||
|
||||
// Homebrew formula
|
||||
const homebrewFormula = [
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Provider } from "../provider/provider"
|
||||
import { generateObject, type ModelMessage } from "ai"
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { mergeDeep } from "remeda"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
@@ -12,9 +13,14 @@ export namespace Agent {
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]),
|
||||
builtIn: z.boolean(),
|
||||
topP: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
options: z.record(z.any()),
|
||||
permission: z.object({
|
||||
edit: Config.Permission,
|
||||
bash: z.record(z.string(), Config.Permission),
|
||||
webfetch: Config.Permission.optional(),
|
||||
}),
|
||||
model: z
|
||||
.object({
|
||||
modelID: z.string(),
|
||||
@@ -23,6 +29,7 @@ export namespace Agent {
|
||||
.optional(),
|
||||
prompt: z.string().optional(),
|
||||
tools: z.record(z.boolean()),
|
||||
options: z.record(z.string(), z.any()),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Agent",
|
||||
@@ -31,6 +38,15 @@ export namespace Agent {
|
||||
|
||||
const state = App.state("agent", async () => {
|
||||
const cfg = await Config.get()
|
||||
const defaultPermission: Info["permission"] = {
|
||||
edit: "allow",
|
||||
bash: {
|
||||
"*": "allow",
|
||||
},
|
||||
webfetch: "allow",
|
||||
}
|
||||
const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})
|
||||
|
||||
const result: Record<string, Info> = {
|
||||
general: {
|
||||
name: "general",
|
||||
@@ -41,23 +57,29 @@ export namespace Agent {
|
||||
todowrite: false,
|
||||
},
|
||||
options: {},
|
||||
permission: agentPermission,
|
||||
mode: "subagent",
|
||||
builtIn: true,
|
||||
},
|
||||
build: {
|
||||
name: "build",
|
||||
tools: {},
|
||||
options: {},
|
||||
permission: agentPermission,
|
||||
mode: "primary",
|
||||
builtIn: true,
|
||||
},
|
||||
plan: {
|
||||
name: "plan",
|
||||
options: {},
|
||||
permission: agentPermission,
|
||||
tools: {
|
||||
write: false,
|
||||
edit: false,
|
||||
patch: false,
|
||||
},
|
||||
mode: "primary",
|
||||
builtIn: true,
|
||||
},
|
||||
}
|
||||
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
||||
@@ -70,25 +92,31 @@ export namespace Agent {
|
||||
item = result[key] = {
|
||||
name: key,
|
||||
mode: "all",
|
||||
permission: agentPermission,
|
||||
options: {},
|
||||
tools: {},
|
||||
builtIn: false,
|
||||
}
|
||||
if (value.model) item.model = Provider.parseModel(value.model)
|
||||
if (value.prompt) item.prompt = value.prompt
|
||||
if (value.tools)
|
||||
const { model, prompt, tools, description, temperature, top_p, mode, permission, ...extra } = value
|
||||
item.options = {
|
||||
...item.options,
|
||||
...extra,
|
||||
}
|
||||
if (model) item.model = Provider.parseModel(model)
|
||||
if (prompt) item.prompt = prompt
|
||||
if (tools)
|
||||
item.tools = {
|
||||
...item.tools,
|
||||
...value.tools,
|
||||
}
|
||||
if (value.description) item.description = value.description
|
||||
if (value.temperature != undefined) item.temperature = value.temperature
|
||||
if (value.top_p != undefined) item.topP = value.top_p
|
||||
if (value.mode) item.mode = value.mode
|
||||
if (value.options)
|
||||
item.options = {
|
||||
...item.options,
|
||||
...value.options,
|
||||
...tools,
|
||||
}
|
||||
if (description) item.description = description
|
||||
if (temperature != undefined) item.temperature = temperature
|
||||
if (top_p != undefined) item.topP = top_p
|
||||
if (mode) item.mode = mode
|
||||
|
||||
if (permission ?? cfg.permission) {
|
||||
item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
@@ -131,3 +159,32 @@ export namespace Agent {
|
||||
return result.object
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
|
||||
const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
|
||||
let mergedBash
|
||||
if (merged.bash) {
|
||||
if (typeof merged.bash === "string") {
|
||||
mergedBash = {
|
||||
"*": merged.bash,
|
||||
}
|
||||
}
|
||||
// if granular permissions are provided, default to "ask"
|
||||
if (typeof merged.bash === "object") {
|
||||
mergedBash = mergeDeep(
|
||||
{
|
||||
"*": "ask",
|
||||
},
|
||||
merged.bash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const result: Agent.Info["permission"] = {
|
||||
edit: merged.edit ?? "allow",
|
||||
webfetch: merged.webfetch ?? "allow",
|
||||
bash: mergedBash ?? { "*": "allow" },
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { generatePKCE } from "@openauthjs/openauth/pkce"
|
||||
import { Auth } from "./index"
|
||||
|
||||
export namespace AuthAnthropic {
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
|
||||
export async function authorize(mode: "max" | "console") {
|
||||
const pkce = await generatePKCE()
|
||||
|
||||
const url = new URL(
|
||||
`https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`,
|
||||
import.meta.url,
|
||||
)
|
||||
url.searchParams.set("code", "true")
|
||||
url.searchParams.set("client_id", CLIENT_ID)
|
||||
url.searchParams.set("response_type", "code")
|
||||
url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
|
||||
url.searchParams.set("scope", "org:create_api_key user:profile user:inference")
|
||||
url.searchParams.set("code_challenge", pkce.challenge)
|
||||
url.searchParams.set("code_challenge_method", "S256")
|
||||
url.searchParams.set("state", pkce.verifier)
|
||||
return {
|
||||
url: url.toString(),
|
||||
verifier: pkce.verifier,
|
||||
}
|
||||
}
|
||||
|
||||
export async function exchange(code: string, verifier: string) {
|
||||
const splits = code.split("#")
|
||||
const result = await fetch("https://console.anthropic.com/v1/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: splits[0],
|
||||
state: splits[1],
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: "https://console.anthropic.com/oauth/code/callback",
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
})
|
||||
if (!result.ok) throw new ExchangeFailed()
|
||||
const json = await result.json()
|
||||
return {
|
||||
refresh: json.refresh_token as string,
|
||||
access: json.access_token as string,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
}
|
||||
}
|
||||
|
||||
export async function access() {
|
||||
const info = await Auth.get("anthropic")
|
||||
if (!info || info.type !== "oauth") return
|
||||
if (info.access && info.expires > Date.now()) return info.access
|
||||
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: info.refresh,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) return
|
||||
const json = await response.json()
|
||||
await Auth.set("anthropic", {
|
||||
type: "oauth",
|
||||
refresh: json.refresh_token as string,
|
||||
access: json.access_token as string,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
})
|
||||
return json.access_token as string
|
||||
}
|
||||
|
||||
export class ExchangeFailed extends Error {
|
||||
constructor() {
|
||||
super("Exchange failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Global } from "../global"
|
||||
import { lazy } from "../util/lazy"
|
||||
import path from "path"
|
||||
|
||||
export const AuthCopilot = lazy(async () => {
|
||||
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
|
||||
const exists = await file.exists()
|
||||
const response = fetch("https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts")
|
||||
.then((x) => Bun.write(file, x))
|
||||
.catch(() => {})
|
||||
|
||||
if (!exists) {
|
||||
const worked = await response
|
||||
if (!worked) return
|
||||
}
|
||||
const result = await import(file.name!).catch(() => {})
|
||||
if (!result) return
|
||||
return result.AuthCopilot
|
||||
})
|
||||
@@ -4,25 +4,31 @@ import fs from "fs/promises"
|
||||
import { z } from "zod"
|
||||
|
||||
export namespace Auth {
|
||||
export const Oauth = z.object({
|
||||
type: z.literal("oauth"),
|
||||
refresh: z.string(),
|
||||
access: z.string(),
|
||||
expires: z.number(),
|
||||
})
|
||||
export const Oauth = z
|
||||
.object({
|
||||
type: z.literal("oauth"),
|
||||
refresh: z.string(),
|
||||
access: z.string(),
|
||||
expires: z.number(),
|
||||
})
|
||||
.openapi({ ref: "OAuth" })
|
||||
|
||||
export const Api = z.object({
|
||||
type: z.literal("api"),
|
||||
key: z.string(),
|
||||
})
|
||||
export const Api = z
|
||||
.object({
|
||||
type: z.literal("api"),
|
||||
key: z.string(),
|
||||
})
|
||||
.openapi({ ref: "ApiAuth" })
|
||||
|
||||
export const WellKnown = z.object({
|
||||
type: z.literal("wellknown"),
|
||||
key: z.string(),
|
||||
token: z.string(),
|
||||
})
|
||||
export const WellKnown = z
|
||||
.object({
|
||||
type: z.literal("wellknown"),
|
||||
key: z.string(),
|
||||
token: z.string(),
|
||||
})
|
||||
.openapi({ ref: "WellKnownAuth" })
|
||||
|
||||
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown])
|
||||
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).openapi({ ref: "Auth" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
const filepath = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
@@ -39,7 +39,7 @@ const AgentCreateCommand = cmd({
|
||||
const query = await prompts.text({
|
||||
message: "Description",
|
||||
placeholder: "What should this agent do?",
|
||||
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(query)) throw new UI.CancelledError()
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { AuthAnthropic } from "../../auth/anthropic"
|
||||
import { AuthCopilot } from "../../auth/copilot"
|
||||
import { Auth } from "../../auth"
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
@@ -10,6 +8,8 @@ import { map, pipe, sortBy, values } from "remeda"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../../global"
|
||||
import { Plugin } from "../../plugin"
|
||||
import { App } from "../../app/app"
|
||||
|
||||
export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
@@ -75,242 +75,179 @@ export const AuthLoginCommand = cmd({
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Bun.spawn({
|
||||
cmd: wellknown.auth.command,
|
||||
stdout: "pipe",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
prompts.log.error("Failed")
|
||||
await App.provide({ cwd: process.cwd() }, async () => {
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Bun.spawn({
|
||||
cmd: wellknown.auth.command,
|
||||
stdout: "pipe",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const token = await new Response(proc.stdout).text()
|
||||
await Auth.set(args.url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
token: token.trim(),
|
||||
})
|
||||
prompts.log.success("Logged into " + args.url)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const token = await new Response(proc.stdout).text()
|
||||
await Auth.set(args.url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
token: token.trim(),
|
||||
})
|
||||
prompts.log.success("Logged into " + args.url)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await ModelsDev.refresh().catch(() => {})
|
||||
const providers = await ModelsDev.get()
|
||||
const priority: Record<string, number> = {
|
||||
anthropic: 0,
|
||||
"github-copilot": 1,
|
||||
openai: 2,
|
||||
google: 3,
|
||||
openrouter: 4,
|
||||
vercel: 5,
|
||||
}
|
||||
let provider = await prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: priority[x.id] === 0 ? "recommended" : undefined,
|
||||
})),
|
||||
),
|
||||
{
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
|
||||
if (provider === "other") {
|
||||
provider = await prompts.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) => x && (x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||
})
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
provider = provider.replace(/^@ai-sdk\//, "")
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
prompts.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
prompts.log.info(
|
||||
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
|
||||
)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
const method = await prompts.select({
|
||||
message: "Login method",
|
||||
await ModelsDev.refresh().catch(() => {})
|
||||
const providers = await ModelsDev.get()
|
||||
const priority: Record<string, number> = {
|
||||
anthropic: 0,
|
||||
"github-copilot": 1,
|
||||
openai: 2,
|
||||
google: 3,
|
||||
openrouter: 4,
|
||||
vercel: 5,
|
||||
}
|
||||
let provider = await prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: priority[x.id] === 0 ? "recommended" : undefined,
|
||||
})),
|
||||
),
|
||||
{
|
||||
label: "Claude Pro/Max",
|
||||
value: "max",
|
||||
},
|
||||
{
|
||||
label: "Create API Key",
|
||||
value: "console",
|
||||
},
|
||||
{
|
||||
label: "Manually enter API Key",
|
||||
value: "api",
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
||||
|
||||
if (method === "max") {
|
||||
// some weird bug where program exits without this
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const { url, verifier } = await AuthAnthropic.authorize("max")
|
||||
prompts.note("Trying to open browser...")
|
||||
try {
|
||||
await open(url)
|
||||
} catch (e) {
|
||||
prompts.log.error(
|
||||
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
|
||||
)
|
||||
}
|
||||
prompts.log.info(url)
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
|
||||
const code = await prompts.text({
|
||||
message: "Paste the authorization code here: ",
|
||||
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
||||
|
||||
try {
|
||||
const credentials = await AuthAnthropic.exchange(code, verifier)
|
||||
await Auth.set("anthropic", {
|
||||
type: "oauth",
|
||||
refresh: credentials.refresh,
|
||||
access: credentials.access,
|
||||
expires: credentials.expires,
|
||||
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
|
||||
if (plugin && plugin.auth) {
|
||||
let index = 0
|
||||
if (plugin.auth.methods.length > 1) {
|
||||
const method = await prompts.select({
|
||||
message: "Login method",
|
||||
options: [
|
||||
...plugin.auth.methods.map((x, index) => ({
|
||||
label: x.label,
|
||||
value: index.toString(),
|
||||
})),
|
||||
],
|
||||
})
|
||||
prompts.log.success("Login successful")
|
||||
} catch {
|
||||
prompts.log.error("Invalid code")
|
||||
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
||||
index = parseInt(method)
|
||||
}
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const method = plugin.auth.methods[index]
|
||||
if (method.type === "oauth") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const authorize = await method.authorize()
|
||||
|
||||
if (method === "console") {
|
||||
// some weird bug where program exits without this
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const { url, verifier } = await AuthAnthropic.authorize("console")
|
||||
prompts.note("Trying to open browser...")
|
||||
try {
|
||||
await open(url)
|
||||
} catch (e) {
|
||||
prompts.log.error(
|
||||
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
|
||||
)
|
||||
}
|
||||
prompts.log.info(url)
|
||||
|
||||
const code = await prompts.text({
|
||||
message: "Paste the authorization code here: ",
|
||||
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
||||
|
||||
try {
|
||||
const credentials = await AuthAnthropic.exchange(code, verifier)
|
||||
const accessToken = credentials.access
|
||||
const response = await fetch("https://api.anthropic.com/api/oauth/claude_cli/create_api_key", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json, text/plain, */*",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create API key")
|
||||
if (authorize.url) {
|
||||
try {
|
||||
await open(authorize.url)
|
||||
} catch (e) {}
|
||||
prompts.log.info("Go to: " + authorize.url)
|
||||
}
|
||||
const json = await response.json()
|
||||
await Auth.set("anthropic", {
|
||||
type: "api",
|
||||
key: json.raw_key,
|
||||
})
|
||||
|
||||
prompts.log.success("Login successful - API key created and saved")
|
||||
} catch (error) {
|
||||
prompts.log.error("Invalid code or failed to create API key")
|
||||
if (authorize.method === "auto") {
|
||||
if (authorize.instructions) {
|
||||
prompts.log.info(authorize.instructions)
|
||||
}
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Waiting for authorization...")
|
||||
const result = await authorize.callback()
|
||||
if (result.type === "failed") {
|
||||
spinner.stop("Failed to authorize", 1)
|
||||
}
|
||||
if (result.type === "success") {
|
||||
await Auth.set(provider, {
|
||||
type: "oauth",
|
||||
refresh: result.refresh,
|
||||
access: result.access,
|
||||
expires: result.expires,
|
||||
})
|
||||
spinner.stop("Login successful")
|
||||
}
|
||||
}
|
||||
|
||||
if (authorize.method === "code") {
|
||||
const code = await prompts.text({
|
||||
message: "Paste the authorization code here: ",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
||||
const result = await authorize.callback(code)
|
||||
if (result.type === "failed") {
|
||||
prompts.log.error("Failed to authorize")
|
||||
}
|
||||
if (result.type === "success") {
|
||||
await Auth.set(provider, {
|
||||
type: "oauth",
|
||||
refresh: result.refresh,
|
||||
access: result.access,
|
||||
expires: result.expires,
|
||||
})
|
||||
prompts.log.success("Login successful")
|
||||
}
|
||||
}
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "other") {
|
||||
provider = await prompts.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||
})
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
provider = provider.replace(/^@ai-sdk\//, "")
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
prompts.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
prompts.log.info(
|
||||
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
|
||||
)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const copilot = await AuthCopilot()
|
||||
if (provider === "github-copilot" && copilot) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
const deviceInfo = await copilot.authorize()
|
||||
|
||||
prompts.note(`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`)
|
||||
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Waiting for authorization...")
|
||||
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, deviceInfo.interval * 1000))
|
||||
const response = await copilot.poll(deviceInfo.device)
|
||||
if (response.status === "pending") continue
|
||||
if (response.status === "success") {
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
refresh: response.refresh,
|
||||
access: response.access,
|
||||
expires: response.expires,
|
||||
})
|
||||
spinner.stop("Login successful")
|
||||
break
|
||||
}
|
||||
if (response.status === "failed") {
|
||||
spinner.stop("Failed to authorize", 1)
|
||||
break
|
||||
}
|
||||
if (provider === "vercel") {
|
||||
prompts.log.info("You can create an api key in the dashboard")
|
||||
}
|
||||
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
await Auth.set(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
})
|
||||
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (provider === "vercel") {
|
||||
prompts.log.info("You can create an api key in the dashboard")
|
||||
}
|
||||
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
await Auth.set(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
})
|
||||
|
||||
prompts.outro("Done")
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -188,13 +188,17 @@ export const GithubInstallCommand = cmd({
|
||||
const info = await $`git remote get-url origin`.quiet().nothrow().text()
|
||||
// match https or git pattern
|
||||
// ie. https://github.com/sst/opencode.git
|
||||
// ie. https://github.com/sst/opencode
|
||||
// ie. git@github.com:sst/opencode.git
|
||||
const parsed = info.match(/git@github\.com:(.*)\.git/) ?? info.match(/github\.com\/(.*)\.git/)
|
||||
// ie. git@github.com:sst/opencode
|
||||
// ie. ssh://git@github.com/sst/opencode.git
|
||||
// ie. ssh://git@github.com/sst/opencode
|
||||
const parsed = info.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
throw new UI.CancelledError()
|
||||
}
|
||||
const [owner, repo] = parsed[1].split("/")
|
||||
const [, owner, repo] = parsed
|
||||
return { owner, repo, root: app.path.root }
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const McpAddCommand = cmd({
|
||||
|
||||
const name = await prompts.text({
|
||||
message: "Enter MCP server name",
|
||||
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(name)) throw new UI.CancelledError()
|
||||
|
||||
@@ -44,7 +44,7 @@ export const McpAddCommand = cmd({
|
||||
const command = await prompts.text({
|
||||
message: "Enter command to run",
|
||||
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
|
||||
validate: (x) => x && (x.length > 0 ? undefined : "Required"),
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||
|
||||
|
||||
@@ -84,10 +84,6 @@ export const RunCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
UI.empty()
|
||||
UI.println(UI.logo())
|
||||
UI.empty()
|
||||
|
||||
const cfg = await Config.get()
|
||||
if (cfg.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
||||
try {
|
||||
@@ -101,7 +97,6 @@ export const RunCommand = cmd({
|
||||
}
|
||||
}
|
||||
}
|
||||
UI.empty()
|
||||
|
||||
const agent = await (async () => {
|
||||
if (args.agent) return Agent.get(args.agent)
|
||||
@@ -116,9 +111,6 @@ export const RunCommand = cmd({
|
||||
return Provider.defaultModel()
|
||||
})()
|
||||
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "@ ", UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`)
|
||||
UI.empty()
|
||||
|
||||
function printEvent(color: string, type: string, title: string) {
|
||||
UI.println(
|
||||
color + `|`,
|
||||
@@ -137,7 +129,8 @@ export const RunCommand = cmd({
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
||||
const title =
|
||||
part.state.title || Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown"
|
||||
part.state.title ||
|
||||
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
|
||||
printEvent(color, tool, title)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ import { Bus } from "../../bus"
|
||||
import { Log } from "../../util/log"
|
||||
import { FileWatcher } from "../../file/watch"
|
||||
import { Ide } from "../../ide"
|
||||
import { Agent } from "../../agent/agent"
|
||||
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { Session } from "../../session"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_TUI_PATH: string
|
||||
@@ -38,6 +40,16 @@ export const TuiCommand = cmd({
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("continue", {
|
||||
alias: ["c"],
|
||||
describe: "continue the last session",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
describe: "session id to continue",
|
||||
type: "string",
|
||||
})
|
||||
.option("prompt", {
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
@@ -68,6 +80,19 @@ export const TuiCommand = cmd({
|
||||
return
|
||||
}
|
||||
const result = await bootstrap({ cwd }, async (app) => {
|
||||
const sessionID = await (async () => {
|
||||
if (args.continue) {
|
||||
const list = Session.list()
|
||||
const first = await list.next()
|
||||
await list.return()
|
||||
if (first.done) return
|
||||
return first.value.id
|
||||
}
|
||||
if (args.session) {
|
||||
return args.session
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
FileWatcher.init()
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
@@ -105,6 +130,7 @@ export const TuiCommand = cmd({
|
||||
...(args.model ? ["--model", args.model] : []),
|
||||
...(args.prompt ? ["--prompt", args.prompt] : []),
|
||||
...(args.mode ? ["--mode", args.mode] : []),
|
||||
...(sessionID ? ["--session", sessionID] : []),
|
||||
],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
@@ -115,7 +141,6 @@ export const TuiCommand = cmd({
|
||||
CGO_ENABLED: "0",
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||
OPENCODE_AGENTS: JSON.stringify(await Agent.list()),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
@@ -126,7 +151,7 @@ export const TuiCommand = cmd({
|
||||
if (Installation.isDev()) return
|
||||
if (Installation.isSnapshot()) return
|
||||
const config = await Config.global()
|
||||
if (config.autoupdate === false) return
|
||||
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
||||
const latest = await Installation.latest().catch(() => {})
|
||||
if (!latest) return
|
||||
if (Installation.VERSION === latest) return
|
||||
|
||||
@@ -12,7 +12,7 @@ export function FormatError(input: unknown) {
|
||||
}
|
||||
if (Config.InvalidError.isInstance(input))
|
||||
return [
|
||||
`Config file at ${input.data.path} is invalid`,
|
||||
`Config file at ${input.data.path} is invalid` + (input.data.message ? `: ${input.data.message}` : ""),
|
||||
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
|
||||
].join("\n")
|
||||
|
||||
|
||||
@@ -105,11 +105,15 @@ export namespace Config {
|
||||
result.plugin = result.plugin || []
|
||||
result.plugin.push(
|
||||
...[
|
||||
...(await Filesystem.globUp("plugin/*.ts", Global.Path.config, Global.Path.config)),
|
||||
...(await Filesystem.globUp(".opencode/plugin/*.ts", app.path.cwd, app.path.root)),
|
||||
...(await Filesystem.globUp("plugin/*.{ts,js}", Global.Path.config, Global.Path.config)),
|
||||
...(await Filesystem.globUp(".opencode/plugin/*.{ts,js}", app.path.cwd, app.path.root)),
|
||||
].map((x) => "file://" + x),
|
||||
)
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
}
|
||||
|
||||
// Handle migration from autoshare to share field
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
@@ -123,6 +127,12 @@ export namespace Config {
|
||||
if (result.keybinds?.switch_mode_reverse && !result.keybinds.switch_agent_reverse) {
|
||||
result.keybinds.switch_agent_reverse = result.keybinds.switch_mode_reverse
|
||||
}
|
||||
if (result.keybinds?.switch_agent && !result.keybinds.agent_cycle) {
|
||||
result.keybinds.agent_cycle = result.keybinds.switch_agent
|
||||
}
|
||||
if (result.keybinds?.switch_agent_reverse && !result.keybinds.agent_cycle_reverse) {
|
||||
result.keybinds.agent_cycle_reverse = result.keybinds.switch_agent_reverse
|
||||
}
|
||||
|
||||
if (!result.username) {
|
||||
const os = await import("os")
|
||||
@@ -164,6 +174,9 @@ export namespace Config {
|
||||
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
||||
export type Mcp = z.infer<typeof Mcp>
|
||||
|
||||
export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
|
||||
export type Permission = z.infer<typeof Permission>
|
||||
|
||||
export const Agent = z
|
||||
.object({
|
||||
model: z.string().optional(),
|
||||
@@ -173,9 +186,16 @@ export namespace Config {
|
||||
tools: z.record(z.string(), z.boolean()).optional(),
|
||||
disable: z.boolean().optional(),
|
||||
description: z.string().optional().describe("Description of when to use the agent"),
|
||||
options: z.record(z.string(), z.any()).optional().describe("Additional model options passed through to provider"),
|
||||
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]).optional(),
|
||||
permission: z
|
||||
.object({
|
||||
edit: Permission.optional(),
|
||||
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
|
||||
webfetch: Permission.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.catchall(z.any())
|
||||
.openapi({
|
||||
ref: "AgentConfig",
|
||||
})
|
||||
@@ -185,15 +205,12 @@ export namespace Config {
|
||||
.object({
|
||||
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
|
||||
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
|
||||
switch_mode: z.string().optional().default("none").describe("@deprecated use switch_agent. Next mode"),
|
||||
switch_mode_reverse: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("none")
|
||||
.describe("@deprecated use switch_agent_reverse. Previous mode"),
|
||||
switch_agent: z.string().optional().default("tab").describe("Next agent"),
|
||||
switch_agent_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
||||
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
|
||||
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
|
||||
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
|
||||
project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
|
||||
tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
|
||||
thinking_blocks: z.string().optional().default("<leader>b").describe("Toggle thinking blocks"),
|
||||
session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
|
||||
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
|
||||
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
|
||||
@@ -201,18 +218,12 @@ export namespace Config {
|
||||
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
||||
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
|
||||
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
|
||||
tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
|
||||
model_list: z.string().optional().default("<leader>m").describe("List available models"),
|
||||
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
|
||||
file_list: z.string().optional().default("<leader>f").describe("List files"),
|
||||
file_close: z.string().optional().default("esc").describe("Close file"),
|
||||
file_search: z.string().optional().default("<leader>/").describe("Search file"),
|
||||
file_diff_toggle: z.string().optional().default("<leader>v").describe("Split/unified diff"),
|
||||
project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
input_submit: z.string().optional().default("enter").describe("Submit input"),
|
||||
input_newline: z.string().optional().default("shift+enter,ctrl+j").describe("Insert newline in input"),
|
||||
session_child_cycle: z.string().optional().default("ctrl+right").describe("Cycle to next child session"),
|
||||
session_child_cycle_reverse: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("ctrl+left")
|
||||
.describe("Cycle to previous child session"),
|
||||
messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"),
|
||||
messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"),
|
||||
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
|
||||
@@ -221,35 +232,63 @@ export namespace Config {
|
||||
.optional()
|
||||
.default("ctrl+alt+d")
|
||||
.describe("Scroll messages down by half page"),
|
||||
messages_previous: z.string().optional().default("ctrl+up").describe("Navigate to previous message"),
|
||||
messages_next: z.string().optional().default("ctrl+down").describe("Navigate to next message"),
|
||||
messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
|
||||
messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"),
|
||||
messages_layout_toggle: z.string().optional().default("<leader>p").describe("Toggle layout"),
|
||||
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
|
||||
messages_revert: z.string().optional().default("none").describe("@deprecated use messages_undo. Revert message"),
|
||||
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
|
||||
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
|
||||
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
|
||||
model_list: z.string().optional().default("<leader>m").describe("List available models"),
|
||||
model_cycle_recent: z.string().optional().default("f2").describe("Next recent model"),
|
||||
model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recent model"),
|
||||
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
|
||||
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
input_submit: z.string().optional().default("enter").describe("Submit input"),
|
||||
input_newline: z.string().optional().default("shift+enter,ctrl+j").describe("Insert newline in input"),
|
||||
// Deprecated commands
|
||||
switch_mode: z.string().optional().default("none").describe("@deprecated use agent_cycle. Next mode"),
|
||||
switch_mode_reverse: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("none")
|
||||
.describe("@deprecated use agent_cycle_reverse. Previous mode"),
|
||||
switch_agent: z.string().optional().default("tab").describe("@deprecated use agent_cycle. Next agent"),
|
||||
switch_agent_reverse: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("shift+tab")
|
||||
.describe("@deprecated use agent_cycle_reverse. Previous agent"),
|
||||
file_list: z.string().optional().default("none").describe("@deprecated Currently not available. List files"),
|
||||
file_close: z.string().optional().default("none").describe("@deprecated Close file"),
|
||||
file_search: z.string().optional().default("none").describe("@deprecated Search file"),
|
||||
file_diff_toggle: z.string().optional().default("none").describe("@deprecated Split/unified diff"),
|
||||
messages_previous: z.string().optional().default("none").describe("@deprecated Navigate to previous message"),
|
||||
messages_next: z.string().optional().default("none").describe("@deprecated Navigate to next message"),
|
||||
messages_layout_toggle: z.string().optional().default("none").describe("@deprecated Toggle layout"),
|
||||
messages_revert: z.string().optional().default("none").describe("@deprecated use messages_undo. Revert message"),
|
||||
})
|
||||
.strict()
|
||||
.openapi({
|
||||
ref: "KeybindsConfig",
|
||||
})
|
||||
|
||||
export const TUI = z.object({
|
||||
scroll_speed: z.number().min(1).optional().default(2).describe("TUI scroll speed"),
|
||||
})
|
||||
|
||||
export const Layout = z.enum(["auto", "stretch"]).openapi({
|
||||
ref: "LayoutConfig",
|
||||
})
|
||||
export type Layout = z.infer<typeof Layout>
|
||||
|
||||
export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
|
||||
export type Permission = z.infer<typeof Permission>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
||||
theme: z.string().optional().describe("Theme name to use for the interface"),
|
||||
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
|
||||
tui: TUI.optional().describe("TUI specific settings"),
|
||||
plugin: z.string().array().optional(),
|
||||
snapshot: z.boolean().optional(),
|
||||
share: z
|
||||
@@ -294,7 +333,7 @@ export namespace Config {
|
||||
.record(
|
||||
ModelsDev.Provider.partial()
|
||||
.extend({
|
||||
models: z.record(ModelsDev.Model.partial()),
|
||||
models: z.record(ModelsDev.Model.partial()).optional(),
|
||||
options: z
|
||||
.object({
|
||||
apiKey: z.string().optional(),
|
||||
@@ -417,14 +456,14 @@ export namespace Config {
|
||||
return load(text, filepath)
|
||||
}
|
||||
|
||||
async function load(text: string, filepath: string) {
|
||||
async function load(text: string, configFilepath: string) {
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
||||
const fileMatches = text.match(/\{file:[^}]+\}/g)
|
||||
if (fileMatches) {
|
||||
const configDir = path.dirname(filepath)
|
||||
const configDir = path.dirname(configFilepath)
|
||||
const lines = text.split("\n")
|
||||
|
||||
for (const match of fileMatches) {
|
||||
@@ -437,7 +476,20 @@ export namespace Config {
|
||||
filePath = path.join(os.homedir(), filePath.slice(2))
|
||||
}
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = (await Bun.file(resolvedPath).text()).trim()
|
||||
const fileContent = (
|
||||
await Bun.file(resolvedPath)
|
||||
.text()
|
||||
.catch((error) => {
|
||||
const errMsg = `bad file reference: "${match}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{ path: configFilepath, message: errMsg + ` ${resolvedPath} does not exist` },
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
|
||||
})
|
||||
).trim()
|
||||
// escape newlines/quotes, strip outer quotes
|
||||
text = text.replace(match, JSON.stringify(fileContent).slice(1, -1))
|
||||
}
|
||||
@@ -462,7 +514,7 @@ export namespace Config {
|
||||
.join("\n")
|
||||
|
||||
throw new JsonError({
|
||||
path: filepath,
|
||||
path: configFilepath,
|
||||
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||
})
|
||||
}
|
||||
@@ -471,21 +523,21 @@ export namespace Config {
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
await Bun.write(filepath, JSON.stringify(parsed.data, null, 2))
|
||||
await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2))
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin) {
|
||||
for (let i = 0; i < data.plugin?.length; i++) {
|
||||
const plugin = data.plugin[i]
|
||||
try {
|
||||
data.plugin[i] = import.meta.resolve(plugin, filepath)
|
||||
data.plugin[i] = import.meta.resolve(plugin, configFilepath)
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
throw new InvalidError({ path: filepath, issues: parsed.error.issues })
|
||||
throw new InvalidError({ path: configFilepath, issues: parsed.error.issues })
|
||||
}
|
||||
export const JsonError = NamedError.create(
|
||||
"ConfigJsonError",
|
||||
@@ -500,6 +552,7 @@ export namespace Config {
|
||||
z.object({
|
||||
path: z.string(),
|
||||
issues: z.custom<z.ZodIssue[]>().optional(),
|
||||
message: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ export namespace Flag {
|
||||
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
|
||||
export const OPENCODE_DISABLE_WATCHER = truthy("OPENCODE_DISABLE_WATCHER")
|
||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
||||
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
|
||||
@@ -76,7 +76,7 @@ export const prettier: Info = {
|
||||
|
||||
export const biome: Info = {
|
||||
name: "biome",
|
||||
command: [BunProc.which(), "x", "biome", "format", "--write", "$FILE"],
|
||||
command: [BunProc.which(), "x", "@biomejs/biome", "format", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
@@ -110,8 +110,14 @@ export const biome: Info = {
|
||||
],
|
||||
async enabled() {
|
||||
const app = App.info()
|
||||
const items = await Filesystem.findUp("biome.json", app.path.cwd, app.path.root)
|
||||
return items.length > 0
|
||||
const configs = ["biome.json", "biome.jsonc"]
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, app.path.cwd, app.path.root)
|
||||
if (found.length > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export namespace Format {
|
||||
const proc = Bun.spawn({
|
||||
cmd: item.command.map((x) => x.replace("$FILE", file)),
|
||||
cwd: App.info().path.cwd,
|
||||
env: item.environment,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
|
||||
@@ -25,9 +25,10 @@ await Promise.all([
|
||||
fs.mkdir(Global.Path.config, { recursive: true }),
|
||||
fs.mkdir(Global.Path.state, { recursive: true }),
|
||||
fs.mkdir(Global.Path.log, { recursive: true }),
|
||||
fs.mkdir(Global.Path.bin, { recursive: true }),
|
||||
])
|
||||
|
||||
const CACHE_VERSION = "6"
|
||||
const CACHE_VERSION = "8"
|
||||
|
||||
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
||||
.text()
|
||||
|
||||
@@ -21,6 +21,9 @@ import { GithubCommand } from "./cli/cmd/github"
|
||||
|
||||
const cancel = new AbortController()
|
||||
|
||||
try {
|
||||
} catch (e) {}
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error("rejection", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
@@ -58,6 +61,8 @@ const cli = yargs(hideBin(process.argv))
|
||||
})(),
|
||||
})
|
||||
|
||||
process.env["OPENCODE"] = "1"
|
||||
|
||||
Log.Default.info("opencode", {
|
||||
version: Installation.VERSION,
|
||||
args: process.argv.slice(2),
|
||||
|
||||
@@ -126,19 +126,26 @@ export namespace LSPClient {
|
||||
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(app.path.cwd, input.path)
|
||||
const file = Bun.file(input.path)
|
||||
const text = await file.text()
|
||||
const version = files[input.path]
|
||||
if (version !== undefined) {
|
||||
diagnostics.delete(input.path)
|
||||
await connection.sendNotification("textDocument/didClose", {
|
||||
textDocument: {
|
||||
uri: `file://` + input.path,
|
||||
},
|
||||
})
|
||||
}
|
||||
log.info("textDocument/didOpen", input)
|
||||
diagnostics.delete(input.path)
|
||||
const extension = path.extname(input.path)
|
||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||
|
||||
const version = files[input.path]
|
||||
if (version !== undefined) {
|
||||
const next = version + 1
|
||||
files[input.path] = next
|
||||
log.info("textDocument/didChange", { path: input.path, version: next })
|
||||
await connection.sendNotification("textDocument/didChange", {
|
||||
textDocument: {
|
||||
uri: `file://` + input.path,
|
||||
version: next,
|
||||
},
|
||||
contentChanges: [{ text }],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.info("textDocument/didOpen", input)
|
||||
diagnostics.delete(input.path)
|
||||
await connection.sendNotification("textDocument/didOpen", {
|
||||
textDocument: {
|
||||
uri: `file://` + input.path,
|
||||
|
||||
@@ -94,6 +94,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
||||
".yml": "yaml",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".vue": "vue",
|
||||
".zig": "zig",
|
||||
".zon": "zig",
|
||||
} as const
|
||||
|
||||
@@ -65,6 +65,67 @@ export namespace LSPServer {
|
||||
},
|
||||
}
|
||||
|
||||
export const Vue: Info = {
|
||||
id: "vue",
|
||||
extensions: [".vue"],
|
||||
root: NearestRoot([
|
||||
"tsconfig.json",
|
||||
"jsconfig.json",
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
"vite.config.ts",
|
||||
"vite.config.js",
|
||||
"nuxt.config.ts",
|
||||
"nuxt.config.js",
|
||||
"vue.config.js",
|
||||
]),
|
||||
async spawn(_, root) {
|
||||
let binary = Bun.which("vue-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
Global.Path.bin,
|
||||
"node_modules",
|
||||
"@vue",
|
||||
"language-server",
|
||||
"bin",
|
||||
"vue-language-server.js",
|
||||
)
|
||||
if (!(await Bun.file(js).exists())) {
|
||||
await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
process: proc,
|
||||
initialization: {
|
||||
// Leave empty; the server will auto-detect workspace TypeScript.
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const ESLint: Info = {
|
||||
id: "eslint",
|
||||
root: NearestRoot([
|
||||
@@ -81,7 +142,7 @@ export namespace LSPServer {
|
||||
".eslintrc.json",
|
||||
"package.json",
|
||||
]),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
|
||||
async spawn(app, root) {
|
||||
const eslint = await Bun.resolve("eslint", app.path.cwd).catch(() => {})
|
||||
if (!eslint) return
|
||||
@@ -94,7 +155,7 @@ export namespace LSPServer {
|
||||
const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip")
|
||||
await Bun.file(zipPath).write(response)
|
||||
|
||||
await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow()
|
||||
await $`unzip -o -q ${zipPath}`.quiet().cwd(Global.Path.bin).nothrow()
|
||||
await fs.rm(zipPath, { force: true })
|
||||
|
||||
const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main")
|
||||
@@ -210,7 +271,24 @@ export namespace LSPServer {
|
||||
extensions: [".py", ".pyi"],
|
||||
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
|
||||
async spawn(_, root) {
|
||||
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
|
||||
let binary = Bun.which("pyright-langserver")
|
||||
const args = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
||||
if (!(await Bun.file(js).exists())) {
|
||||
await Bun.spawn([BunProc.which(), "install", "pyright"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push(...["run", js])
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -252,7 +330,7 @@ export namespace LSPServer {
|
||||
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
|
||||
await Bun.file(zipPath).write(response)
|
||||
|
||||
await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow()
|
||||
await $`unzip -o -q ${zipPath}`.quiet().cwd(Global.Path.bin).nothrow()
|
||||
|
||||
await fs.rm(zipPath, {
|
||||
force: true,
|
||||
@@ -354,7 +432,7 @@ export namespace LSPServer {
|
||||
await Bun.file(tempPath).write(downloadResponse)
|
||||
|
||||
if (ext === "zip") {
|
||||
await $`unzip -o -q ${tempPath}`.cwd(Global.Path.bin).nothrow()
|
||||
await $`unzip -o -q ${tempPath}`.quiet().cwd(Global.Path.bin).nothrow()
|
||||
} else {
|
||||
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).nothrow()
|
||||
}
|
||||
@@ -421,4 +499,81 @@ export namespace LSPServer {
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const Clangd: Info = {
|
||||
id: "clangd",
|
||||
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]),
|
||||
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("clangd", {
|
||||
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
log.info("downloading clangd from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest")
|
||||
if (!releaseResponse.ok) {
|
||||
log.error("Failed to fetch clangd release info")
|
||||
return
|
||||
}
|
||||
|
||||
const release = await releaseResponse.json()
|
||||
|
||||
const platform = process.platform
|
||||
let assetName = ""
|
||||
|
||||
if (platform === "darwin") {
|
||||
assetName = "clangd-mac-"
|
||||
} else if (platform === "linux") {
|
||||
assetName = "clangd-linux-"
|
||||
} else if (platform === "win32") {
|
||||
assetName = "clangd-windows-"
|
||||
} else {
|
||||
log.error(`Platform ${platform} is not supported by clangd auto-download`)
|
||||
return
|
||||
}
|
||||
|
||||
assetName += release.tag_name + ".zip"
|
||||
|
||||
const asset = release.assets.find((a: any) => a.name === assetName)
|
||||
if (!asset) {
|
||||
log.error(`Could not find asset ${assetName} in latest clangd release`)
|
||||
return
|
||||
}
|
||||
|
||||
const downloadUrl = asset.browser_download_url
|
||||
const downloadResponse = await fetch(downloadUrl)
|
||||
if (!downloadResponse.ok) {
|
||||
log.error("Failed to download clangd")
|
||||
return
|
||||
}
|
||||
|
||||
const zipPath = path.join(Global.Path.bin, "clangd.zip")
|
||||
await Bun.file(zipPath).write(downloadResponse)
|
||||
|
||||
await $`unzip -o -q ${zipPath}`.quiet().cwd(Global.Path.bin).nothrow()
|
||||
await fs.rm(zipPath, { force: true })
|
||||
|
||||
const extractedDir = path.join(Global.Path.bin, assetName.replace(".zip", ""))
|
||||
bin = path.join(extractedDir, "bin", "clangd" + (platform === "win32" ? ".exe" : ""))
|
||||
|
||||
if (!(await Bun.file(bin).exists())) {
|
||||
log.error("Failed to extract clangd binary")
|
||||
return
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.nothrow()
|
||||
}
|
||||
|
||||
log.info(`installed clangd`, { bin })
|
||||
}
|
||||
|
||||
return {
|
||||
process: spawn(bin, ["--background-index", "--clang-tidy"], {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,8 @@ export namespace MCP {
|
||||
for (const [clientName, client] of Object.entries(await clients())) {
|
||||
for (const [toolName, tool] of Object.entries(await client.tools())) {
|
||||
const sanitizedClientName = clientName.replace(/\s+/g, "_")
|
||||
result[sanitizedClientName + "_" + toolName] = tool
|
||||
const sanitizedToolName = toolName.replace(/[-\s]+/g, "_")
|
||||
result[sanitizedClientName + "_" + sanitizedToolName] = tool
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -62,7 +62,7 @@ export namespace Permission {
|
||||
async (state) => {
|
||||
for (const pending of Object.values(state.pending)) {
|
||||
for (const item of Object.values(pending)) {
|
||||
item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID))
|
||||
item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID, item.info.metadata))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -82,11 +82,13 @@ export namespace Permission {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
toolCallID: input.callID,
|
||||
pattern: input.pattern,
|
||||
})
|
||||
if (approved[input.sessionID]?.[input.pattern ?? input.type]) return
|
||||
const info: Info = {
|
||||
id: Identifier.ascending("permission"),
|
||||
type: input.type,
|
||||
pattern: input.pattern,
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
callID: input.callID,
|
||||
@@ -103,7 +105,7 @@ export namespace Permission {
|
||||
}).then((x) => x.status)
|
||||
) {
|
||||
case "deny":
|
||||
throw new RejectedError(info.sessionID, info.id, info.callID)
|
||||
throw new RejectedError(info.sessionID, info.id, info.callID, info.metadata)
|
||||
case "allow":
|
||||
return
|
||||
}
|
||||
@@ -129,7 +131,7 @@ export namespace Permission {
|
||||
if (!match) return
|
||||
delete pending[input.sessionID][input.permissionID]
|
||||
if (input.response === "reject") {
|
||||
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID))
|
||||
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
|
||||
return
|
||||
}
|
||||
match.resolve()
|
||||
@@ -154,8 +156,9 @@ export namespace Permission {
|
||||
public readonly sessionID: string,
|
||||
public readonly permissionID: string,
|
||||
public readonly toolCallID?: string,
|
||||
public readonly metadata?: Record<string, any>,
|
||||
) {
|
||||
super(`The user rejected permission to use this functionality`)
|
||||
super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Config } from "../config/config"
|
||||
import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
// Lazy import to avoid circular dependency with session/tool registry
|
||||
// import { Server } from "../server/server"
|
||||
import { Server } from "../server/server"
|
||||
import { BunProc } from "../bun"
|
||||
import { Flag } from "../flag/flag"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
@@ -14,11 +14,21 @@ export namespace Plugin {
|
||||
const state = App.state("plugin", async (app) => {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
fetch: async (...args) => (await import("../server/server")).Server.app().fetch(...args),
|
||||
fetch: async (...args) => Server.app().fetch(...args),
|
||||
})
|
||||
const config = await Config.get()
|
||||
const hooks = []
|
||||
for (let plugin of config.plugin ?? []) {
|
||||
const input = {
|
||||
client,
|
||||
app,
|
||||
$: Bun.$,
|
||||
}
|
||||
const plugins = [...(config.plugin ?? [])]
|
||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||
plugins.push("opencode-copilot-auth")
|
||||
plugins.push("opencode-anthropic-auth")
|
||||
}
|
||||
for (let plugin of plugins) {
|
||||
log.info("loading plugin", { path: plugin })
|
||||
if (!plugin.startsWith("file://")) {
|
||||
const [pkg, version] = plugin.split("@")
|
||||
@@ -26,22 +36,19 @@ export namespace Plugin {
|
||||
}
|
||||
const mod = await import(plugin)
|
||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||
const init = await fn({
|
||||
client,
|
||||
app,
|
||||
$: Bun.$,
|
||||
})
|
||||
const init = await fn(input)
|
||||
hooks.push(init)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hooks,
|
||||
input,
|
||||
}
|
||||
})
|
||||
|
||||
export async function trigger<
|
||||
Name extends keyof Required<Hooks>,
|
||||
Name extends Exclude<keyof Required<Hooks>, "auth" | "event">,
|
||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||
>(name: Name, input: Input, output: Output): Promise<Output> {
|
||||
@@ -57,6 +64,10 @@ export namespace Plugin {
|
||||
return output
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return state().then((x) => x.hooks)
|
||||
}
|
||||
|
||||
export function init() {
|
||||
Bus.subscribeAll(async (input) => {
|
||||
const hooks = await state().then((x) => x.hooks)
|
||||
|
||||
@@ -5,8 +5,7 @@ import { mergeDeep, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { AuthAnthropic } from "../auth/anthropic"
|
||||
import { AuthCopilot } from "../auth/copilot"
|
||||
import { Plugin } from "../plugin"
|
||||
import { ModelsDev } from "./models"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Auth } from "../auth"
|
||||
@@ -26,93 +25,13 @@ export namespace Provider {
|
||||
type Source = "env" | "config" | "custom" | "api"
|
||||
|
||||
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
||||
async anthropic(provider) {
|
||||
const access = await AuthAnthropic.access()
|
||||
if (!access) return { autoload: false }
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
}
|
||||
}
|
||||
async anthropic() {
|
||||
return {
|
||||
autoload: true,
|
||||
autoload: false,
|
||||
options: {
|
||||
apiKey: "",
|
||||
async fetch(input: any, init: any) {
|
||||
const access = await AuthAnthropic.access()
|
||||
const headers = {
|
||||
...init.headers,
|
||||
authorization: `Bearer ${access}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
}
|
||||
delete headers["x-api-key"]
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"github-copilot": async (provider) => {
|
||||
const copilot = await AuthCopilot()
|
||||
if (!copilot) return { autoload: false }
|
||||
let info = await Auth.get("github-copilot")
|
||||
if (!info || info.type !== "oauth") return { autoload: false }
|
||||
|
||||
if (provider && provider.models) {
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
apiKey: "",
|
||||
async fetch(input: any, init: any) {
|
||||
const info = await Auth.get("github-copilot")
|
||||
if (!info || info.type !== "oauth") return
|
||||
if (!info.access || info.expires < Date.now()) {
|
||||
const tokens = await copilot.access(info.refresh)
|
||||
if (!tokens) throw new Error("GitHub Copilot authentication expired")
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
...tokens,
|
||||
})
|
||||
info.access = tokens.access
|
||||
}
|
||||
let isAgentCall = false
|
||||
let isVisionRequest = false
|
||||
try {
|
||||
const body = typeof init.body === "string" ? JSON.parse(init.body) : init.body
|
||||
if (body?.messages) {
|
||||
isAgentCall = body.messages.some((msg: any) => msg.role && ["tool", "assistant"].includes(msg.role))
|
||||
isVisionRequest = body.messages.some(
|
||||
(msg: any) =>
|
||||
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
const headers: Record<string, string> = {
|
||||
...init.headers,
|
||||
...copilot.HEADERS,
|
||||
Authorization: `Bearer ${info.access}`,
|
||||
"Openai-Intent": "conversation-edits",
|
||||
"X-Initiator": isAgentCall ? "agent" : "user",
|
||||
}
|
||||
if (isVisionRequest) {
|
||||
headers["Copilot-Vision-Request"] = "true"
|
||||
}
|
||||
delete headers["x-api-key"]
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
headers: {
|
||||
"anthropic-beta":
|
||||
"claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -340,6 +259,17 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
|
||||
for (const plugin of await Plugin.list()) {
|
||||
if (!plugin.auth) continue
|
||||
const providerID = plugin.auth.provider
|
||||
if (disabled.has(providerID)) continue
|
||||
const auth = await Auth.get(providerID)
|
||||
if (!auth) continue
|
||||
if (!plugin.auth.loader) continue
|
||||
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
|
||||
mergeProvider(plugin.auth.provider, options ?? {}, "custom")
|
||||
}
|
||||
|
||||
// load config
|
||||
for (const [providerID, provider] of configProviders) {
|
||||
mergeProvider(providerID, provider.options ?? {}, "config")
|
||||
@@ -448,7 +378,7 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
|
||||
const priority = ["gemini-2.5-pro-preview", "codex-mini", "claude-sonnet-4"]
|
||||
const priority = ["gemini-2.5-pro-preview", "gpt-5", "claude-sonnet-4"]
|
||||
export function sort(models: ModelsDev.Model[]) {
|
||||
return sortBy(
|
||||
models,
|
||||
|
||||
@@ -74,6 +74,7 @@ export namespace ProviderTransform {
|
||||
|
||||
export function temperature(_providerID: string, modelID: string) {
|
||||
if (modelID.toLowerCase().includes("qwen")) return 0.55
|
||||
if (modelID.toLowerCase().includes("claude")) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -82,27 +83,19 @@ export namespace ProviderTransform {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function options(_providerID: string, modelID: string) {
|
||||
if (modelID.includes("gpt-5")) {
|
||||
return {
|
||||
reasoningEffort: "minimal",
|
||||
textVerbosity: "low",
|
||||
// reasoningSummary: "auto",
|
||||
// include: ["reasoning.encrypted_content"],
|
||||
export function options(providerID: string, modelID: string, sessionID: string): Record<string, any> | undefined {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
if (providerID === "openai") {
|
||||
result["promptCacheKey"] = sessionID
|
||||
}
|
||||
|
||||
if (modelID.includes("gpt-5") && !modelID.includes("gpt-5-chat")) {
|
||||
result["reasoningEffort"] = "minimal"
|
||||
if (providerID !== "azure") {
|
||||
result["textVerbosity"] = "low"
|
||||
}
|
||||
}
|
||||
// if (modelID.includes("claude")) {
|
||||
// return {
|
||||
// thinking: {
|
||||
// type: "enabled",
|
||||
// budgetTokens: 32000,
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
// if (_providerID === "bedrock") {
|
||||
// return {
|
||||
// reasoningConfig: { type: "enabled", budgetTokens: 32000 },
|
||||
// }
|
||||
// }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { callTui, TuiRoute } from "./tui"
|
||||
import { Permission } from "../permission"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Auth } from "../auth"
|
||||
|
||||
const ERRORS = {
|
||||
400: {
|
||||
@@ -88,7 +89,7 @@ export namespace Server {
|
||||
version: "0.0.3",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.0.0",
|
||||
openapi: "3.1.1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -247,6 +248,34 @@ export namespace Server {
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/session/:id/children",
|
||||
describeRoute({
|
||||
description: "Get a session's children",
|
||||
operationId: "session.children",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of children",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").id
|
||||
const session = await Session.children(sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session",
|
||||
describeRoute({
|
||||
@@ -264,8 +293,18 @@ export namespace Server {
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z
|
||||
.object({
|
||||
parentID: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
),
|
||||
async (c) => {
|
||||
const session = await Session.create()
|
||||
const body = c.req.valid("json") ?? {}
|
||||
const session = await Session.create(body.parentID, body.title)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -296,6 +335,47 @@ export namespace Server {
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/session/:id",
|
||||
describeRoute({
|
||||
description: "Update session properties",
|
||||
operationId: "session.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").id
|
||||
const updates = c.req.valid("json")
|
||||
|
||||
const updatedSession = await Session.update(sessionID, (session) => {
|
||||
if (updates.title !== undefined) {
|
||||
session.title = updates.title
|
||||
}
|
||||
})
|
||||
|
||||
return c.json(updatedSession)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session/:id/init",
|
||||
describeRoute({
|
||||
@@ -551,6 +631,36 @@ export namespace Server {
|
||||
return c.json(msg)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session/:id/shell",
|
||||
describeRoute({
|
||||
description: "Run a shell command",
|
||||
operationId: "session.shell",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Created message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MessageV2.Assistant),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({ description: "Session ID" }),
|
||||
}),
|
||||
),
|
||||
zValidator("json", Session.CommandInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").id
|
||||
const body = c.req.valid("json")
|
||||
const msg = await Session.shell({ ...body, sessionID })
|
||||
return c.json(msg)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/session/:id/revert",
|
||||
describeRoute({
|
||||
@@ -1027,7 +1137,7 @@ export namespace Server {
|
||||
.post(
|
||||
"/tui/execute-command",
|
||||
describeRoute({
|
||||
description: "Execute a TUI command (e.g. switch_agent)",
|
||||
description: "Execute a TUI command (e.g. agent_cycle)",
|
||||
operationId: "tui.executeCommand",
|
||||
responses: {
|
||||
200: {
|
||||
@@ -1048,7 +1158,64 @@ export namespace Server {
|
||||
),
|
||||
async (c) => c.json(await callTui(c)),
|
||||
)
|
||||
.post(
|
||||
"/tui/show-toast",
|
||||
describeRoute({
|
||||
description: "Show a toast notification in the TUI",
|
||||
operationId: "tui.showToast",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Toast notification shown successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
message: z.string(),
|
||||
variant: z.enum(["info", "success", "warning", "error"]),
|
||||
}),
|
||||
),
|
||||
async (c) => c.json(await callTui(c)),
|
||||
)
|
||||
.route("/tui/control", TuiRoute)
|
||||
.put(
|
||||
"/auth/:id",
|
||||
describeRoute({
|
||||
description: "Set authentication credentials",
|
||||
operationId: "auth.set",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully set authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...ERRORS,
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
),
|
||||
zValidator("json", Auth.Info),
|
||||
async (c) => {
|
||||
const id = c.req.valid("param").id
|
||||
const info = c.req.valid("json")
|
||||
await Auth.set(id, info)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
})
|
||||
@@ -1062,7 +1229,7 @@ export namespace Server {
|
||||
version: "1.0.0",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.0.0",
|
||||
openapi: "3.1.1",
|
||||
},
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "path"
|
||||
import { spawn } from "child_process"
|
||||
import { Decimal } from "decimal.js"
|
||||
import { z, ZodSchema } from "zod"
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from "ai"
|
||||
|
||||
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
|
||||
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||
|
||||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
@@ -41,6 +43,9 @@ import { ToolRegistry } from "../tool/registry"
|
||||
import { Plugin } from "../plugin"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Permission } from "../permission"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { ulid } from "ulid"
|
||||
import { defer } from "../util/defer"
|
||||
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
@@ -158,12 +163,12 @@ export namespace Session {
|
||||
},
|
||||
)
|
||||
|
||||
export async function create(parentID?: string) {
|
||||
export async function create(parentID?: string, title?: string) {
|
||||
const result: Info = {
|
||||
id: Identifier.descending("session"),
|
||||
version: Installation.VERSION,
|
||||
parentID,
|
||||
title: createDefaultTitle(!!parentID),
|
||||
title: title ?? createDefaultTitle(!!parentID),
|
||||
time: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
@@ -521,7 +526,9 @@ export namespace Session {
|
||||
t.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: new AbortController().signal,
|
||||
agent: input.agent!,
|
||||
messageID: userMsg.id,
|
||||
extra: { bypassCwdCheck: true },
|
||||
metadata: async () => {},
|
||||
}),
|
||||
)
|
||||
@@ -607,17 +614,6 @@ export namespace Session {
|
||||
]
|
||||
}),
|
||||
).then((x) => x.flat())
|
||||
/*
|
||||
if (inputAgent === "plan")
|
||||
userParts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
text: PROMPT_PLAN,
|
||||
synthetic: true,
|
||||
})
|
||||
*/
|
||||
await Plugin.trigger(
|
||||
"chat.message",
|
||||
{},
|
||||
@@ -675,14 +671,14 @@ export namespace Session {
|
||||
const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true)
|
||||
if (lastSummary) msgs = msgs.filter((msg) => msg.info.id >= lastSummary.info.id)
|
||||
|
||||
if (msgs.length === 1 && !session.parentID && isDefaultTitle(session.title)) {
|
||||
if (msgs.filter((m) => m.info.role === "user").length === 1 && !session.parentID && isDefaultTitle(session.title)) {
|
||||
const small = (await Provider.getSmallModel(input.providerID)) ?? model
|
||||
generateText({
|
||||
maxOutputTokens: small.info.reasoning ? 1024 : 20,
|
||||
providerOptions: {
|
||||
[input.providerID]: {
|
||||
...small.info.options,
|
||||
...ProviderTransform.options(input.providerID, small.info.id),
|
||||
...ProviderTransform.options(input.providerID, small.info.id, input.sessionID),
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
@@ -720,6 +716,16 @@ export namespace Session {
|
||||
}
|
||||
|
||||
const agent = await Agent.get(inputAgent)
|
||||
if (agent.name === "plan") {
|
||||
msgs.at(-1)?.parts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
text: PROMPT_PLAN,
|
||||
synthetic: true,
|
||||
})
|
||||
}
|
||||
let system = SystemPrompt.header(input.providerID)
|
||||
system.push(
|
||||
...(() => {
|
||||
@@ -758,17 +764,22 @@ export namespace Session {
|
||||
sessionID: input.sessionID,
|
||||
}
|
||||
await updateMessage(assistantMsg)
|
||||
await using _ = defer(async () => {
|
||||
if (assistantMsg.time.completed) return
|
||||
await Storage.remove(`session/message/${input.sessionID}/${assistantMsg.id}`)
|
||||
await Bus.publish(MessageV2.Event.Removed, { sessionID: input.sessionID, messageID: assistantMsg.id })
|
||||
})
|
||||
const tools: Record<string, AITool> = {}
|
||||
|
||||
const processor = createProcessor(assistantMsg, model.info)
|
||||
|
||||
const enabledTools = pipe(
|
||||
agent.tools,
|
||||
mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID)),
|
||||
mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID, agent)),
|
||||
mergeDeep(input.tools ?? {}),
|
||||
)
|
||||
for (const item of await ToolRegistry.tools(input.providerID, input.modelID)) {
|
||||
if (enabledTools[item.id] === false) continue
|
||||
if (Wildcard.all(item.id, enabledTools) === false) continue
|
||||
tools[item.id] = tool({
|
||||
id: item.id as any,
|
||||
description: item.description,
|
||||
@@ -790,6 +801,7 @@ export namespace Session {
|
||||
abort: options.abortSignal!,
|
||||
messageID: assistantMsg.id,
|
||||
callID: options.toolCallId,
|
||||
agent: agent.name,
|
||||
metadata: async (val) => {
|
||||
const match = processor.partFromToolCall(options.toolCallId)
|
||||
if (match && match.state.status === "running") {
|
||||
@@ -829,7 +841,7 @@ export namespace Session {
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(await MCP.tools())) {
|
||||
if (enabledTools[key] === false) continue
|
||||
if (Wildcard.all(key, enabledTools) === false) continue
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
item.execute = async (args, opts) => {
|
||||
@@ -865,7 +877,7 @@ export namespace Session {
|
||||
: undefined,
|
||||
topP: agent.topP ?? ProviderTransform.topP(input.providerID, input.modelID),
|
||||
options: {
|
||||
...ProviderTransform.options(input.providerID, input.modelID),
|
||||
...ProviderTransform.options(input.providerID, input.modelID, input.sessionID),
|
||||
...model.info.options,
|
||||
...agent.options,
|
||||
},
|
||||
@@ -961,7 +973,7 @@ export namespace Session {
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...MessageV2.toModelMessage(msgs),
|
||||
...MessageV2.toModelMessage(msgs.filter((m) => !(m.info.role === "assistant" && m.info.error))),
|
||||
],
|
||||
tools: model.info.tool_call === false ? undefined : tools,
|
||||
model: wrapLanguageModel({
|
||||
@@ -993,6 +1005,136 @@ export namespace Session {
|
||||
return result
|
||||
}
|
||||
|
||||
export const CommandInput = z.object({
|
||||
sessionID: Identifier.schema("session"),
|
||||
agent: z.string(),
|
||||
command: z.string(),
|
||||
})
|
||||
export type CommandInput = z.infer<typeof CommandInput>
|
||||
export async function shell(input: CommandInput) {
|
||||
using abort = lock(input.sessionID)
|
||||
const msg: MessageV2.Assistant = {
|
||||
id: Identifier.ascending("message"),
|
||||
sessionID: input.sessionID,
|
||||
system: [],
|
||||
mode: input.agent,
|
||||
cost: 0,
|
||||
path: {
|
||||
cwd: App.info().path.cwd,
|
||||
root: App.info().path.root,
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
role: "assistant",
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: "",
|
||||
providerID: "",
|
||||
}
|
||||
await updateMessage(msg)
|
||||
const part: MessageV2.Part = {
|
||||
type: "tool",
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: msg.id,
|
||||
sessionID: input.sessionID,
|
||||
tool: "bash",
|
||||
callID: ulid(),
|
||||
state: {
|
||||
status: "running",
|
||||
time: {
|
||||
start: Date.now(),
|
||||
},
|
||||
input: {
|
||||
command: input.command,
|
||||
},
|
||||
},
|
||||
}
|
||||
await updatePart(part)
|
||||
const app = App.info()
|
||||
const shell = process.env["SHELL"] ?? "bash"
|
||||
const shellName = path.basename(shell)
|
||||
|
||||
const scripts: Record<string, string> = {
|
||||
nu: input.command,
|
||||
fish: `eval "${input.command}"`,
|
||||
}
|
||||
|
||||
const script =
|
||||
scripts[shellName] ??
|
||||
`[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
||||
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
||||
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
||||
eval "${input.command}"`
|
||||
|
||||
const isFishOrNu = shellName === "fish" || shellName === "nu"
|
||||
const args = isFishOrNu ? ["-c", script] : ["-c", "-l", script]
|
||||
|
||||
const proc = spawn(shell, args, {
|
||||
cwd: app.path.cwd,
|
||||
signal: abort.signal,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "dumb",
|
||||
},
|
||||
})
|
||||
|
||||
let output = ""
|
||||
|
||||
proc.stdout?.on("data", (chunk) => {
|
||||
output += chunk.toString()
|
||||
if (part.state.status === "running") {
|
||||
part.state.metadata = {
|
||||
output: output,
|
||||
description: "",
|
||||
}
|
||||
updatePart(part)
|
||||
}
|
||||
})
|
||||
|
||||
proc.stderr?.on("data", (chunk) => {
|
||||
output += chunk.toString()
|
||||
if (part.state.status === "running") {
|
||||
part.state.metadata = {
|
||||
output: output,
|
||||
description: "",
|
||||
}
|
||||
updatePart(part)
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
proc.on("close", () => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
msg.time.completed = Date.now()
|
||||
await updateMessage(msg)
|
||||
if (part.state.status === "running") {
|
||||
part.state = {
|
||||
status: "completed",
|
||||
time: {
|
||||
...part.state.time,
|
||||
end: Date.now(),
|
||||
},
|
||||
input: part.state.input,
|
||||
title: "",
|
||||
metadata: {
|
||||
output,
|
||||
description: "",
|
||||
},
|
||||
output,
|
||||
}
|
||||
await updatePart(part)
|
||||
}
|
||||
return { info: msg, parts: [part] }
|
||||
}
|
||||
|
||||
function createProcessor(assistantMsg: MessageV2.Assistant, model: ModelsDev.Model) {
|
||||
const toolcalls: Record<string, MessageV2.ToolPart> = {}
|
||||
let snapshot: string | undefined
|
||||
@@ -1007,7 +1149,7 @@ export namespace Session {
|
||||
async process(stream: StreamTextResult<Record<string, AITool>, never>) {
|
||||
try {
|
||||
let currentText: MessageV2.TextPart | undefined
|
||||
// let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
|
||||
let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
|
||||
|
||||
for await (const value of stream.fullStream) {
|
||||
log.info("part", {
|
||||
@@ -1017,7 +1159,6 @@ export namespace Session {
|
||||
case "start":
|
||||
break
|
||||
|
||||
/*
|
||||
case "reasoning-start":
|
||||
if (value.id in reasoningMap) {
|
||||
continue
|
||||
@@ -1055,7 +1196,6 @@ export namespace Session {
|
||||
delete reasoningMap[value.id]
|
||||
}
|
||||
break
|
||||
*/
|
||||
|
||||
case "tool-input-start":
|
||||
const part = await updatePart({
|
||||
@@ -1130,6 +1270,7 @@ export namespace Session {
|
||||
status: "error",
|
||||
input: value.input,
|
||||
error: (value.error as any).toString(),
|
||||
metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined,
|
||||
time: {
|
||||
start: match.state.time.start,
|
||||
end: Date.now(),
|
||||
|
||||
@@ -64,6 +64,7 @@ export namespace MessageV2 {
|
||||
status: z.literal("error"),
|
||||
input: z.record(z.any()),
|
||||
error: z.string(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
time: z.object({
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
|
||||
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
If the user asks for help or wants to give feedback inform them of the following:
|
||||
- /help: Get help with using opencode
|
||||
- To give feedback, users should report the issue at https://github.com/sst/opencode/issues
|
||||
|
||||
When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from opencode docs at https://opencode.ai
|
||||
|
||||
# Tone and style
|
||||
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
|
||||
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
|
||||
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
You should be concise, direct, and to the point.
|
||||
You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail.
|
||||
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
|
||||
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
|
||||
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
|
||||
Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
|
||||
Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
|
||||
<example>
|
||||
user: 2 + 2
|
||||
assistant: 4
|
||||
@@ -56,18 +50,18 @@ assistant: [runs ls and sees foo.c, bar.c, baz.c]
|
||||
user: which file contains the implementation of foo?
|
||||
assistant: src/foo.c
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: write tests for new feature
|
||||
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
|
||||
</example>
|
||||
When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
|
||||
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
|
||||
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface.
|
||||
|
||||
# Proactiveness
|
||||
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
|
||||
1. Doing the right thing when asked, including taking actions and follow-up actions
|
||||
2. Not surprising the user with actions you take without asking
|
||||
- Doing the right thing when asked, including taking actions and follow-up actions
|
||||
- Not surprising the user with actions you take without asking
|
||||
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
|
||||
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
|
||||
|
||||
# Following conventions
|
||||
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
|
||||
@@ -81,7 +75,7 @@ When making changes to files, first understand the file's code conventions. Mimi
|
||||
|
||||
|
||||
# Task Management
|
||||
You have access to the TodoWrite and TodoRead tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
|
||||
You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
|
||||
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
|
||||
|
||||
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
|
||||
@@ -127,27 +121,24 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
|
||||
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
|
||||
</example>
|
||||
|
||||
|
||||
# Doing tasks
|
||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||
- Use the TodoWrite tool to plan the task if required
|
||||
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
|
||||
- Implement the solution using all tools available to you
|
||||
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
|
||||
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
|
||||
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time.
|
||||
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||
|
||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
|
||||
|
||||
# Tool usage policy
|
||||
- When doing file search, prefer to use the Task tool in order to reduce context usage.
|
||||
- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description.
|
||||
|
||||
- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
|
||||
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
|
||||
|
||||
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
|
||||
|
||||
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
|
||||
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
|
||||
|
||||
IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
|
||||
|
||||
# Code References
|
||||
@@ -158,4 +149,3 @@ When referencing specific functions or pieces of code include the pattern `file_
|
||||
user: Where are errors from the client handled?
|
||||
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
|
||||
</example>
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
You are an expert AI programming assistant
|
||||
Your name is opencode
|
||||
Keep your answers short and impersonal.
|
||||
<gptAgentInstructions>
|
||||
You are a highly sophisticated coding agent with expert-level knowledge across programming languages and frameworks.
|
||||
You are an agent - you must keep going until the user's query is completely resolved, before ending your turn and yielding back to the user.
|
||||
Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.
|
||||
You MUST iterate and keep going until the problem is solved.
|
||||
You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me.
|
||||
Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
|
||||
Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.
|
||||
You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
|
||||
You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input.
|
||||
You will be given some context and attachments along with the user prompt. You can use them if they are relevant to the task, and ignore them if not.
|
||||
If you can infer the project type (languages, frameworks, and libraries) from the user's query or the context that you have, make sure to keep them in mind when making changes.
|
||||
Use multiple tools as needed, and do not give up until the task is complete or impossible.
|
||||
NEVER print codeblocks for file changes or terminal commands unless explicitly requested - use the appropriate tool.
|
||||
Do not repeat yourself after tool calls; continue from where you left off.
|
||||
You must use webfetch tool to recursively gather all information from URL's provided to you by the user, as well as any links you find in the content of those pages.
|
||||
</gptAgentInstructions>
|
||||
<structuredWorkflow>
|
||||
# Workflow
|
||||
1. Understand the problem deeply. Carefully read the issue and think critically about what is required.
|
||||
2. Investigate the codebase. Explore relevant files, search for key functions, and gather context.
|
||||
3. Develop a clear, step-by-step plan. Break down the fix into manageable,
|
||||
incremental steps - use the todo tool to track your progress.
|
||||
4. Implement the fix incrementally. Make small, testable code changes.
|
||||
5. Debug as needed. Use debugging techniques to isolate and resolve issues.
|
||||
6. Test frequently. Run tests after each change to verify correctness.
|
||||
7. Iterate until the root cause is fixed and all tests pass.
|
||||
8. Reflect and validate comprehensively. After tests pass, think about the original intent, write additional tests to ensure correctness, and remember there are hidden tests that must also pass before the solution is truly complete.
|
||||
**CRITICAL - Before ending your turn:**
|
||||
- Review and update the todo list, marking completed, skipped (with explanations), or blocked items.
|
||||
|
||||
## 1. Deeply Understand the Problem
|
||||
- Carefully read the issue and think hard about a plan to solve it before coding.
|
||||
- Break down the problem into manageable parts. Consider the following:
|
||||
- What is the expected behavior?
|
||||
- What are the edge cases?
|
||||
- What are the potential pitfalls?
|
||||
- How does this fit into the larger context of the codebase?
|
||||
- What are the dependencies and interactions with other parts of the codee
|
||||
|
||||
## 2. Codebase Investigation
|
||||
- Explore relevant files and directories.
|
||||
- Search for key functions, classes, or variables related to the issue.
|
||||
- Read and understand relevant code snippets.
|
||||
- Identify the root cause of the problem.
|
||||
- Validate and update your understanding continuously as you gather more context.
|
||||
|
||||
## 3. Develop a Detailed Plan
|
||||
- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
|
||||
- Create a todo list to track your progress.
|
||||
- Each time you check off a step, update the todo list.
|
||||
- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn and asking the user what they want to do next.
|
||||
|
||||
## 4. Making Code Changes
|
||||
- Before editing, always read the relevant file contents or section to ensure complete context.
|
||||
- Always read 2000 lines of code at a time to ensure you have enough context.
|
||||
- If a patch is not applied correctly, attempt to reapply it.
|
||||
- Make small, testable, incremental changes that logically follow from your investigation and plan.
|
||||
- Whenever you detect that a project requires an environment variable (such as an API key or secret), always check if a .env file exists in the project root. If it does not exist, automatically create a .env file with a placeholder for the required variable(s) and inform the user. Do this proactively, without waiting for the user to request it.
|
||||
|
||||
## 5. Debugging
|
||||
- Make code changes only if you have high confidence they can solve the problem
|
||||
- When debugging, try to determine the root cause rather than addressing symptoms
|
||||
- Debug for as long as needed to identify the root cause and identify a fix
|
||||
- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening
|
||||
- To test hypotheses, you can also add test statements or functions
|
||||
- Revisit your assumptions if unexpected behavior occurs.
|
||||
|
||||
</structuredWorkflow>
|
||||
<communicationGuidelines>
|
||||
Always communicate clearly and concisely in a warm and friendly yet professional tone. Use upbeat language and sprinkle in light, witty humor where appropriate.
|
||||
If the user corrects you, do not immediately assume they are right. Think deeply about their feedback and how you can incorporate it into your solution. Stand your ground if you have the evidence to support your conclusion.
|
||||
|
||||
</communicationGuidelines>
|
||||
<codeSearchInstructions>
|
||||
These instructions only apply when the question is about the user's workspace.
|
||||
First, analyze the developer's request to determine how complicated their task is. Leverage any of the tools available to you to gather the context needed to provided a complete and accurate response. Keep your search focused on the developer's request, and don't run extra tools if the developer's request clearly can be satisfied by just one.
|
||||
If the developer wants to implement a feature and they have not specified the relevant files, first break down the developer's request into smaller concepts and think about the kinds of files you need to grasp each concept.
|
||||
If you aren't sure which tool is relevant, you can call multiple tools. You can call tools repeatedly to take actions or gather as much context as needed.
|
||||
Don't make assumptions about the situation. Gather enough context to address the developer's request without going overboard.
|
||||
Think step by step:
|
||||
1. Read the provided relevant workspace information (code excerpts, file names, and symbols) to understand the user's workspace.
|
||||
2. Consider how to answer the user's prompt based on the provided information and your specialized coding knowledge. Always assume that the user is asking about the code in their workspace instead of asking a general programming question. Prefer using variables, functions, types, and classes from the workspace over those from the standard library.
|
||||
3. Generate a response that clearly and accurately answers the user's question. In your response, add fully qualified links for referenced symbols (example: [`namespace.VariableName`](path/to/file.ts)) and links for files (example: [path/to/file](path/to/file.ts)) so that the user can open them.
|
||||
Remember that you MUST add links for all referenced symbols from the workspace and fully qualify the symbol name in the link, for example: [`namespace.functionName`](path/to/util.ts).
|
||||
Remember that you MUST add links for all workspace files, for example: [path/to/file.js](path/to/file.js)
|
||||
|
||||
</codeSearchInstructions>
|
||||
<codeSearchToolUseInstructions>
|
||||
These instructions only apply when the question is about the user's workspace.
|
||||
Unless it is clear that the user's question relates to the current workspace, you should avoid using the code search tools and instead prefer to answer the user's question directly.
|
||||
Remember that you can call multiple tools in one response.
|
||||
Use semantic_search to search for high level concepts or descriptions of functionality in the user's question. This is the best place to start if you don't know where to look or the exact strings found in the codebase.
|
||||
Prefer search_workspace_symbols over grep_search when you have precise code identifiers to search for.
|
||||
Prefer grep_search over semantic_search when you have precise keywords to search for.
|
||||
The tools file_search, grep_search, and get_changed_files are deterministic and comprehensive, so do not repeatedly invoke them with the same arguments.
|
||||
|
||||
</codeSearchToolUseInstructions>
|
||||
When suggesting code changes or new content, use Markdown code blocks.
|
||||
To start a code block, use 4 backticks.
|
||||
After the backticks, add the programming language name.
|
||||
If the code modifies an existing file or should be placed at a specific location, add a line comment with 'filepath:' and the file path.
|
||||
If you want the user to decide where to place the code, do not add the file path comment.
|
||||
In the code block, use a line comment with '...existing code...' to indicate code that is already present in the file.
|
||||
````languageId
|
||||
// filepath: /path/to/file
|
||||
// ...existing code...
|
||||
{ changed code }
|
||||
// ...existing code...
|
||||
{ changed code }
|
||||
// ...existing code...
|
||||
````
|
||||
<toolUseInstructions>
|
||||
If the user is requesting a code sample, you can answer it directly without using any tools.
|
||||
When using a tool, follow the JSON schema very carefully and make sure to include ALL required properties.
|
||||
No need to ask permission before using a tool.
|
||||
NEVER say the name of a tool to a user. For example, instead of saying that you'll use the run_in_terminal tool, say "I'll run the command in a terminal".
|
||||
If you think running multiple tools can answer the user's question, prefer calling them in parallel whenever possible, but do not call semantic_search in parallel.
|
||||
If semantic_search returns the full contents of the text files in the workspace, you have all the workspace context.
|
||||
You can use the grep_search to get an overview of a file by searching for a string within that one file, instead of using read_file many times.
|
||||
If you don't know exactly the string or filename pattern you're looking for, use semantic_search to do a semantic search across the workspace.
|
||||
When invoking a tool that takes a file path, always use the absolute file path.
|
||||
Tools can be disabled by the user. You may see tools used previously in the conversation that are not currently available. Be careful to only use the tools that are currently available to you.
|
||||
</toolUseInstructions>
|
||||
|
||||
<outputFormatting>
|
||||
Use proper Markdown formatting in your answers. When referring to a filename or symbol in the user's workspace, wrap it in backticks.
|
||||
When sharing setup or run steps for the user to execute, render commands in fenced code blocks with an appropriate language tag (`bash`, `sh`, `powershell`, `python`, etc.). Keep one command per line; avoid prose-only representations of commands.
|
||||
Keep responses conversational and fun—use a brief, friendly preamble that acknowledges the goal and states what you're about to do next. Avoid literal scaffold labels like "Plan:", "Task receipt:", or "Actions:"; instead, use short paragraphs and, when helpful, concise bullet lists. Do not start with filler acknowledgements (e.g., "Sounds good", "Great", "Okay, I will…"). For multi-step tasks, maintain a lightweight checklist implicitly and weave progress into your narration.
|
||||
For section headers in your response, use level-2 Markdown headings (`##`) for top-level sections and level-3 (`###`) for subsections. Choose titles dynamically to match the task and content. Do not hard-code fixed section names; create only the sections that make sense and only when they have non-empty content. Keep headings short and descriptive (e.g., "actions taken", "files changed", "how to run", "performance", "notes"), and order them naturally (actions > artifacts > how to run > performance > notes) when applicable. You may add a tasteful emoji to a heading when it improves scannability; keep it minimal and professional. Headings must start at the beginning of the line with `## ` or `### `, have a blank line before and after, and must not be inside lists, block quotes, or code fences.
|
||||
When listing files created/edited, include a one-line purpose for each file when helpful. In performance sections, base any metrics on actual runs from this session; note the hardware/OS context and mark estimates clearly—never fabricate numbers. In "Try it" sections, keep commands copyable; comments starting with `#` are okay, but put each command on its own line.
|
||||
If platform-specific acceleration applies, include an optional speed-up fenced block with commands. Close with a concise completion summary describing what changed and how it was verified (build/tests/linters), plus any follow-ups.
|
||||
<example>
|
||||
The class `Person` is in `src/models/person.ts`.
|
||||
</example>
|
||||
Use KaTeX for math equations in your answers.
|
||||
Wrap inline math equations in $.
|
||||
Wrap more complex blocks of math equations in $$.
|
||||
|
||||
</outputFormatting>
|
||||
@@ -1,3 +1,8 @@
|
||||
<system-reminder>
|
||||
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received (for example, to make edits).
|
||||
Plan mode is active. The user indicated that they do not want you to execute yet
|
||||
-- you MUST NOT make any edits, run any non-readonly tools (including changing
|
||||
configs or making commits), or otherwise make any changes to the system. This
|
||||
supersedes any other instructions you have received (for example, to make
|
||||
edits). Bash tool must only run readonly commands
|
||||
</system-reminder>
|
||||
|
||||
|
||||
@@ -13,15 +13,16 @@ import PROMPT_GEMINI from "./prompt/gemini.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
import PROMPT_SUMMARIZE from "./prompt/summarize.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import PROMPT_CODEX from "./prompt/codex.txt"
|
||||
import PROMPT_COPILOT_GPT_5 from "./prompt/copilot-gpt-5.txt"
|
||||
|
||||
export namespace SystemPrompt {
|
||||
export function header(providerID: string) {
|
||||
if (providerID.includes("anthropic")) return [PROMPT_ANTHROPIC_SPOOF.trim()]
|
||||
return []
|
||||
}
|
||||
|
||||
export function provider(modelID: string) {
|
||||
if (modelID.includes("gpt-5")) return [PROMPT_CODEX]
|
||||
if (modelID.includes("gpt-5")) return [PROMPT_COPILOT_GPT_5]
|
||||
if (modelID.includes("gpt-") || modelID.includes("o1") || modelID.includes("o3")) return [PROMPT_BEAST]
|
||||
if (modelID.includes("gemini-")) return [PROMPT_GEMINI]
|
||||
if (modelID.includes("claude")) return [PROMPT_ANTHROPIC]
|
||||
@@ -53,28 +54,53 @@ export namespace SystemPrompt {
|
||||
]
|
||||
}
|
||||
|
||||
const CUSTOM_FILES = [
|
||||
const LOCAL_RULE_FILES = [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
const GLOBAL_RULE_FILES = [
|
||||
path.join(Global.Path.config, "AGENTS.md"),
|
||||
path.join(os.homedir(), ".claude", "CLAUDE.md"),
|
||||
]
|
||||
|
||||
export async function custom() {
|
||||
const { cwd, root } = App.info().path
|
||||
const config = await Config.get()
|
||||
const paths = new Set<string>()
|
||||
|
||||
for (const item of CUSTOM_FILES) {
|
||||
const matches = await Filesystem.findUp(item, cwd, root)
|
||||
matches.forEach((path) => paths.add(path))
|
||||
for (const localRuleFile of LOCAL_RULE_FILES) {
|
||||
const matches = await Filesystem.findUp(localRuleFile, cwd, root)
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((path) => paths.add(path))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
paths.add(path.join(Global.Path.config, "AGENTS.md"))
|
||||
paths.add(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
for (const globalRuleFile of GLOBAL_RULE_FILES) {
|
||||
if (await Bun.file(globalRuleFile).exists()) {
|
||||
paths.add(globalRuleFile)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (config.instructions) {
|
||||
for (const instruction of config.instructions) {
|
||||
const matches = await Filesystem.globUp(instruction, cwd, root).catch(() => [])
|
||||
for (let instruction of config.instructions) {
|
||||
if (instruction.startsWith("~/")) {
|
||||
instruction = path.join(os.homedir(), instruction.slice(2))
|
||||
}
|
||||
let matches: string[] = []
|
||||
if (path.isAbsolute(instruction)) {
|
||||
matches = await Array.fromAsync(
|
||||
new Bun.Glob(path.basename(instruction)).scan({
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
}),
|
||||
).catch(() => [])
|
||||
} else {
|
||||
matches = await Filesystem.globUp(instruction, cwd, root).catch(() => [])
|
||||
}
|
||||
matches.forEach((path) => paths.add(path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export namespace Snapshot {
|
||||
}
|
||||
await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow()
|
||||
const hash = await $`git --git-dir ${git} write-tree`.quiet().cwd(app.path.cwd).nothrow().text()
|
||||
log.info("tracking", { hash, cwd: app.path.cwd, git })
|
||||
return hash.trim()
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ export namespace Snapshot {
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(app.path.cwd, x)),
|
||||
.map((x) => path.join(app.path.root, x)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,46 @@
|
||||
import { z } from "zod"
|
||||
import { exec } from "child_process"
|
||||
import { text } from "stream/consumers"
|
||||
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./bash.txt"
|
||||
import { App } from "../app/app"
|
||||
import { Permission } from "../permission"
|
||||
import { Config } from "../config/config"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Log } from "../util/log"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { $ } from "bun"
|
||||
import { Agent } from "../agent/agent"
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 30000
|
||||
const MAX_OUTPUT_LENGTH = 30_000
|
||||
const DEFAULT_TIMEOUT = 1 * 60 * 1000
|
||||
const MAX_TIMEOUT = 10 * 60 * 1000
|
||||
|
||||
const log = Log.create({ service: "bash-tool" })
|
||||
|
||||
const parser = lazy(async () => {
|
||||
const { default: Parser } = await import("tree-sitter")
|
||||
const Bash = await import("tree-sitter-bash")
|
||||
const p = new Parser()
|
||||
p.setLanguage(Bash.language as any)
|
||||
return p
|
||||
try {
|
||||
const { default: Parser } = await import("tree-sitter")
|
||||
const Bash = await import("tree-sitter-bash")
|
||||
const p = new Parser()
|
||||
p.setLanguage(Bash.language as any)
|
||||
return p
|
||||
} catch (e) {
|
||||
const { default: Parser } = await import("web-tree-sitter")
|
||||
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { with: { type: "wasm" } })
|
||||
await Parser.init({
|
||||
locateFile() {
|
||||
return treeWasm
|
||||
},
|
||||
})
|
||||
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
|
||||
with: { type: "wasm" },
|
||||
})
|
||||
const bashLanguage = await Parser.Language.load(bashWasm)
|
||||
const p = new Parser()
|
||||
p.setLanguage(bashLanguage)
|
||||
return p
|
||||
}
|
||||
})
|
||||
|
||||
export const BashTool = Tool.define("bash", {
|
||||
@@ -40,20 +57,8 @@ export const BashTool = Tool.define("bash", {
|
||||
async execute(params, ctx) {
|
||||
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
|
||||
const app = App.info()
|
||||
const cfg = await Config.get()
|
||||
const tree = await parser().then((p) => p.parse(params.command))
|
||||
const permissions = (() => {
|
||||
const value = cfg.permission?.bash
|
||||
if (!value)
|
||||
return {
|
||||
"*": "allow",
|
||||
}
|
||||
if (typeof value === "string")
|
||||
return {
|
||||
"*": value,
|
||||
}
|
||||
return value
|
||||
})()
|
||||
const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)
|
||||
|
||||
let needsAsk = false
|
||||
for (const node of tree.rootNode.descendantsOfType("command")) {
|
||||
@@ -93,17 +98,10 @@ export const BashTool = Tool.define("bash", {
|
||||
|
||||
// always allow cd if it passes above check
|
||||
if (!needsAsk && command[0] !== "cd") {
|
||||
const action = (() => {
|
||||
for (const [pattern, value] of Object.entries(permissions)) {
|
||||
const match = Wildcard.match(node.text, pattern)
|
||||
log.info("checking", { text: node.text.trim(), pattern, match })
|
||||
if (match) return value
|
||||
}
|
||||
return "ask"
|
||||
})()
|
||||
const action = Wildcard.all(node.text, permissions)
|
||||
if (action === "deny") {
|
||||
throw new Error(
|
||||
"The user has specifically restricted access to this command, you are not allowed to execute it.",
|
||||
`The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${JSON.stringify(permissions)}`,
|
||||
)
|
||||
}
|
||||
if (action === "ask") needsAsk = true
|
||||
@@ -113,6 +111,7 @@ export const BashTool = Tool.define("bash", {
|
||||
if (needsAsk) {
|
||||
await Permission.ask({
|
||||
type: "bash",
|
||||
pattern: params.command,
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
callID: ctx.callID,
|
||||
@@ -126,12 +125,38 @@ export const BashTool = Tool.define("bash", {
|
||||
const process = exec(params.command, {
|
||||
cwd: app.path.cwd,
|
||||
signal: ctx.abort,
|
||||
maxBuffer: MAX_OUTPUT_LENGTH,
|
||||
timeout,
|
||||
})
|
||||
|
||||
const stdoutPromise = text(process.stdout!)
|
||||
const stderrPromise = text(process.stderr!)
|
||||
let output = ""
|
||||
|
||||
// Initialize metadata with empty output
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: "",
|
||||
description: params.description,
|
||||
},
|
||||
})
|
||||
|
||||
process.stdout?.on("data", (chunk) => {
|
||||
output += chunk.toString()
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: output,
|
||||
description: params.description,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
process.stderr?.on("data", (chunk) => {
|
||||
output += chunk.toString()
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: output,
|
||||
description: params.description,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
process.on("close", () => {
|
||||
@@ -139,18 +164,27 @@ export const BashTool = Tool.define("bash", {
|
||||
})
|
||||
})
|
||||
|
||||
const stdout = await stdoutPromise
|
||||
const stderr = await stderrPromise
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
output: output,
|
||||
exit: process.exitCode,
|
||||
description: params.description,
|
||||
},
|
||||
})
|
||||
|
||||
if (output.length > MAX_OUTPUT_LENGTH) {
|
||||
output = output.slice(0, MAX_OUTPUT_LENGTH)
|
||||
output += "\n\n(Output was truncated due to length limit)"
|
||||
}
|
||||
|
||||
return {
|
||||
title: params.command,
|
||||
metadata: {
|
||||
stderr,
|
||||
stdout,
|
||||
output,
|
||||
exit: process.exitCode,
|
||||
description: params.description,
|
||||
},
|
||||
output: [`<stdout>`, stdout ?? "", `</stdout>`, `<stderr>`, stderr ?? "", `</stderr>`].join("\n"),
|
||||
output,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -59,10 +59,6 @@ When the user asks you to create a new git commit, follow these steps carefully:
|
||||
|
||||
3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel:
|
||||
- Add relevant untracked files to the staging area.
|
||||
- Create the commit with a message ending with:
|
||||
🤖 Generated with [opencode](https://opencode.ai)
|
||||
|
||||
Co-Authored-By: opencode <noreply@opencode.ai>
|
||||
- Run git status to make sure the commit succeeded.
|
||||
|
||||
4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
|
||||
@@ -76,17 +72,6 @@ Important notes:
|
||||
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
||||
- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
|
||||
- Return an empty response - the user will see the git output directly
|
||||
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
|
||||
<example>
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Commit message here.
|
||||
|
||||
🤖 Generated with [opencode](https://opencode.ai)
|
||||
|
||||
Co-Authored-By: opencode <noreply@opencode.ai>
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
# Creating pull requests
|
||||
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
|
||||
@@ -125,14 +110,6 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>
|
||||
|
||||
## Test plan
|
||||
[Checklist of TODOs for testing the pull request...]
|
||||
|
||||
🤖 Generated with [opencode](https://opencode.ai)
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
Important:
|
||||
- NEVER update the git config
|
||||
- Return the PR URL when you're done, so the user can see it
|
||||
|
||||
@@ -14,8 +14,8 @@ import { App } from "../app/app"
|
||||
import { File } from "../file"
|
||||
import { Bus } from "../bus"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Config } from "../config/config"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Agent } from "../agent/agent"
|
||||
|
||||
export const EditTool = Tool.define("edit", {
|
||||
description: DESCRIPTION,
|
||||
@@ -40,7 +40,7 @@ export const EditTool = Tool.define("edit", {
|
||||
throw new Error(`File ${filePath} is not in the current working directory`)
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
const agent = await Agent.get(ctx.agent)
|
||||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
@@ -48,7 +48,7 @@ export const EditTool = Tool.define("edit", {
|
||||
if (params.oldString === "") {
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
if (cfg.permission?.edit === "ask") {
|
||||
if (agent.permission.edit === "ask") {
|
||||
await Permission.ask({
|
||||
type: "edit",
|
||||
sessionID: ctx.sessionID,
|
||||
@@ -77,12 +77,13 @@ export const EditTool = Tool.define("edit", {
|
||||
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
|
||||
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
if (cfg.permission?.edit === "ask") {
|
||||
if (agent.permission.edit === "ask") {
|
||||
await Permission.ask({
|
||||
type: "edit",
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
callID: ctx.callID,
|
||||
pattern: filePath,
|
||||
title: "Edit this file: " + filePath,
|
||||
metadata: {
|
||||
filePath,
|
||||
@@ -188,7 +189,10 @@ export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
matchEndIndex += originalLines[i + k].length
|
||||
if (k < searchLines.length - 1) {
|
||||
matchEndIndex += 1 // Add newline character except for the last line
|
||||
}
|
||||
}
|
||||
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ReadTool = Tool.define("read", {
|
||||
filepath = path.join(process.cwd(), filepath)
|
||||
}
|
||||
const app = App.info()
|
||||
if (!Filesystem.contains(app.path.cwd, filepath)) {
|
||||
if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(app.path.cwd, filepath)) {
|
||||
throw new Error(`File ${filepath} is not in the current working directory`)
|
||||
}
|
||||
|
||||
@@ -98,8 +98,6 @@ function isImageFile(filePath: string): string | false {
|
||||
return "GIF"
|
||||
case ".bmp":
|
||||
return "BMP"
|
||||
case ".svg":
|
||||
return "SVG"
|
||||
case ".webp":
|
||||
return "WebP"
|
||||
default:
|
||||
|
||||
@@ -7,7 +7,6 @@ Usage:
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any lines longer than 2000 characters will be truncated
|
||||
- Results are returned using cat -n format, with line numbers starting at 1
|
||||
- This tool allows opencode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as opencode is a multimodal LLM.
|
||||
- This tool cannot read binary files, including images
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
||||
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
|
||||
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WriteTool } from "./write"
|
||||
import { InvalidTool } from "./invalid"
|
||||
import { Config } from "../config/config"
|
||||
import type { Agent } from "../agent/agent"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const ALL = [
|
||||
@@ -66,23 +66,31 @@ export namespace ToolRegistry {
|
||||
return result
|
||||
}
|
||||
|
||||
export async function enabled(_providerID: string, _modelID: string): Promise<Record<string, boolean>> {
|
||||
const cfg = await Config.get()
|
||||
export async function enabled(
|
||||
_providerID: string,
|
||||
modelID: string,
|
||||
agent: Agent.Info,
|
||||
): Promise<Record<string, boolean>> {
|
||||
const result: Record<string, boolean> = {}
|
||||
result["patch"] = false
|
||||
|
||||
if (cfg.permission?.edit === "deny") {
|
||||
if (agent.permission.edit === "deny") {
|
||||
result["edit"] = false
|
||||
result["patch"] = false
|
||||
result["write"] = false
|
||||
}
|
||||
if (cfg?.permission?.bash === "deny") {
|
||||
if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) {
|
||||
result["bash"] = false
|
||||
}
|
||||
if (cfg?.permission?.webfetch === "deny") {
|
||||
if (agent.permission.webfetch === "deny") {
|
||||
result["webfetch"] = false
|
||||
}
|
||||
|
||||
if (modelID.includes("qwen")) {
|
||||
result["todowrite"] = false
|
||||
result["todoread"] = false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ export const TaskTool = Tool.define("task", async () => {
|
||||
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const session = await Session.create(ctx.sessionID)
|
||||
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
|
||||
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
|
||||
const agent = await Agent.get(params.subagent_type)
|
||||
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
|
||||
const session = await Session.create(ctx.sessionID, params.description + ` (@${agent.name} subagent)`)
|
||||
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
|
||||
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
|
||||
const messageID = Identifier.ascending("message")
|
||||
const parts: Record<string, MessageV2.ToolPart> = {}
|
||||
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Launch a new agent to handle complex, multi-step tasks autonomously.
|
||||
Launch a new agent to handle complex, multi-step tasks autonomously.
|
||||
|
||||
Available agent types and the tools they have access to:
|
||||
{agents}
|
||||
@@ -23,7 +23,7 @@ Usage notes:
|
||||
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
|
||||
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
|
||||
|
||||
Example usage:
|
||||
Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above):
|
||||
|
||||
<example_agent_descriptions>
|
||||
"code-reviewer": use this agent after you are done writing a signficant piece of code
|
||||
@@ -48,7 +48,7 @@ function isPrime(n) {
|
||||
Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code
|
||||
</commentary>
|
||||
assistant: Now let me use the code-reviewer agent to review the code
|
||||
assistant: Uses the Task tool to launch the with the code-reviewer agent
|
||||
assistant: Uses the Task tool to launch the code-reviewer agent
|
||||
</example>
|
||||
|
||||
<example>
|
||||
|
||||
@@ -1,53 +1,70 @@
|
||||
import Parser from "tree-sitter";
|
||||
import Bash from "tree-sitter-bash";
|
||||
const parser = async () => {
|
||||
try {
|
||||
const { default: Parser } = await import("tree-sitter")
|
||||
const Bash = await import("tree-sitter-bash")
|
||||
const p = new Parser()
|
||||
p.setLanguage(Bash.language as any)
|
||||
return p
|
||||
} catch (e) {
|
||||
const { default: Parser } = await import("web-tree-sitter")
|
||||
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { with: { type: "wasm" } })
|
||||
await Parser.init({
|
||||
locateFile() {
|
||||
return treeWasm
|
||||
},
|
||||
})
|
||||
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
|
||||
with: { type: "wasm" },
|
||||
})
|
||||
const bashLanguage = await Parser.Language.load(bashWasm)
|
||||
const p = new Parser()
|
||||
p.setLanguage(bashLanguage)
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(Bash.language as any);
|
||||
const sourceCode = `cd --foo foo/bar && echo "hello" && cd ../baz`
|
||||
|
||||
const sourceCode = `cd --foo foo/bar && echo "hello" && cd ../baz`;
|
||||
|
||||
const tree = parser.parse(sourceCode);
|
||||
const tree = await parser().then((p) => p.parse(sourceCode))
|
||||
|
||||
// Function to extract commands and arguments
|
||||
function extractCommands(
|
||||
node: any,
|
||||
): Array<{ command: string; args: string[] }> {
|
||||
const commands: Array<{ command: string; args: string[] }> = [];
|
||||
function extractCommands(node: any): Array<{ command: string; args: string[] }> {
|
||||
const commands: Array<{ command: string; args: string[] }> = []
|
||||
|
||||
function traverse(node: any) {
|
||||
if (node.type === "command") {
|
||||
const commandNode = node.child(0);
|
||||
const commandNode = node.child(0)
|
||||
if (commandNode) {
|
||||
const command = commandNode.text;
|
||||
const args: string[] = [];
|
||||
const command = commandNode.text
|
||||
const args: string[] = []
|
||||
|
||||
// Extract arguments
|
||||
for (let i = 1; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
const child = node.child(i)
|
||||
if (child && child.type === "word") {
|
||||
args.push(child.text);
|
||||
args.push(child.text)
|
||||
}
|
||||
}
|
||||
|
||||
commands.push({ command, args });
|
||||
commands.push({ command, args })
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse children
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
traverse(node.child(i));
|
||||
traverse(node.child(i))
|
||||
}
|
||||
}
|
||||
|
||||
traverse(node);
|
||||
return commands;
|
||||
traverse(node)
|
||||
return commands
|
||||
}
|
||||
|
||||
// Extract and display commands
|
||||
console.log("Source code: " + sourceCode);
|
||||
const commands = extractCommands(tree.rootNode);
|
||||
console.log("Extracted commands:");
|
||||
console.log("Source code: " + sourceCode)
|
||||
const commands = extractCommands(tree.rootNode)
|
||||
console.log("Extracted commands:")
|
||||
commands.forEach((cmd, index) => {
|
||||
console.log(`${index + 1}. Command: ${cmd.command}`);
|
||||
console.log(` Args: [${cmd.args.join(", ")}]`);
|
||||
});
|
||||
console.log(`${index + 1}. Command: ${cmd.command}`)
|
||||
console.log(` Args: [${cmd.args.join(", ")}]`)
|
||||
})
|
||||
|
||||
@@ -7,8 +7,10 @@ export namespace Tool {
|
||||
export type Context<M extends Metadata = Metadata> = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
callID?: string
|
||||
abort: AbortSignal
|
||||
extra?: { [key: string]: any }
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
}
|
||||
export interface Info<Parameters extends StandardSchemaV1 = StandardSchemaV1, M extends Metadata = Metadata> {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const WebFetchTool = Tool.define("webfetch", {
|
||||
if (cfg.permission?.webfetch === "ask")
|
||||
await Permission.ask({
|
||||
type: "webfetch",
|
||||
pattern: params.url,
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
callID: ctx.callID,
|
||||
|
||||
@@ -8,8 +8,8 @@ import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Config } from "../config/config"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Agent } from "../agent/agent"
|
||||
|
||||
export const WriteTool = Tool.define("write", {
|
||||
description: DESCRIPTION,
|
||||
@@ -28,8 +28,8 @@ export const WriteTool = Tool.define("write", {
|
||||
const exists = await file.exists()
|
||||
if (exists) await FileTime.assert(ctx.sessionID, filepath)
|
||||
|
||||
const cfg = await Config.get()
|
||||
if (cfg.permission?.edit === "ask")
|
||||
const agent = await Agent.get(ctx.agent)
|
||||
if (agent.permission.edit === "ask")
|
||||
await Permission.ask({
|
||||
type: "write",
|
||||
sessionID: ctx.sessionID,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function defer<T extends () => void | Promise<void>>(
|
||||
fn: T,
|
||||
): T extends () => Promise<void> ? { [Symbol.asyncDispose]: () => Promise<void> } : { [Symbol.dispose]: () => void } {
|
||||
return {
|
||||
[Symbol.dispose]() {
|
||||
fn()
|
||||
},
|
||||
[Symbol.asyncDispose]() {
|
||||
return Promise.resolve(fn())
|
||||
},
|
||||
} as any
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sortBy, pipe } from "remeda"
|
||||
|
||||
export namespace Wildcard {
|
||||
export function match(str: string, pattern: string) {
|
||||
const regex = new RegExp(
|
||||
@@ -11,4 +13,16 @@ export namespace Wildcard {
|
||||
)
|
||||
return regex.test(str)
|
||||
}
|
||||
|
||||
export function all(input: string, patterns: Record<string, any>) {
|
||||
const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]))
|
||||
let result = undefined
|
||||
for (const [pattern, value] of sorted) {
|
||||
if (match(input, pattern)) {
|
||||
result = value
|
||||
continue
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
toolCallID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
}
|
||||
@@ -27,13 +28,13 @@ describe("tool.bash", () => {
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.exit).toBe(0)
|
||||
expect(result.metadata.stdout).toContain("test")
|
||||
expect(result.metadata.output).toContain("test")
|
||||
})
|
||||
})
|
||||
|
||||
test("cd ../ should fail outside of project root", async () => {
|
||||
await App.provide({ cwd: projectRoot }, async () => {
|
||||
await expect(
|
||||
expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cd ../",
|
||||
|
||||
@@ -8,6 +8,7 @@ const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
toolCallID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import type { Event, createOpencodeClient, App, Model, Provider, Permission, UserMessage, Part } from "@opencode-ai/sdk"
|
||||
import type {
|
||||
Event,
|
||||
createOpencodeClient,
|
||||
App,
|
||||
Model,
|
||||
Provider,
|
||||
Permission,
|
||||
UserMessage,
|
||||
Part,
|
||||
Auth,
|
||||
} from "@opencode-ai/sdk"
|
||||
import type { BunShell } from "./shell"
|
||||
|
||||
export type PluginInput = {
|
||||
@@ -10,6 +20,49 @@ export type Plugin = (input: PluginInput) => Promise<Hooks>
|
||||
|
||||
export interface Hooks {
|
||||
event?: (input: { event: Event }) => Promise<void>
|
||||
auth?: {
|
||||
provider: string
|
||||
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
|
||||
methods: (
|
||||
| {
|
||||
type: "oauth"
|
||||
label: string
|
||||
authorize(): Promise<
|
||||
{ url: string; instructions: string } & (
|
||||
| {
|
||||
method: "auto"
|
||||
callback(): Promise<
|
||||
| {
|
||||
type: "success"
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
}
|
||||
| {
|
||||
type: "failed"
|
||||
}
|
||||
>
|
||||
}
|
||||
| {
|
||||
method: "code"
|
||||
callback(code: string): Promise<
|
||||
| {
|
||||
type: "success"
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
}
|
||||
| {
|
||||
type: "failed"
|
||||
}
|
||||
>
|
||||
}
|
||||
)
|
||||
>
|
||||
}
|
||||
| { type: "api"; label: string }
|
||||
)[]
|
||||
}
|
||||
/**
|
||||
* Called when a new message is received
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
configured_endpoints: 34
|
||||
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-b86cf7bb8df4f60ebe8b8f51e281c8076cfdccc8554178c1b78beca4b025f0ff.yml
|
||||
openapi_spec_hash: 47633b7481d91708643ea7b43fffffe6
|
||||
config_hash: bd7f6435ed0c0005f373b5526c07a055
|
||||
configured_endpoints: 39
|
||||
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-e4b6496e5f2c68fa8b3ea1b88e40041eaf5ce2652001344df80bf130675d1766.yml
|
||||
openapi_spec_hash: df474311dc9e4a89cd483bd8b8d971d8
|
||||
config_hash: eab3723c4c2232a6ba1821151259d6da
|
||||
|
||||
@@ -110,16 +110,20 @@ Response Types:
|
||||
|
||||
Methods:
|
||||
|
||||
- <code title="post /session">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.New">New</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /session">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.New">New</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionNewParams">SessionNewParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="patch /session/{id}">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Update">Update</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionUpdateParams">SessionUpdateParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="get /session">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.List">List</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="delete /session/{id}">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Delete">Delete</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /session/{id}/abort">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Abort">Abort</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Chat">Chat</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionChatParams">SessionChatParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AssistantMessage">AssistantMessage</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="get /session/{id}/children">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Children">Children</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="get /session/{id}">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Get">Get</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /session/{id}/init">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Init">Init</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionInitParams">SessionInitParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="get /session/{id}/message/{messageID}">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Message">Message</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, messageID <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionMessageResponse">SessionMessageResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="get /session/{id}/message">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Messages">Messages</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) ([]<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionMessagesResponse">SessionMessagesResponse</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /session/{id}/revert">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Revert">Revert</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionRevertParams">SessionRevertParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Share">Share</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /session/{id}/shell">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Shell">Shell</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionShellParams">SessionShellParams</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#AssistantMessage">AssistantMessage</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /session/{id}/summarize">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Summarize">Summarize</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionSummarizeParams">SessionSummarizeParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /session/{id}/unrevert">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Unrevert">Unrevert</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="delete /session/{id}/share">client.Session.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SessionService.Unshare">Unshare</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, id <a href="https://pkg.go.dev/builtin#string">string</a>) (<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
@@ -145,4 +149,5 @@ Methods:
|
||||
- <code title="post /tui/open-models">client.Tui.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TuiService.OpenModels">OpenModels</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /tui/open-sessions">client.Tui.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TuiService.OpenSessions">OpenSessions</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /tui/open-themes">client.Tui.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TuiService.OpenThemes">OpenThemes</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /tui/show-toast">client.Tui.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TuiService.ShowToast">ShowToast</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>, body <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TuiShowToastParams">TuiShowToastParams</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
- <code title="post /tui/submit-prompt">client.Tui.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#TuiService.SubmitPrompt">SubmitPrompt</a>(ctx <a href="https://pkg.go.dev/context">context</a>.<a href="https://pkg.go.dev/context#Context">Context</a>) (<a href="https://pkg.go.dev/builtin#bool">bool</a>, <a href="https://pkg.go.dev/builtin#error">error</a>)</code>
|
||||
|
||||
+87
-9
@@ -72,21 +72,27 @@ func (r *AppService) Providers(ctx context.Context, opts ...option.RequestOption
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
Mode AgentMode `json:"mode,required"`
|
||||
Name string `json:"name,required"`
|
||||
Tools map[string]bool `json:"tools,required"`
|
||||
Description string `json:"description"`
|
||||
Model AgentModel `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
TopP float64 `json:"topP"`
|
||||
JSON agentJSON `json:"-"`
|
||||
BuiltIn bool `json:"builtIn,required"`
|
||||
Mode AgentMode `json:"mode,required"`
|
||||
Name string `json:"name,required"`
|
||||
Options map[string]interface{} `json:"options,required"`
|
||||
Permission AgentPermission `json:"permission,required"`
|
||||
Tools map[string]bool `json:"tools,required"`
|
||||
Description string `json:"description"`
|
||||
Model AgentModel `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
TopP float64 `json:"topP"`
|
||||
JSON agentJSON `json:"-"`
|
||||
}
|
||||
|
||||
// agentJSON contains the JSON metadata for the struct [Agent]
|
||||
type agentJSON struct {
|
||||
BuiltIn apijson.Field
|
||||
Mode apijson.Field
|
||||
Name apijson.Field
|
||||
Options apijson.Field
|
||||
Permission apijson.Field
|
||||
Tools apijson.Field
|
||||
Description apijson.Field
|
||||
Model apijson.Field
|
||||
@@ -121,6 +127,78 @@ func (r AgentMode) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type AgentPermission struct {
|
||||
Bash map[string]AgentPermissionBash `json:"bash,required"`
|
||||
Edit AgentPermissionEdit `json:"edit,required"`
|
||||
Webfetch AgentPermissionWebfetch `json:"webfetch"`
|
||||
JSON agentPermissionJSON `json:"-"`
|
||||
}
|
||||
|
||||
// agentPermissionJSON contains the JSON metadata for the struct [AgentPermission]
|
||||
type agentPermissionJSON struct {
|
||||
Bash apijson.Field
|
||||
Edit apijson.Field
|
||||
Webfetch apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *AgentPermission) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r agentPermissionJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type AgentPermissionBash string
|
||||
|
||||
const (
|
||||
AgentPermissionBashAsk AgentPermissionBash = "ask"
|
||||
AgentPermissionBashAllow AgentPermissionBash = "allow"
|
||||
AgentPermissionBashDeny AgentPermissionBash = "deny"
|
||||
)
|
||||
|
||||
func (r AgentPermissionBash) IsKnown() bool {
|
||||
switch r {
|
||||
case AgentPermissionBashAsk, AgentPermissionBashAllow, AgentPermissionBashDeny:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type AgentPermissionEdit string
|
||||
|
||||
const (
|
||||
AgentPermissionEditAsk AgentPermissionEdit = "ask"
|
||||
AgentPermissionEditAllow AgentPermissionEdit = "allow"
|
||||
AgentPermissionEditDeny AgentPermissionEdit = "deny"
|
||||
)
|
||||
|
||||
func (r AgentPermissionEdit) IsKnown() bool {
|
||||
switch r {
|
||||
case AgentPermissionEditAsk, AgentPermissionEditAllow, AgentPermissionEditDeny:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type AgentPermissionWebfetch string
|
||||
|
||||
const (
|
||||
AgentPermissionWebfetchAsk AgentPermissionWebfetch = "ask"
|
||||
AgentPermissionWebfetchAllow AgentPermissionWebfetch = "allow"
|
||||
AgentPermissionWebfetchDeny AgentPermissionWebfetch = "deny"
|
||||
)
|
||||
|
||||
func (r AgentPermissionWebfetch) IsKnown() bool {
|
||||
switch r {
|
||||
case AgentPermissionWebfetchAsk, AgentPermissionWebfetchAllow, AgentPermissionWebfetchDeny:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type AgentModel struct {
|
||||
ModelID string `json:"modelID,required"`
|
||||
ProviderID string `json:"providerID,required"`
|
||||
|
||||
+744
-100
File diff suppressed because it is too large
Load Diff
+121
-123
@@ -54,13 +54,13 @@ type EventListResponse struct {
|
||||
// [EventListResponseEventMessageRemovedProperties],
|
||||
// [EventListResponseEventMessagePartUpdatedProperties],
|
||||
// [EventListResponseEventMessagePartRemovedProperties],
|
||||
// [EventListResponseEventStorageWriteProperties],
|
||||
// [EventListResponseEventFileEditedProperties], [interface{}], [Permission],
|
||||
// [EventListResponseEventStorageWriteProperties], [Permission],
|
||||
// [EventListResponseEventPermissionRepliedProperties],
|
||||
// [EventListResponseEventFileEditedProperties],
|
||||
// [EventListResponseEventSessionUpdatedProperties],
|
||||
// [EventListResponseEventSessionDeletedProperties],
|
||||
// [EventListResponseEventSessionIdleProperties],
|
||||
// [EventListResponseEventSessionErrorProperties],
|
||||
// [EventListResponseEventSessionErrorProperties], [interface{}],
|
||||
// [EventListResponseEventFileWatcherUpdatedProperties],
|
||||
// [EventListResponseEventIdeInstalledProperties].
|
||||
Properties interface{} `json:"properties,required"`
|
||||
@@ -100,12 +100,11 @@ func (r *EventListResponse) UnmarshalJSON(data []byte) (err error) {
|
||||
// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved],
|
||||
// [EventListResponseEventMessagePartUpdated],
|
||||
// [EventListResponseEventMessagePartRemoved],
|
||||
// [EventListResponseEventStorageWrite], [EventListResponseEventFileEdited],
|
||||
// [EventListResponseEventServerConnected],
|
||||
// [EventListResponseEventPermissionUpdated],
|
||||
// [EventListResponseEventPermissionReplied],
|
||||
// [EventListResponseEventStorageWrite], [EventListResponseEventPermissionUpdated],
|
||||
// [EventListResponseEventPermissionReplied], [EventListResponseEventFileEdited],
|
||||
// [EventListResponseEventSessionUpdated], [EventListResponseEventSessionDeleted],
|
||||
// [EventListResponseEventSessionIdle], [EventListResponseEventSessionError],
|
||||
// [EventListResponseEventServerConnected],
|
||||
// [EventListResponseEventFileWatcherUpdated],
|
||||
// [EventListResponseEventIdeInstalled].
|
||||
func (r EventListResponse) AsUnion() EventListResponseUnion {
|
||||
@@ -117,12 +116,11 @@ func (r EventListResponse) AsUnion() EventListResponseUnion {
|
||||
// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved],
|
||||
// [EventListResponseEventMessagePartUpdated],
|
||||
// [EventListResponseEventMessagePartRemoved],
|
||||
// [EventListResponseEventStorageWrite], [EventListResponseEventFileEdited],
|
||||
// [EventListResponseEventServerConnected],
|
||||
// [EventListResponseEventPermissionUpdated],
|
||||
// [EventListResponseEventPermissionReplied],
|
||||
// [EventListResponseEventStorageWrite], [EventListResponseEventPermissionUpdated],
|
||||
// [EventListResponseEventPermissionReplied], [EventListResponseEventFileEdited],
|
||||
// [EventListResponseEventSessionUpdated], [EventListResponseEventSessionDeleted],
|
||||
// [EventListResponseEventSessionIdle], [EventListResponseEventSessionError],
|
||||
// [EventListResponseEventServerConnected],
|
||||
// [EventListResponseEventFileWatcherUpdated] or
|
||||
// [EventListResponseEventIdeInstalled].
|
||||
type EventListResponseUnion interface {
|
||||
@@ -168,16 +166,6 @@ func init() {
|
||||
Type: reflect.TypeOf(EventListResponseEventStorageWrite{}),
|
||||
DiscriminatorValue: "storage.write",
|
||||
},
|
||||
apijson.UnionVariant{
|
||||
TypeFilter: gjson.JSON,
|
||||
Type: reflect.TypeOf(EventListResponseEventFileEdited{}),
|
||||
DiscriminatorValue: "file.edited",
|
||||
},
|
||||
apijson.UnionVariant{
|
||||
TypeFilter: gjson.JSON,
|
||||
Type: reflect.TypeOf(EventListResponseEventServerConnected{}),
|
||||
DiscriminatorValue: "server.connected",
|
||||
},
|
||||
apijson.UnionVariant{
|
||||
TypeFilter: gjson.JSON,
|
||||
Type: reflect.TypeOf(EventListResponseEventPermissionUpdated{}),
|
||||
@@ -188,6 +176,11 @@ func init() {
|
||||
Type: reflect.TypeOf(EventListResponseEventPermissionReplied{}),
|
||||
DiscriminatorValue: "permission.replied",
|
||||
},
|
||||
apijson.UnionVariant{
|
||||
TypeFilter: gjson.JSON,
|
||||
Type: reflect.TypeOf(EventListResponseEventFileEdited{}),
|
||||
DiscriminatorValue: "file.edited",
|
||||
},
|
||||
apijson.UnionVariant{
|
||||
TypeFilter: gjson.JSON,
|
||||
Type: reflect.TypeOf(EventListResponseEventSessionUpdated{}),
|
||||
@@ -208,6 +201,11 @@ func init() {
|
||||
Type: reflect.TypeOf(EventListResponseEventSessionError{}),
|
||||
DiscriminatorValue: "session.error",
|
||||
},
|
||||
apijson.UnionVariant{
|
||||
TypeFilter: gjson.JSON,
|
||||
Type: reflect.TypeOf(EventListResponseEventServerConnected{}),
|
||||
DiscriminatorValue: "server.connected",
|
||||
},
|
||||
apijson.UnionVariant{
|
||||
TypeFilter: gjson.JSON,
|
||||
Type: reflect.TypeOf(EventListResponseEventFileWatcherUpdated{}),
|
||||
@@ -651,105 +649,6 @@ func (r EventListResponseEventStorageWriteType) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type EventListResponseEventFileEdited struct {
|
||||
Properties EventListResponseEventFileEditedProperties `json:"properties,required"`
|
||||
Type EventListResponseEventFileEditedType `json:"type,required"`
|
||||
JSON eventListResponseEventFileEditedJSON `json:"-"`
|
||||
}
|
||||
|
||||
// eventListResponseEventFileEditedJSON contains the JSON metadata for the struct
|
||||
// [EventListResponseEventFileEdited]
|
||||
type eventListResponseEventFileEditedJSON struct {
|
||||
Properties apijson.Field
|
||||
Type apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *EventListResponseEventFileEdited) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r eventListResponseEventFileEditedJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
func (r EventListResponseEventFileEdited) implementsEventListResponse() {}
|
||||
|
||||
type EventListResponseEventFileEditedProperties struct {
|
||||
File string `json:"file,required"`
|
||||
JSON eventListResponseEventFileEditedPropertiesJSON `json:"-"`
|
||||
}
|
||||
|
||||
// eventListResponseEventFileEditedPropertiesJSON contains the JSON metadata for
|
||||
// the struct [EventListResponseEventFileEditedProperties]
|
||||
type eventListResponseEventFileEditedPropertiesJSON struct {
|
||||
File apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *EventListResponseEventFileEditedProperties) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r eventListResponseEventFileEditedPropertiesJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type EventListResponseEventFileEditedType string
|
||||
|
||||
const (
|
||||
EventListResponseEventFileEditedTypeFileEdited EventListResponseEventFileEditedType = "file.edited"
|
||||
)
|
||||
|
||||
func (r EventListResponseEventFileEditedType) IsKnown() bool {
|
||||
switch r {
|
||||
case EventListResponseEventFileEditedTypeFileEdited:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type EventListResponseEventServerConnected struct {
|
||||
Properties interface{} `json:"properties,required"`
|
||||
Type EventListResponseEventServerConnectedType `json:"type,required"`
|
||||
JSON eventListResponseEventServerConnectedJSON `json:"-"`
|
||||
}
|
||||
|
||||
// eventListResponseEventServerConnectedJSON contains the JSON metadata for the
|
||||
// struct [EventListResponseEventServerConnected]
|
||||
type eventListResponseEventServerConnectedJSON struct {
|
||||
Properties apijson.Field
|
||||
Type apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *EventListResponseEventServerConnected) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r eventListResponseEventServerConnectedJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
func (r EventListResponseEventServerConnected) implementsEventListResponse() {}
|
||||
|
||||
type EventListResponseEventServerConnectedType string
|
||||
|
||||
const (
|
||||
EventListResponseEventServerConnectedTypeServerConnected EventListResponseEventServerConnectedType = "server.connected"
|
||||
)
|
||||
|
||||
func (r EventListResponseEventServerConnectedType) IsKnown() bool {
|
||||
switch r {
|
||||
case EventListResponseEventServerConnectedTypeServerConnected:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type EventListResponseEventPermissionUpdated struct {
|
||||
Properties Permission `json:"properties,required"`
|
||||
Type EventListResponseEventPermissionUpdatedType `json:"type,required"`
|
||||
@@ -853,6 +752,66 @@ func (r EventListResponseEventPermissionRepliedType) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type EventListResponseEventFileEdited struct {
|
||||
Properties EventListResponseEventFileEditedProperties `json:"properties,required"`
|
||||
Type EventListResponseEventFileEditedType `json:"type,required"`
|
||||
JSON eventListResponseEventFileEditedJSON `json:"-"`
|
||||
}
|
||||
|
||||
// eventListResponseEventFileEditedJSON contains the JSON metadata for the struct
|
||||
// [EventListResponseEventFileEdited]
|
||||
type eventListResponseEventFileEditedJSON struct {
|
||||
Properties apijson.Field
|
||||
Type apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *EventListResponseEventFileEdited) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r eventListResponseEventFileEditedJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
func (r EventListResponseEventFileEdited) implementsEventListResponse() {}
|
||||
|
||||
type EventListResponseEventFileEditedProperties struct {
|
||||
File string `json:"file,required"`
|
||||
JSON eventListResponseEventFileEditedPropertiesJSON `json:"-"`
|
||||
}
|
||||
|
||||
// eventListResponseEventFileEditedPropertiesJSON contains the JSON metadata for
|
||||
// the struct [EventListResponseEventFileEditedProperties]
|
||||
type eventListResponseEventFileEditedPropertiesJSON struct {
|
||||
File apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *EventListResponseEventFileEditedProperties) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r eventListResponseEventFileEditedPropertiesJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type EventListResponseEventFileEditedType string
|
||||
|
||||
const (
|
||||
EventListResponseEventFileEditedTypeFileEdited EventListResponseEventFileEditedType = "file.edited"
|
||||
)
|
||||
|
||||
func (r EventListResponseEventFileEditedType) IsKnown() bool {
|
||||
switch r {
|
||||
case EventListResponseEventFileEditedTypeFileEdited:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type EventListResponseEventSessionUpdated struct {
|
||||
Properties EventListResponseEventSessionUpdatedProperties `json:"properties,required"`
|
||||
Type EventListResponseEventSessionUpdatedType `json:"type,required"`
|
||||
@@ -1229,6 +1188,45 @@ func (r EventListResponseEventSessionErrorType) IsKnown() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type EventListResponseEventServerConnected struct {
|
||||
Properties interface{} `json:"properties,required"`
|
||||
Type EventListResponseEventServerConnectedType `json:"type,required"`
|
||||
JSON eventListResponseEventServerConnectedJSON `json:"-"`
|
||||
}
|
||||
|
||||
// eventListResponseEventServerConnectedJSON contains the JSON metadata for the
|
||||
// struct [EventListResponseEventServerConnected]
|
||||
type eventListResponseEventServerConnectedJSON struct {
|
||||
Properties apijson.Field
|
||||
Type apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *EventListResponseEventServerConnected) UnmarshalJSON(data []byte) (err error) {
|
||||
return apijson.UnmarshalRoot(data, r)
|
||||
}
|
||||
|
||||
func (r eventListResponseEventServerConnectedJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
func (r EventListResponseEventServerConnected) implementsEventListResponse() {}
|
||||
|
||||
type EventListResponseEventServerConnectedType string
|
||||
|
||||
const (
|
||||
EventListResponseEventServerConnectedTypeServerConnected EventListResponseEventServerConnectedType = "server.connected"
|
||||
)
|
||||
|
||||
func (r EventListResponseEventServerConnectedType) IsKnown() bool {
|
||||
switch r {
|
||||
case EventListResponseEventServerConnectedTypeServerConnected:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type EventListResponseEventFileWatcherUpdated struct {
|
||||
Properties EventListResponseEventFileWatcherUpdatedProperties `json:"properties,required"`
|
||||
Type EventListResponseEventFileWatcherUpdatedType `json:"type,required"`
|
||||
@@ -1376,21 +1374,21 @@ const (
|
||||
EventListResponseTypeMessagePartUpdated EventListResponseType = "message.part.updated"
|
||||
EventListResponseTypeMessagePartRemoved EventListResponseType = "message.part.removed"
|
||||
EventListResponseTypeStorageWrite EventListResponseType = "storage.write"
|
||||
EventListResponseTypeFileEdited EventListResponseType = "file.edited"
|
||||
EventListResponseTypeServerConnected EventListResponseType = "server.connected"
|
||||
EventListResponseTypePermissionUpdated EventListResponseType = "permission.updated"
|
||||
EventListResponseTypePermissionReplied EventListResponseType = "permission.replied"
|
||||
EventListResponseTypeFileEdited EventListResponseType = "file.edited"
|
||||
EventListResponseTypeSessionUpdated EventListResponseType = "session.updated"
|
||||
EventListResponseTypeSessionDeleted EventListResponseType = "session.deleted"
|
||||
EventListResponseTypeSessionIdle EventListResponseType = "session.idle"
|
||||
EventListResponseTypeSessionError EventListResponseType = "session.error"
|
||||
EventListResponseTypeServerConnected EventListResponseType = "server.connected"
|
||||
EventListResponseTypeFileWatcherUpdated EventListResponseType = "file.watcher.updated"
|
||||
EventListResponseTypeIdeInstalled EventListResponseType = "ide.installed"
|
||||
)
|
||||
|
||||
func (r EventListResponseType) IsKnown() bool {
|
||||
switch r {
|
||||
case EventListResponseTypeInstallationUpdated, EventListResponseTypeLspClientDiagnostics, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeMessagePartRemoved, EventListResponseTypeStorageWrite, EventListResponseTypeFileEdited, EventListResponseTypeServerConnected, EventListResponseTypePermissionUpdated, EventListResponseTypePermissionReplied, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionIdle, EventListResponseTypeSessionError, EventListResponseTypeFileWatcherUpdated, EventListResponseTypeIdeInstalled:
|
||||
case EventListResponseTypeInstallationUpdated, EventListResponseTypeLspClientDiagnostics, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeMessagePartRemoved, EventListResponseTypeStorageWrite, EventListResponseTypePermissionUpdated, EventListResponseTypePermissionReplied, EventListResponseTypeFileEdited, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionIdle, EventListResponseTypeSessionError, EventListResponseTypeServerConnected, EventListResponseTypeFileWatcherUpdated, EventListResponseTypeIdeInstalled:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
+141
-65
@@ -39,10 +39,22 @@ func NewSessionService(opts ...option.RequestOption) (r *SessionService) {
|
||||
}
|
||||
|
||||
// Create a new session
|
||||
func (r *SessionService) New(ctx context.Context, opts ...option.RequestOption) (res *Session, err error) {
|
||||
func (r *SessionService) New(ctx context.Context, body SessionNewParams, opts ...option.RequestOption) (res *Session, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
path := "session"
|
||||
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...)
|
||||
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...)
|
||||
return
|
||||
}
|
||||
|
||||
// Update session properties
|
||||
func (r *SessionService) Update(ctx context.Context, id string, body SessionUpdateParams, opts ...option.RequestOption) (res *Session, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
if id == "" {
|
||||
err = errors.New("missing required id parameter")
|
||||
return
|
||||
}
|
||||
path := fmt.Sprintf("session/%s", id)
|
||||
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPatch, path, body, &res, opts...)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -90,6 +102,30 @@ func (r *SessionService) Chat(ctx context.Context, id string, body SessionChatPa
|
||||
return
|
||||
}
|
||||
|
||||
// Get a session's children
|
||||
func (r *SessionService) Children(ctx context.Context, id string, opts ...option.RequestOption) (res *[]Session, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
if id == "" {
|
||||
err = errors.New("missing required id parameter")
|
||||
return
|
||||
}
|
||||
path := fmt.Sprintf("session/%s/children", id)
|
||||
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
|
||||
return
|
||||
}
|
||||
|
||||
// Get session
|
||||
func (r *SessionService) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *Session, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
if id == "" {
|
||||
err = errors.New("missing required id parameter")
|
||||
return
|
||||
}
|
||||
path := fmt.Sprintf("session/%s", id)
|
||||
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
|
||||
return
|
||||
}
|
||||
|
||||
// Analyze the app and create an AGENTS.md file
|
||||
func (r *SessionService) Init(ctx context.Context, id string, body SessionInitParams, opts ...option.RequestOption) (res *bool, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
@@ -154,6 +190,18 @@ func (r *SessionService) Share(ctx context.Context, id string, opts ...option.Re
|
||||
return
|
||||
}
|
||||
|
||||
// Run a shell command
|
||||
func (r *SessionService) Shell(ctx context.Context, id string, body SessionShellParams, opts ...option.RequestOption) (res *AssistantMessage, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
if id == "" {
|
||||
err = errors.New("missing required id parameter")
|
||||
return
|
||||
}
|
||||
path := fmt.Sprintf("session/%s/shell", id)
|
||||
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...)
|
||||
return
|
||||
}
|
||||
|
||||
// Summarize the session
|
||||
func (r *SessionService) Summarize(ctx context.Context, id string, body SessionSummarizeParams, opts ...option.RequestOption) (res *bool, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
@@ -964,11 +1012,11 @@ type Part struct {
|
||||
// This field can have the runtime type of [[]string].
|
||||
Files interface{} `json:"files"`
|
||||
Hash string `json:"hash"`
|
||||
Mime string `json:"mime"`
|
||||
Name string `json:"name"`
|
||||
// This field can have the runtime type of [map[string]interface{}].
|
||||
ProviderMetadata interface{} `json:"providerMetadata"`
|
||||
Snapshot string `json:"snapshot"`
|
||||
Metadata interface{} `json:"metadata"`
|
||||
Mime string `json:"mime"`
|
||||
Name string `json:"name"`
|
||||
Snapshot string `json:"snapshot"`
|
||||
// This field can have the runtime type of [FilePartSource], [AgentPartSource].
|
||||
Source interface{} `json:"source"`
|
||||
// This field can have the runtime type of [ToolPartState].
|
||||
@@ -987,29 +1035,29 @@ type Part struct {
|
||||
|
||||
// partJSON contains the JSON metadata for the struct [Part]
|
||||
type partJSON struct {
|
||||
ID apijson.Field
|
||||
MessageID apijson.Field
|
||||
SessionID apijson.Field
|
||||
Type apijson.Field
|
||||
CallID apijson.Field
|
||||
Cost apijson.Field
|
||||
Filename apijson.Field
|
||||
Files apijson.Field
|
||||
Hash apijson.Field
|
||||
Mime apijson.Field
|
||||
Name apijson.Field
|
||||
ProviderMetadata apijson.Field
|
||||
Snapshot apijson.Field
|
||||
Source apijson.Field
|
||||
State apijson.Field
|
||||
Synthetic apijson.Field
|
||||
Text apijson.Field
|
||||
Time apijson.Field
|
||||
Tokens apijson.Field
|
||||
Tool apijson.Field
|
||||
URL apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
ID apijson.Field
|
||||
MessageID apijson.Field
|
||||
SessionID apijson.Field
|
||||
Type apijson.Field
|
||||
CallID apijson.Field
|
||||
Cost apijson.Field
|
||||
Filename apijson.Field
|
||||
Files apijson.Field
|
||||
Hash apijson.Field
|
||||
Metadata apijson.Field
|
||||
Mime apijson.Field
|
||||
Name apijson.Field
|
||||
Snapshot apijson.Field
|
||||
Source apijson.Field
|
||||
State apijson.Field
|
||||
Synthetic apijson.Field
|
||||
Text apijson.Field
|
||||
Time apijson.Field
|
||||
Tokens apijson.Field
|
||||
Tool apijson.Field
|
||||
URL apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r partJSON) RawJSON() string {
|
||||
@@ -1163,27 +1211,27 @@ func (r PartType) IsKnown() bool {
|
||||
}
|
||||
|
||||
type ReasoningPart struct {
|
||||
ID string `json:"id,required"`
|
||||
MessageID string `json:"messageID,required"`
|
||||
SessionID string `json:"sessionID,required"`
|
||||
Text string `json:"text,required"`
|
||||
Type ReasoningPartType `json:"type,required"`
|
||||
ProviderMetadata map[string]interface{} `json:"providerMetadata"`
|
||||
Time ReasoningPartTime `json:"time"`
|
||||
JSON reasoningPartJSON `json:"-"`
|
||||
ID string `json:"id,required"`
|
||||
MessageID string `json:"messageID,required"`
|
||||
SessionID string `json:"sessionID,required"`
|
||||
Text string `json:"text,required"`
|
||||
Time ReasoningPartTime `json:"time,required"`
|
||||
Type ReasoningPartType `json:"type,required"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
JSON reasoningPartJSON `json:"-"`
|
||||
}
|
||||
|
||||
// reasoningPartJSON contains the JSON metadata for the struct [ReasoningPart]
|
||||
type reasoningPartJSON struct {
|
||||
ID apijson.Field
|
||||
MessageID apijson.Field
|
||||
SessionID apijson.Field
|
||||
Text apijson.Field
|
||||
Type apijson.Field
|
||||
ProviderMetadata apijson.Field
|
||||
Time apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
ID apijson.Field
|
||||
MessageID apijson.Field
|
||||
SessionID apijson.Field
|
||||
Text apijson.Field
|
||||
Time apijson.Field
|
||||
Type apijson.Field
|
||||
Metadata apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
|
||||
func (r *ReasoningPart) UnmarshalJSON(data []byte) (err error) {
|
||||
@@ -1196,20 +1244,6 @@ func (r reasoningPartJSON) RawJSON() string {
|
||||
|
||||
func (r ReasoningPart) implementsPart() {}
|
||||
|
||||
type ReasoningPartType string
|
||||
|
||||
const (
|
||||
ReasoningPartTypeReasoning ReasoningPartType = "reasoning"
|
||||
)
|
||||
|
||||
func (r ReasoningPartType) IsKnown() bool {
|
||||
switch r {
|
||||
case ReasoningPartTypeReasoning:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type ReasoningPartTime struct {
|
||||
Start float64 `json:"start,required"`
|
||||
End float64 `json:"end"`
|
||||
@@ -1233,6 +1267,20 @@ func (r reasoningPartTimeJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type ReasoningPartType string
|
||||
|
||||
const (
|
||||
ReasoningPartTypeReasoning ReasoningPartType = "reasoning"
|
||||
)
|
||||
|
||||
func (r ReasoningPartType) IsKnown() bool {
|
||||
switch r {
|
||||
case ReasoningPartTypeReasoning:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id,required"`
|
||||
Time SessionTime `json:"time,required"`
|
||||
@@ -1999,11 +2047,12 @@ func (r toolStateCompletedTimeJSON) RawJSON() string {
|
||||
}
|
||||
|
||||
type ToolStateError struct {
|
||||
Error string `json:"error,required"`
|
||||
Input map[string]interface{} `json:"input,required"`
|
||||
Status ToolStateErrorStatus `json:"status,required"`
|
||||
Time ToolStateErrorTime `json:"time,required"`
|
||||
JSON toolStateErrorJSON `json:"-"`
|
||||
Error string `json:"error,required"`
|
||||
Input map[string]interface{} `json:"input,required"`
|
||||
Status ToolStateErrorStatus `json:"status,required"`
|
||||
Time ToolStateErrorTime `json:"time,required"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
JSON toolStateErrorJSON `json:"-"`
|
||||
}
|
||||
|
||||
// toolStateErrorJSON contains the JSON metadata for the struct [ToolStateError]
|
||||
@@ -2012,6 +2061,7 @@ type toolStateErrorJSON struct {
|
||||
Input apijson.Field
|
||||
Status apijson.Field
|
||||
Time apijson.Field
|
||||
Metadata apijson.Field
|
||||
raw string
|
||||
ExtraFields map[string]apijson.Field
|
||||
}
|
||||
@@ -2274,6 +2324,23 @@ func (r sessionMessagesResponseJSON) RawJSON() string {
|
||||
return r.raw
|
||||
}
|
||||
|
||||
type SessionNewParams struct {
|
||||
ParentID param.Field[string] `json:"parentID"`
|
||||
Title param.Field[string] `json:"title"`
|
||||
}
|
||||
|
||||
func (r SessionNewParams) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type SessionUpdateParams struct {
|
||||
Title param.Field[string] `json:"title"`
|
||||
}
|
||||
|
||||
func (r SessionUpdateParams) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type SessionChatParams struct {
|
||||
ModelID param.Field[string] `json:"modelID,required"`
|
||||
Parts param.Field[[]SessionChatParamsPartUnion] `json:"parts,required"`
|
||||
@@ -2348,6 +2415,15 @@ func (r SessionRevertParams) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type SessionShellParams struct {
|
||||
Agent param.Field[string] `json:"agent,required"`
|
||||
Command param.Field[string] `json:"command,required"`
|
||||
}
|
||||
|
||||
func (r SessionShellParams) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type SessionSummarizeParams struct {
|
||||
ModelID param.Field[string] `json:"modelID,required"`
|
||||
ProviderID param.Field[string] `json:"providerID,required"`
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/sst/opencode-sdk-go/option"
|
||||
)
|
||||
|
||||
func TestSessionNew(t *testing.T) {
|
||||
func TestSessionNewWithOptionalParams(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
|
||||
@@ -25,7 +25,38 @@ func TestSessionNew(t *testing.T) {
|
||||
client := opencode.NewClient(
|
||||
option.WithBaseURL(baseURL),
|
||||
)
|
||||
_, err := client.Session.New(context.TODO())
|
||||
_, err := client.Session.New(context.TODO(), opencode.SessionNewParams{
|
||||
ParentID: opencode.F("parentID"),
|
||||
Title: opencode.F("title"),
|
||||
})
|
||||
if err != nil {
|
||||
var apierr *opencode.Error
|
||||
if errors.As(err, &apierr) {
|
||||
t.Log(string(apierr.DumpRequest(true)))
|
||||
}
|
||||
t.Fatalf("err should be nil: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionUpdateWithOptionalParams(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
|
||||
baseURL = envURL
|
||||
}
|
||||
if !testutil.CheckTestServer(t, baseURL) {
|
||||
return
|
||||
}
|
||||
client := opencode.NewClient(
|
||||
option.WithBaseURL(baseURL),
|
||||
)
|
||||
_, err := client.Session.Update(
|
||||
context.TODO(),
|
||||
"id",
|
||||
opencode.SessionUpdateParams{
|
||||
Title: opencode.F("title"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
var apierr *opencode.Error
|
||||
if errors.As(err, &apierr) {
|
||||
@@ -146,6 +177,50 @@ func TestSessionChatWithOptionalParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionChildren(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
|
||||
baseURL = envURL
|
||||
}
|
||||
if !testutil.CheckTestServer(t, baseURL) {
|
||||
return
|
||||
}
|
||||
client := opencode.NewClient(
|
||||
option.WithBaseURL(baseURL),
|
||||
)
|
||||
_, err := client.Session.Children(context.TODO(), "id")
|
||||
if err != nil {
|
||||
var apierr *opencode.Error
|
||||
if errors.As(err, &apierr) {
|
||||
t.Log(string(apierr.DumpRequest(true)))
|
||||
}
|
||||
t.Fatalf("err should be nil: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionGet(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
|
||||
baseURL = envURL
|
||||
}
|
||||
if !testutil.CheckTestServer(t, baseURL) {
|
||||
return
|
||||
}
|
||||
client := opencode.NewClient(
|
||||
option.WithBaseURL(baseURL),
|
||||
)
|
||||
_, err := client.Session.Get(context.TODO(), "id")
|
||||
if err != nil {
|
||||
var apierr *opencode.Error
|
||||
if errors.As(err, &apierr) {
|
||||
t.Log(string(apierr.DumpRequest(true)))
|
||||
}
|
||||
t.Fatalf("err should be nil: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInit(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
@@ -275,6 +350,35 @@ func TestSessionShare(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionShell(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
|
||||
baseURL = envURL
|
||||
}
|
||||
if !testutil.CheckTestServer(t, baseURL) {
|
||||
return
|
||||
}
|
||||
client := opencode.NewClient(
|
||||
option.WithBaseURL(baseURL),
|
||||
)
|
||||
_, err := client.Session.Shell(
|
||||
context.TODO(),
|
||||
"id",
|
||||
opencode.SessionShellParams{
|
||||
Agent: opencode.F("agent"),
|
||||
Command: opencode.F("command"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
var apierr *opencode.Error
|
||||
if errors.As(err, &apierr) {
|
||||
t.Log(string(apierr.DumpRequest(true)))
|
||||
}
|
||||
t.Fatalf("err should be nil: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSummarize(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
|
||||
+36
-1
@@ -47,7 +47,7 @@ func (r *TuiService) ClearPrompt(ctx context.Context, opts ...option.RequestOpti
|
||||
return
|
||||
}
|
||||
|
||||
// Execute a TUI command (e.g. switch_agent)
|
||||
// Execute a TUI command (e.g. agent_cycle)
|
||||
func (r *TuiService) ExecuteCommand(ctx context.Context, body TuiExecuteCommandParams, opts ...option.RequestOption) (res *bool, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
path := "tui/execute-command"
|
||||
@@ -87,6 +87,14 @@ func (r *TuiService) OpenThemes(ctx context.Context, opts ...option.RequestOptio
|
||||
return
|
||||
}
|
||||
|
||||
// Show a toast notification in the TUI
|
||||
func (r *TuiService) ShowToast(ctx context.Context, body TuiShowToastParams, opts ...option.RequestOption) (res *bool, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
path := "tui/show-toast"
|
||||
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...)
|
||||
return
|
||||
}
|
||||
|
||||
// Submit the prompt
|
||||
func (r *TuiService) SubmitPrompt(ctx context.Context, opts ...option.RequestOption) (res *bool, err error) {
|
||||
opts = append(r.Options[:], opts...)
|
||||
@@ -110,3 +118,30 @@ type TuiExecuteCommandParams struct {
|
||||
func (r TuiExecuteCommandParams) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type TuiShowToastParams struct {
|
||||
Message param.Field[string] `json:"message,required"`
|
||||
Variant param.Field[TuiShowToastParamsVariant] `json:"variant,required"`
|
||||
Title param.Field[string] `json:"title"`
|
||||
}
|
||||
|
||||
func (r TuiShowToastParams) MarshalJSON() (data []byte, err error) {
|
||||
return apijson.MarshalRoot(r)
|
||||
}
|
||||
|
||||
type TuiShowToastParamsVariant string
|
||||
|
||||
const (
|
||||
TuiShowToastParamsVariantInfo TuiShowToastParamsVariant = "info"
|
||||
TuiShowToastParamsVariantSuccess TuiShowToastParamsVariant = "success"
|
||||
TuiShowToastParamsVariantWarning TuiShowToastParamsVariant = "warning"
|
||||
TuiShowToastParamsVariantError TuiShowToastParamsVariant = "error"
|
||||
)
|
||||
|
||||
func (r TuiShowToastParamsVariant) IsKnown() bool {
|
||||
switch r {
|
||||
case TuiShowToastParamsVariantInfo, TuiShowToastParamsVariantSuccess, TuiShowToastParamsVariantWarning, TuiShowToastParamsVariantError:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -171,6 +171,32 @@ func TestTuiOpenThemes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuiShowToastWithOptionalParams(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
|
||||
baseURL = envURL
|
||||
}
|
||||
if !testutil.CheckTestServer(t, baseURL) {
|
||||
return
|
||||
}
|
||||
client := opencode.NewClient(
|
||||
option.WithBaseURL(baseURL),
|
||||
)
|
||||
_, err := client.Tui.ShowToast(context.TODO(), opencode.TuiShowToastParams{
|
||||
Message: opencode.F("message"),
|
||||
Variant: opencode.F(opencode.TuiShowToastParamsVariantInfo),
|
||||
Title: opencode.F("title"),
|
||||
})
|
||||
if err != nil {
|
||||
var apierr *opencode.Error
|
||||
if errors.As(err, &apierr) {
|
||||
t.Log(string(apierr.DumpRequest(true)))
|
||||
}
|
||||
t.Fatalf("err should be nil: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuiSubmitPrompt(t *testing.T) {
|
||||
t.Skip("skipped: tests are disabled for the time being")
|
||||
baseURL := "http://localhost:4010"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -19,6 +19,10 @@ import type {
|
||||
SessionDeleteResponses,
|
||||
SessionGetData,
|
||||
SessionGetResponses,
|
||||
SessionUpdateData,
|
||||
SessionUpdateResponses,
|
||||
SessionChildrenData,
|
||||
SessionChildrenResponses,
|
||||
SessionInitData,
|
||||
SessionInitResponses,
|
||||
SessionAbortData,
|
||||
@@ -35,6 +39,8 @@ import type {
|
||||
SessionChatResponses,
|
||||
SessionMessageData,
|
||||
SessionMessageResponses,
|
||||
SessionShellData,
|
||||
SessionShellResponses,
|
||||
SessionRevertData,
|
||||
SessionRevertResponses,
|
||||
SessionUnrevertData,
|
||||
@@ -73,6 +79,11 @@ import type {
|
||||
TuiClearPromptResponses,
|
||||
TuiExecuteCommandData,
|
||||
TuiExecuteCommandResponses,
|
||||
TuiShowToastData,
|
||||
TuiShowToastResponses,
|
||||
AuthSetData,
|
||||
AuthSetResponses,
|
||||
AuthSetErrors,
|
||||
} from "./types.gen.js"
|
||||
import { client as _heyApiClient } from "./client.gen.js"
|
||||
|
||||
@@ -201,6 +212,10 @@ class Session extends _HeyApiClient {
|
||||
return (options?.client ?? this._client).post<SessionCreateResponses, SessionCreateErrors, ThrowOnError>({
|
||||
url: "/session",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -224,6 +239,30 @@ class Session extends _HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session properties
|
||||
*/
|
||||
public update<ThrowOnError extends boolean = false>(options: Options<SessionUpdateData, ThrowOnError>) {
|
||||
return (options.client ?? this._client).patch<SessionUpdateResponses, unknown, ThrowOnError>({
|
||||
url: "/session/{id}",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session's children
|
||||
*/
|
||||
public children<ThrowOnError extends boolean = false>(options: Options<SessionChildrenData, ThrowOnError>) {
|
||||
return (options.client ?? this._client).get<SessionChildrenResponses, unknown, ThrowOnError>({
|
||||
url: "/session/{id}/children",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze the app and create an AGENTS.md file
|
||||
*/
|
||||
@@ -316,6 +355,20 @@ class Session extends _HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a shell command
|
||||
*/
|
||||
public shell<ThrowOnError extends boolean = false>(options: Options<SessionShellData, ThrowOnError>) {
|
||||
return (options.client ?? this._client).post<SessionShellResponses, unknown, ThrowOnError>({
|
||||
url: "/session/{id}/shell",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert a message
|
||||
*/
|
||||
@@ -471,7 +524,7 @@ class Tui extends _HeyApiClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a TUI command (e.g. switch_agent)
|
||||
* Execute a TUI command (e.g. agent_cycle)
|
||||
*/
|
||||
public executeCommand<ThrowOnError extends boolean = false>(options?: Options<TuiExecuteCommandData, ThrowOnError>) {
|
||||
return (options?.client ?? this._client).post<TuiExecuteCommandResponses, unknown, ThrowOnError>({
|
||||
@@ -483,6 +536,36 @@ class Tui extends _HeyApiClient {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification in the TUI
|
||||
*/
|
||||
public showToast<ThrowOnError extends boolean = false>(options?: Options<TuiShowToastData, ThrowOnError>) {
|
||||
return (options?.client ?? this._client).post<TuiShowToastResponses, unknown, ThrowOnError>({
|
||||
url: "/tui/show-toast",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class Auth extends _HeyApiClient {
|
||||
/**
|
||||
* Set authentication credentials
|
||||
*/
|
||||
public set<ThrowOnError extends boolean = false>(options: Options<AuthSetData, ThrowOnError>) {
|
||||
return (options.client ?? this._client).put<AuthSetResponses, AuthSetErrors, ThrowOnError>({
|
||||
url: "/auth/{id}",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class OpencodeClient extends _HeyApiClient {
|
||||
@@ -512,4 +595,5 @@ export class OpencodeClient extends _HeyApiClient {
|
||||
find = new Find({ client: this._client })
|
||||
file = new File({ client: this._client })
|
||||
tui = new Tui({ client: this._client })
|
||||
auth = new Auth({ client: this._client })
|
||||
}
|
||||
|
||||
@@ -22,18 +22,15 @@ export type Event =
|
||||
| ({
|
||||
type: "storage.write"
|
||||
} & EventStorageWrite)
|
||||
| ({
|
||||
type: "file.edited"
|
||||
} & EventFileEdited)
|
||||
| ({
|
||||
type: "server.connected"
|
||||
} & EventServerConnected)
|
||||
| ({
|
||||
type: "permission.updated"
|
||||
} & EventPermissionUpdated)
|
||||
| ({
|
||||
type: "permission.replied"
|
||||
} & EventPermissionReplied)
|
||||
| ({
|
||||
type: "file.edited"
|
||||
} & EventFileEdited)
|
||||
| ({
|
||||
type: "session.updated"
|
||||
} & EventSessionUpdated)
|
||||
@@ -46,6 +43,9 @@ export type Event =
|
||||
| ({
|
||||
type: "session.error"
|
||||
} & EventSessionError)
|
||||
| ({
|
||||
type: "server.connected"
|
||||
} & EventServerConnected)
|
||||
| ({
|
||||
type: "file.watcher.updated"
|
||||
} & EventFileWatcherUpdated)
|
||||
@@ -54,14 +54,14 @@ export type Event =
|
||||
} & EventIdeInstalled)
|
||||
|
||||
export type EventInstallationUpdated = {
|
||||
type: string
|
||||
type: "installation.updated"
|
||||
properties: {
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventLspClientDiagnostics = {
|
||||
type: string
|
||||
type: "lsp.client.diagnostics"
|
||||
properties: {
|
||||
serverID: string
|
||||
path: string
|
||||
@@ -69,7 +69,7 @@ export type EventLspClientDiagnostics = {
|
||||
}
|
||||
|
||||
export type EventMessageUpdated = {
|
||||
type: string
|
||||
type: "message.updated"
|
||||
properties: {
|
||||
info: Message
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export type Message =
|
||||
export type UserMessage = {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: string
|
||||
role: "user"
|
||||
time: {
|
||||
created: number
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export type UserMessage = {
|
||||
export type AssistantMessage = {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: string
|
||||
role: "assistant"
|
||||
time: {
|
||||
created: number
|
||||
completed?: number
|
||||
@@ -135,7 +135,7 @@ export type AssistantMessage = {
|
||||
}
|
||||
|
||||
export type ProviderAuthError = {
|
||||
name: string
|
||||
name: "ProviderAuthError"
|
||||
data: {
|
||||
providerID: string
|
||||
message: string
|
||||
@@ -143,28 +143,28 @@ export type ProviderAuthError = {
|
||||
}
|
||||
|
||||
export type UnknownError = {
|
||||
name: string
|
||||
name: "UnknownError"
|
||||
data: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type MessageOutputLengthError = {
|
||||
name: string
|
||||
name: "MessageOutputLengthError"
|
||||
data: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type MessageAbortedError = {
|
||||
name: string
|
||||
name: "MessageAbortedError"
|
||||
data: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventMessageRemoved = {
|
||||
type: string
|
||||
type: "message.removed"
|
||||
properties: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -172,7 +172,7 @@ export type EventMessageRemoved = {
|
||||
}
|
||||
|
||||
export type EventMessagePartUpdated = {
|
||||
type: string
|
||||
type: "message.part.updated"
|
||||
properties: {
|
||||
part: Part
|
||||
}
|
||||
@@ -182,6 +182,9 @@ export type Part =
|
||||
| ({
|
||||
type: "text"
|
||||
} & TextPart)
|
||||
| ({
|
||||
type: "reasoning"
|
||||
} & ReasoningPart)
|
||||
| ({
|
||||
type: "file"
|
||||
} & FilePart)
|
||||
@@ -208,7 +211,7 @@ export type TextPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean
|
||||
time?: {
|
||||
@@ -217,11 +220,26 @@ export type TextPart = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ReasoningPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "reasoning"
|
||||
text: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
time: {
|
||||
start: number
|
||||
end?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type FilePart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
type: "file"
|
||||
mime: string
|
||||
filename?: string
|
||||
url: string
|
||||
@@ -238,7 +256,7 @@ export type FilePartSource =
|
||||
|
||||
export type FileSource = {
|
||||
text: FilePartSourceText
|
||||
type: string
|
||||
type: "file"
|
||||
path: string
|
||||
}
|
||||
|
||||
@@ -250,7 +268,7 @@ export type FilePartSourceText = {
|
||||
|
||||
export type SymbolSource = {
|
||||
text: FilePartSourceText
|
||||
type: string
|
||||
type: "symbol"
|
||||
path: string
|
||||
range: Range
|
||||
name: string
|
||||
@@ -272,7 +290,7 @@ export type ToolPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: ToolState
|
||||
@@ -293,11 +311,11 @@ export type ToolState =
|
||||
} & ToolStateError)
|
||||
|
||||
export type ToolStatePending = {
|
||||
status: string
|
||||
status: "pending"
|
||||
}
|
||||
|
||||
export type ToolStateRunning = {
|
||||
status: string
|
||||
status: "running"
|
||||
input?: unknown
|
||||
title?: string
|
||||
metadata?: {
|
||||
@@ -309,7 +327,7 @@ export type ToolStateRunning = {
|
||||
}
|
||||
|
||||
export type ToolStateCompleted = {
|
||||
status: string
|
||||
status: "completed"
|
||||
input: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -325,11 +343,14 @@ export type ToolStateCompleted = {
|
||||
}
|
||||
|
||||
export type ToolStateError = {
|
||||
status: string
|
||||
status: "error"
|
||||
input: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
error: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
time: {
|
||||
start: number
|
||||
end: number
|
||||
@@ -340,14 +361,14 @@ export type StepStartPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
type: "step-start"
|
||||
}
|
||||
|
||||
export type StepFinishPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
type: "step-finish"
|
||||
cost: number
|
||||
tokens: {
|
||||
input: number
|
||||
@@ -364,7 +385,7 @@ export type SnapshotPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
type: "snapshot"
|
||||
snapshot: string
|
||||
}
|
||||
|
||||
@@ -372,7 +393,7 @@ export type PatchPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
type: "patch"
|
||||
hash: string
|
||||
files: Array<string>
|
||||
}
|
||||
@@ -381,7 +402,7 @@ export type AgentPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
type: "agent"
|
||||
name: string
|
||||
source?: {
|
||||
value: string
|
||||
@@ -391,7 +412,7 @@ export type AgentPart = {
|
||||
}
|
||||
|
||||
export type EventMessagePartRemoved = {
|
||||
type: string
|
||||
type: "message.part.removed"
|
||||
properties: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -400,29 +421,15 @@ export type EventMessagePartRemoved = {
|
||||
}
|
||||
|
||||
export type EventStorageWrite = {
|
||||
type: string
|
||||
type: "storage.write"
|
||||
properties: {
|
||||
key: string
|
||||
content?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileEdited = {
|
||||
type: string
|
||||
properties: {
|
||||
file: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventServerConnected = {
|
||||
type: string
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventPermissionUpdated = {
|
||||
type: string
|
||||
type: "permission.updated"
|
||||
properties: Permission
|
||||
}
|
||||
|
||||
@@ -443,7 +450,7 @@ export type Permission = {
|
||||
}
|
||||
|
||||
export type EventPermissionReplied = {
|
||||
type: string
|
||||
type: "permission.replied"
|
||||
properties: {
|
||||
sessionID: string
|
||||
permissionID: string
|
||||
@@ -451,8 +458,15 @@ export type EventPermissionReplied = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileEdited = {
|
||||
type: "file.edited"
|
||||
properties: {
|
||||
file: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionUpdated = {
|
||||
type: string
|
||||
type: "session.updated"
|
||||
properties: {
|
||||
info: Session
|
||||
}
|
||||
@@ -479,21 +493,21 @@ export type Session = {
|
||||
}
|
||||
|
||||
export type EventSessionDeleted = {
|
||||
type: string
|
||||
type: "session.deleted"
|
||||
properties: {
|
||||
info: Session
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionIdle = {
|
||||
type: string
|
||||
type: "session.idle"
|
||||
properties: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionError = {
|
||||
type: string
|
||||
type: "session.error"
|
||||
properties: {
|
||||
sessionID?: string
|
||||
error?:
|
||||
@@ -512,16 +526,23 @@ export type EventSessionError = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventServerConnected = {
|
||||
type: "server.connected"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileWatcherUpdated = {
|
||||
type: string
|
||||
type: "file.watcher.updated"
|
||||
properties: {
|
||||
file: string
|
||||
event: string
|
||||
event: "rename" | "change"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventIdeInstalled = {
|
||||
type: string
|
||||
type: "ide.installed"
|
||||
properties: {
|
||||
ide: string
|
||||
}
|
||||
@@ -551,7 +572,19 @@ export type Config = {
|
||||
* Theme name to use for the interface
|
||||
*/
|
||||
theme?: string
|
||||
/**
|
||||
* Custom keybind configurations
|
||||
*/
|
||||
keybinds?: KeybindsConfig
|
||||
/**
|
||||
* TUI specific settings
|
||||
*/
|
||||
tui?: {
|
||||
/**
|
||||
* TUI scroll speed
|
||||
*/
|
||||
scroll_speed: number
|
||||
}
|
||||
plugin?: Array<string>
|
||||
snapshot?: boolean
|
||||
/**
|
||||
@@ -609,7 +642,7 @@ export type Config = {
|
||||
env?: Array<string>
|
||||
id?: string
|
||||
npm?: string
|
||||
models: {
|
||||
models?: {
|
||||
[key: string]: {
|
||||
id?: string
|
||||
name?: string
|
||||
@@ -665,7 +698,7 @@ export type Config = {
|
||||
lsp?: {
|
||||
[key: string]:
|
||||
| {
|
||||
disabled: boolean
|
||||
disabled: true
|
||||
}
|
||||
| {
|
||||
command: Array<string>
|
||||
@@ -683,14 +716,18 @@ export type Config = {
|
||||
* Additional instruction files or patterns to include
|
||||
*/
|
||||
instructions?: Array<string>
|
||||
/**
|
||||
* @deprecated Always uses stretch layout.
|
||||
*/
|
||||
layout?: LayoutConfig
|
||||
permission?: {
|
||||
edit?: string
|
||||
edit?: "ask" | "allow" | "deny"
|
||||
bash?:
|
||||
| string
|
||||
| ("ask" | "allow" | "deny")
|
||||
| {
|
||||
[key: string]: string
|
||||
[key: string]: "ask" | "allow" | "deny"
|
||||
}
|
||||
webfetch?: "ask" | "allow" | "deny"
|
||||
}
|
||||
experimental?: {
|
||||
hook?: {
|
||||
@@ -722,25 +759,29 @@ export type KeybindsConfig = {
|
||||
*/
|
||||
app_help: string
|
||||
/**
|
||||
* @deprecated use switch_agent. Next mode
|
||||
* Exit the application
|
||||
*/
|
||||
switch_mode: string
|
||||
/**
|
||||
* @deprecated use switch_agent_reverse. Previous mode
|
||||
*/
|
||||
switch_mode_reverse: string
|
||||
/**
|
||||
* Next agent
|
||||
*/
|
||||
switch_agent: string
|
||||
/**
|
||||
* Previous agent
|
||||
*/
|
||||
switch_agent_reverse: string
|
||||
app_exit: string
|
||||
/**
|
||||
* Open external editor
|
||||
*/
|
||||
editor_open: string
|
||||
/**
|
||||
* List available themes
|
||||
*/
|
||||
theme_list: string
|
||||
/**
|
||||
* Create/update AGENTS.md
|
||||
*/
|
||||
project_init: string
|
||||
/**
|
||||
* Toggle tool details
|
||||
*/
|
||||
tool_details: string
|
||||
/**
|
||||
* Toggle thinking blocks
|
||||
*/
|
||||
thinking_blocks: string
|
||||
/**
|
||||
* Export session to editor
|
||||
*/
|
||||
@@ -770,53 +811,13 @@ export type KeybindsConfig = {
|
||||
*/
|
||||
session_compact: string
|
||||
/**
|
||||
* Toggle tool details
|
||||
* Cycle to next child session
|
||||
*/
|
||||
tool_details: string
|
||||
session_child_cycle: string
|
||||
/**
|
||||
* List available models
|
||||
* Cycle to previous child session
|
||||
*/
|
||||
model_list: string
|
||||
/**
|
||||
* List available themes
|
||||
*/
|
||||
theme_list: string
|
||||
/**
|
||||
* List files
|
||||
*/
|
||||
file_list: string
|
||||
/**
|
||||
* Close file
|
||||
*/
|
||||
file_close: string
|
||||
/**
|
||||
* Search file
|
||||
*/
|
||||
file_search: string
|
||||
/**
|
||||
* Split/unified diff
|
||||
*/
|
||||
file_diff_toggle: string
|
||||
/**
|
||||
* Create/update AGENTS.md
|
||||
*/
|
||||
project_init: string
|
||||
/**
|
||||
* Clear input field
|
||||
*/
|
||||
input_clear: string
|
||||
/**
|
||||
* Paste from clipboard
|
||||
*/
|
||||
input_paste: string
|
||||
/**
|
||||
* Submit input
|
||||
*/
|
||||
input_submit: string
|
||||
/**
|
||||
* Insert newline in input
|
||||
*/
|
||||
input_newline: string
|
||||
session_child_cycle_reverse: string
|
||||
/**
|
||||
* Scroll messages up by one page
|
||||
*/
|
||||
@@ -833,14 +834,6 @@ export type KeybindsConfig = {
|
||||
* Scroll messages down by half page
|
||||
*/
|
||||
messages_half_page_down: string
|
||||
/**
|
||||
* Navigate to previous message
|
||||
*/
|
||||
messages_previous: string
|
||||
/**
|
||||
* Navigate to next message
|
||||
*/
|
||||
messages_next: string
|
||||
/**
|
||||
* Navigate to first message
|
||||
*/
|
||||
@@ -849,18 +842,10 @@ export type KeybindsConfig = {
|
||||
* Navigate to last message
|
||||
*/
|
||||
messages_last: string
|
||||
/**
|
||||
* Toggle layout
|
||||
*/
|
||||
messages_layout_toggle: string
|
||||
/**
|
||||
* Copy message
|
||||
*/
|
||||
messages_copy: string
|
||||
/**
|
||||
* @deprecated use messages_undo. Revert message
|
||||
*/
|
||||
messages_revert: string
|
||||
/**
|
||||
* Undo message
|
||||
*/
|
||||
@@ -870,9 +855,93 @@ export type KeybindsConfig = {
|
||||
*/
|
||||
messages_redo: string
|
||||
/**
|
||||
* Exit the application
|
||||
* List available models
|
||||
*/
|
||||
app_exit: string
|
||||
model_list: string
|
||||
/**
|
||||
* Next recent model
|
||||
*/
|
||||
model_cycle_recent: string
|
||||
/**
|
||||
* Previous recent model
|
||||
*/
|
||||
model_cycle_recent_reverse: string
|
||||
/**
|
||||
* List agents
|
||||
*/
|
||||
agent_list: string
|
||||
/**
|
||||
* Next agent
|
||||
*/
|
||||
agent_cycle: string
|
||||
/**
|
||||
* Previous agent
|
||||
*/
|
||||
agent_cycle_reverse: string
|
||||
/**
|
||||
* Clear input field
|
||||
*/
|
||||
input_clear: string
|
||||
/**
|
||||
* Paste from clipboard
|
||||
*/
|
||||
input_paste: string
|
||||
/**
|
||||
* Submit input
|
||||
*/
|
||||
input_submit: string
|
||||
/**
|
||||
* Insert newline in input
|
||||
*/
|
||||
input_newline: string
|
||||
/**
|
||||
* @deprecated use agent_cycle. Next mode
|
||||
*/
|
||||
switch_mode: string
|
||||
/**
|
||||
* @deprecated use agent_cycle_reverse. Previous mode
|
||||
*/
|
||||
switch_mode_reverse: string
|
||||
/**
|
||||
* @deprecated use agent_cycle. Next agent
|
||||
*/
|
||||
switch_agent: string
|
||||
/**
|
||||
* @deprecated use agent_cycle_reverse. Previous agent
|
||||
*/
|
||||
switch_agent_reverse: string
|
||||
/**
|
||||
* @deprecated Currently not available. List files
|
||||
*/
|
||||
file_list: string
|
||||
/**
|
||||
* @deprecated Close file
|
||||
*/
|
||||
file_close: string
|
||||
/**
|
||||
* @deprecated Search file
|
||||
*/
|
||||
file_search: string
|
||||
/**
|
||||
* @deprecated Split/unified diff
|
||||
*/
|
||||
file_diff_toggle: string
|
||||
/**
|
||||
* @deprecated Navigate to previous message
|
||||
*/
|
||||
messages_previous: string
|
||||
/**
|
||||
* @deprecated Navigate to next message
|
||||
*/
|
||||
messages_next: string
|
||||
/**
|
||||
* @deprecated Toggle layout
|
||||
*/
|
||||
messages_layout_toggle: string
|
||||
/**
|
||||
* @deprecated use messages_undo. Revert message
|
||||
*/
|
||||
messages_revert: string
|
||||
}
|
||||
|
||||
export type AgentConfig = {
|
||||
@@ -888,7 +957,35 @@ export type AgentConfig = {
|
||||
* Description of when to use the agent
|
||||
*/
|
||||
description?: string
|
||||
mode?: string
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
permission?: {
|
||||
edit?: "ask" | "allow" | "deny"
|
||||
bash?:
|
||||
| ("ask" | "allow" | "deny")
|
||||
| {
|
||||
[key: string]: "ask" | "allow" | "deny"
|
||||
}
|
||||
webfetch?: "ask" | "allow" | "deny"
|
||||
}
|
||||
[key: string]:
|
||||
| unknown
|
||||
| string
|
||||
| number
|
||||
| {
|
||||
[key: string]: boolean
|
||||
}
|
||||
| boolean
|
||||
| ("subagent" | "primary" | "all")
|
||||
| {
|
||||
edit?: "ask" | "allow" | "deny"
|
||||
bash?:
|
||||
| ("ask" | "allow" | "deny")
|
||||
| {
|
||||
[key: string]: "ask" | "allow" | "deny"
|
||||
}
|
||||
webfetch?: "ask" | "allow" | "deny"
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
export type Provider = {
|
||||
@@ -929,7 +1026,7 @@ export type McpLocalConfig = {
|
||||
/**
|
||||
* Type of MCP server connection
|
||||
*/
|
||||
type: string
|
||||
type: "local"
|
||||
/**
|
||||
* Command and arguments to run the MCP server
|
||||
*/
|
||||
@@ -950,7 +1047,7 @@ export type McpRemoteConfig = {
|
||||
/**
|
||||
* Type of MCP server connection
|
||||
*/
|
||||
type: string
|
||||
type: "remote"
|
||||
/**
|
||||
* URL of the remote MCP server
|
||||
*/
|
||||
@@ -977,7 +1074,7 @@ export type _Error = {
|
||||
|
||||
export type TextPartInput = {
|
||||
id?: string
|
||||
type: string
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean
|
||||
time?: {
|
||||
@@ -988,7 +1085,7 @@ export type TextPartInput = {
|
||||
|
||||
export type FilePartInput = {
|
||||
id?: string
|
||||
type: string
|
||||
type: "file"
|
||||
mime: string
|
||||
filename?: string
|
||||
url: string
|
||||
@@ -997,7 +1094,7 @@ export type FilePartInput = {
|
||||
|
||||
export type AgentPartInput = {
|
||||
id?: string
|
||||
type: string
|
||||
type: "agent"
|
||||
name: string
|
||||
source?: {
|
||||
value: string
|
||||
@@ -1025,9 +1122,17 @@ export type File = {
|
||||
export type Agent = {
|
||||
name: string
|
||||
description?: string
|
||||
mode: string
|
||||
mode: "subagent" | "primary" | "all"
|
||||
builtIn: boolean
|
||||
topP?: number
|
||||
temperature?: number
|
||||
permission: {
|
||||
edit: "ask" | "allow" | "deny"
|
||||
bash: {
|
||||
[key: string]: "ask" | "allow" | "deny"
|
||||
}
|
||||
webfetch?: "ask" | "allow" | "deny"
|
||||
}
|
||||
model?: {
|
||||
modelID: string
|
||||
providerID: string
|
||||
@@ -1036,6 +1141,38 @@ export type Agent = {
|
||||
tools: {
|
||||
[key: string]: boolean
|
||||
}
|
||||
options: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type Auth =
|
||||
| ({
|
||||
type: "oauth"
|
||||
} & OAuth)
|
||||
| ({
|
||||
type: "api"
|
||||
} & ApiAuth)
|
||||
| ({
|
||||
type: "wellknown"
|
||||
} & WellKnownAuth)
|
||||
|
||||
export type OAuth = {
|
||||
type: "oauth"
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
}
|
||||
|
||||
export type ApiAuth = {
|
||||
type: "api"
|
||||
key: string
|
||||
}
|
||||
|
||||
export type WellKnownAuth = {
|
||||
type: "wellknown"
|
||||
key: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export type EventSubscribeData = {
|
||||
@@ -1119,7 +1256,10 @@ export type SessionListResponses = {
|
||||
export type SessionListResponse = SessionListResponses[keyof SessionListResponses]
|
||||
|
||||
export type SessionCreateData = {
|
||||
body?: never
|
||||
body?: {
|
||||
parentID?: string
|
||||
title?: string
|
||||
}
|
||||
path?: never
|
||||
query?: never
|
||||
url: "/session"
|
||||
@@ -1179,6 +1319,44 @@ export type SessionGetResponses = {
|
||||
|
||||
export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses]
|
||||
|
||||
export type SessionUpdateData = {
|
||||
body?: {
|
||||
title?: string
|
||||
}
|
||||
path: {
|
||||
id: string
|
||||
}
|
||||
query?: never
|
||||
url: "/session/{id}"
|
||||
}
|
||||
|
||||
export type SessionUpdateResponses = {
|
||||
/**
|
||||
* Successfully updated session
|
||||
*/
|
||||
200: Session
|
||||
}
|
||||
|
||||
export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses]
|
||||
|
||||
export type SessionChildrenData = {
|
||||
body?: never
|
||||
path: {
|
||||
id: string
|
||||
}
|
||||
query?: never
|
||||
url: "/session/{id}/children"
|
||||
}
|
||||
|
||||
export type SessionChildrenResponses = {
|
||||
/**
|
||||
* List of children
|
||||
*/
|
||||
200: Array<Session>
|
||||
}
|
||||
|
||||
export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses]
|
||||
|
||||
export type SessionInitData = {
|
||||
body?: {
|
||||
messageID: string
|
||||
@@ -1375,6 +1553,30 @@ export type SessionMessageResponses = {
|
||||
|
||||
export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses]
|
||||
|
||||
export type SessionShellData = {
|
||||
body?: {
|
||||
agent: string
|
||||
command: string
|
||||
}
|
||||
path: {
|
||||
/**
|
||||
* Session ID
|
||||
*/
|
||||
id: string
|
||||
}
|
||||
query?: never
|
||||
url: "/session/{id}/shell"
|
||||
}
|
||||
|
||||
export type SessionShellResponses = {
|
||||
/**
|
||||
* Created message
|
||||
*/
|
||||
200: AssistantMessage
|
||||
}
|
||||
|
||||
export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses]
|
||||
|
||||
export type SessionRevertData = {
|
||||
body?: {
|
||||
messageID: string
|
||||
@@ -1747,6 +1949,53 @@ export type TuiExecuteCommandResponses = {
|
||||
|
||||
export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses]
|
||||
|
||||
export type TuiShowToastData = {
|
||||
body?: {
|
||||
title?: string
|
||||
message: string
|
||||
variant: "info" | "success" | "warning" | "error"
|
||||
}
|
||||
path?: never
|
||||
query?: never
|
||||
url: "/tui/show-toast"
|
||||
}
|
||||
|
||||
export type TuiShowToastResponses = {
|
||||
/**
|
||||
* Toast notification shown successfully
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses]
|
||||
|
||||
export type AuthSetData = {
|
||||
body?: Auth
|
||||
path: {
|
||||
id: string
|
||||
}
|
||||
query?: never
|
||||
url: "/auth/{id}"
|
||||
}
|
||||
|
||||
export type AuthSetErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: _Error
|
||||
}
|
||||
|
||||
export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]
|
||||
|
||||
export type AuthSetResponses = {
|
||||
/**
|
||||
* Successfully set authentication credentials
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}` | (string & {})
|
||||
}
|
||||
|
||||
@@ -2,8 +2,34 @@ import { createClient } from "./gen/client/client.js"
|
||||
import { type Config } from "./gen/client/types.js"
|
||||
import { OpencodeClient } from "./gen/sdk.gen.js"
|
||||
export * from "./gen/types.gen.js"
|
||||
import { spawn } from "child_process"
|
||||
|
||||
export function createOpencodeClient(config?: Config) {
|
||||
const client = createClient(config)
|
||||
return new OpencodeClient({ client })
|
||||
}
|
||||
|
||||
export type ServerConfig = {
|
||||
host?: string
|
||||
port?: number
|
||||
}
|
||||
|
||||
export async function createOpencodeServer(config?: ServerConfig) {
|
||||
config = Object.assign(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: 4096,
|
||||
},
|
||||
config ?? {},
|
||||
)
|
||||
|
||||
const proc = spawn(`opencode`, [`serve`, `--host=${config.host}`, `--port=${config.port}`])
|
||||
const url = `http://${config.host}:${config.port}`
|
||||
|
||||
return {
|
||||
url,
|
||||
close() {
|
||||
proc.kill()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ console.log("=== Generating Stainless SDK ===")
|
||||
console.log(process.cwd())
|
||||
|
||||
await $`rm -rf go`
|
||||
await $`bun run ../../opencode/src/index.ts generate > openapi.json`
|
||||
await $`bun run --conditions=development ../../opencode/src/index.ts generate > openapi.json`
|
||||
await $`stl builds create --branch dev --pull --allow-empty --+target go`
|
||||
|
||||
await $`rm -rf ../go`
|
||||
|
||||
@@ -113,7 +113,9 @@ resources:
|
||||
toolStateError: ToolStateError
|
||||
|
||||
methods:
|
||||
get: get /session/{id}
|
||||
list: get /session
|
||||
children: get /session/{id}/children
|
||||
create: post /session
|
||||
delete: delete /session/{id}
|
||||
init: post /session/{id}/init
|
||||
@@ -124,6 +126,8 @@ resources:
|
||||
message: get /session/{id}/message/{messageID}
|
||||
messages: get /session/{id}/message
|
||||
chat: post /session/{id}/message
|
||||
shell: post /session/{id}/shell
|
||||
update: patch /session/{id}
|
||||
revert: post /session/{id}/revert
|
||||
unrevert: post /session/{id}/unrevert
|
||||
|
||||
@@ -144,6 +148,7 @@ resources:
|
||||
openThemes: post /tui/open-themes
|
||||
openModels: post /tui/open-models
|
||||
executeCommand: post /tui/execute-command
|
||||
showToast: post /tui/show-toast
|
||||
|
||||
settings:
|
||||
disable_mock_tests: true
|
||||
|
||||
@@ -32,6 +32,7 @@ func main() {
|
||||
var model *string = flag.String("model", "", "model to begin with")
|
||||
var prompt *string = flag.String("prompt", "", "prompt to begin with")
|
||||
var agent *string = flag.String("agent", "", "agent to begin with")
|
||||
var sessionID *string = flag.String("session", "", "session ID")
|
||||
flag.Parse()
|
||||
|
||||
url := os.Getenv("OPENCODE_SERVER")
|
||||
@@ -44,14 +45,6 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
agentsStr := os.Getenv("OPENCODE_AGENTS")
|
||||
var agents []opencode.Agent
|
||||
err = json.Unmarshal([]byte(agentsStr), &agents)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal modes", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
slog.Error("Failed to stat stdin", "error", err)
|
||||
@@ -80,13 +73,25 @@ func main() {
|
||||
option.WithBaseURL(url),
|
||||
)
|
||||
|
||||
// Fetch agents from the /agent endpoint
|
||||
agentsPtr, err := httpClient.App.Agents(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch agents", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if agentsPtr == nil {
|
||||
slog.Error("No agents returned from server")
|
||||
os.Exit(1)
|
||||
}
|
||||
agents := *agentsPtr
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
apiHandler := util.NewAPILogHandler(ctx, httpClient, "tui", slog.LevelDebug)
|
||||
logger := slog.New(apiHandler)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Debug("TUI launched", "app", appInfoStr, "modes", agentsStr, "url", url)
|
||||
slog.Debug("TUI launched", "app", appInfoStr, "agents_count", len(agents), "url", url)
|
||||
|
||||
go func() {
|
||||
err = clipboard.Init()
|
||||
@@ -96,7 +101,7 @@ func main() {
|
||||
}()
|
||||
|
||||
// Create main context for the application
|
||||
app_, err := app.New(ctx, version, appInfo, agents, httpClient, model, prompt, agent)
|
||||
app_, err := app.New(ctx, version, appInfo, agents, httpClient, model, prompt, agent, sessionID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -46,8 +46,11 @@ type App struct {
|
||||
InitialModel *string
|
||||
InitialPrompt *string
|
||||
InitialAgent *string
|
||||
InitialSession *string
|
||||
compactCancel context.CancelFunc
|
||||
IsLeaderSequence bool
|
||||
IsBashMode bool
|
||||
ScrollSpeed int
|
||||
}
|
||||
|
||||
func (a *App) Agent() *opencode.Agent {
|
||||
@@ -70,9 +73,17 @@ type ModelSelectedMsg struct {
|
||||
Provider opencode.Provider
|
||||
Model opencode.Model
|
||||
}
|
||||
|
||||
type AgentSelectedMsg struct {
|
||||
AgentName string
|
||||
}
|
||||
|
||||
type SessionClearedMsg struct{}
|
||||
type CompactSessionMsg struct{}
|
||||
type SendPrompt = Prompt
|
||||
type SendShell = struct {
|
||||
Command string
|
||||
}
|
||||
type SetEditorContentMsg struct {
|
||||
Text string
|
||||
}
|
||||
@@ -92,6 +103,7 @@ func New(
|
||||
initialModel *string,
|
||||
initialPrompt *string,
|
||||
initialAgent *string,
|
||||
initialSession *string,
|
||||
) (*App, error) {
|
||||
util.RootPath = appInfo.Path.Root
|
||||
util.CwdPath = appInfo.Path.Cwd
|
||||
@@ -172,20 +184,22 @@ func New(
|
||||
slog.Debug("Loaded config", "config", configInfo)
|
||||
|
||||
app := &App{
|
||||
Info: appInfo,
|
||||
Agents: agents,
|
||||
Version: version,
|
||||
StatePath: appStatePath,
|
||||
Config: configInfo,
|
||||
State: appState,
|
||||
Client: httpClient,
|
||||
AgentIndex: agentIndex,
|
||||
Session: &opencode.Session{},
|
||||
Messages: []Message{},
|
||||
Commands: commands.LoadFromConfig(configInfo),
|
||||
InitialModel: initialModel,
|
||||
InitialPrompt: initialPrompt,
|
||||
InitialAgent: initialAgent,
|
||||
Info: appInfo,
|
||||
Agents: agents,
|
||||
Version: version,
|
||||
StatePath: appStatePath,
|
||||
Config: configInfo,
|
||||
State: appState,
|
||||
Client: httpClient,
|
||||
AgentIndex: agentIndex,
|
||||
Session: &opencode.Session{},
|
||||
Messages: []Message{},
|
||||
Commands: commands.LoadFromConfig(configInfo),
|
||||
InitialModel: initialModel,
|
||||
InitialPrompt: initialPrompt,
|
||||
InitialAgent: initialAgent,
|
||||
InitialSession: initialSession,
|
||||
ScrollSpeed: int(configInfo.Tui.ScrollSpeed),
|
||||
}
|
||||
|
||||
return app, nil
|
||||
@@ -266,6 +280,7 @@ func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
|
||||
}
|
||||
|
||||
a.State.Agent = a.Agent().Name
|
||||
a.State.UpdateAgentUsage(a.Agent().Name)
|
||||
return a, a.SaveState()
|
||||
}
|
||||
|
||||
@@ -277,6 +292,110 @@ func (a *App) SwitchAgentReverse() (*App, tea.Cmd) {
|
||||
return a.cycleMode(false)
|
||||
}
|
||||
|
||||
func (a *App) cycleRecentModel(forward bool) (*App, tea.Cmd) {
|
||||
recentModels := a.State.RecentlyUsedModels
|
||||
if len(recentModels) > 5 {
|
||||
recentModels = recentModels[:5]
|
||||
}
|
||||
if len(recentModels) < 2 {
|
||||
return a, toast.NewInfoToast("Need at least 2 recent models to cycle")
|
||||
}
|
||||
nextIndex := 0
|
||||
prevIndex := 0
|
||||
for i, recentModel := range recentModels {
|
||||
if a.Provider != nil && a.Model != nil && recentModel.ProviderID == a.Provider.ID &&
|
||||
recentModel.ModelID == a.Model.ID {
|
||||
nextIndex = (i + 1) % len(recentModels)
|
||||
prevIndex = (i - 1 + len(recentModels)) % len(recentModels)
|
||||
break
|
||||
}
|
||||
}
|
||||
targetIndex := nextIndex
|
||||
if !forward {
|
||||
targetIndex = prevIndex
|
||||
}
|
||||
for range recentModels {
|
||||
currentRecentModel := recentModels[targetIndex%len(recentModels)]
|
||||
provider, model := findModelByProviderAndModelID(
|
||||
a.Providers,
|
||||
currentRecentModel.ProviderID,
|
||||
currentRecentModel.ModelID,
|
||||
)
|
||||
if provider != nil && model != nil {
|
||||
a.Provider, a.Model = provider, model
|
||||
a.State.AgentModel[a.Agent().Name] = AgentModel{
|
||||
ProviderID: provider.ID,
|
||||
ModelID: model.ID,
|
||||
}
|
||||
return a, tea.Sequence(
|
||||
a.SaveState(),
|
||||
toast.NewSuccessToast(
|
||||
fmt.Sprintf("Switched to %s (%s)", model.Name, provider.Name),
|
||||
),
|
||||
)
|
||||
}
|
||||
recentModels = append(
|
||||
recentModels[:targetIndex%len(recentModels)],
|
||||
recentModels[targetIndex%len(recentModels)+1:]...)
|
||||
if len(recentModels) < 2 {
|
||||
a.State.RecentlyUsedModels = recentModels
|
||||
return a, tea.Sequence(
|
||||
a.SaveState(),
|
||||
toast.NewInfoToast("Not enough valid recent models to cycle"),
|
||||
)
|
||||
}
|
||||
}
|
||||
a.State.RecentlyUsedModels = recentModels
|
||||
return a, toast.NewErrorToast("Recent model not found")
|
||||
}
|
||||
|
||||
func (a *App) CycleRecentModel() (*App, tea.Cmd) {
|
||||
return a.cycleRecentModel(true)
|
||||
}
|
||||
|
||||
func (a *App) CycleRecentModelReverse() (*App, tea.Cmd) {
|
||||
return a.cycleRecentModel(false)
|
||||
}
|
||||
|
||||
func (a *App) SwitchToAgent(agentName string) (*App, tea.Cmd) {
|
||||
// Find the agent index by name
|
||||
for i, agent := range a.Agents {
|
||||
if agent.Name == agentName {
|
||||
a.AgentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Set up model for the new agent
|
||||
modelID := a.Agent().Model.ModelID
|
||||
providerID := a.Agent().Model.ProviderID
|
||||
if modelID == "" {
|
||||
if model, ok := a.State.AgentModel[a.Agent().Name]; ok {
|
||||
modelID = model.ModelID
|
||||
providerID = model.ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
if modelID != "" {
|
||||
for _, provider := range a.Providers {
|
||||
if provider.ID == providerID {
|
||||
a.Provider = &provider
|
||||
for _, model := range provider.Models {
|
||||
if model.ID == modelID {
|
||||
a.Model = &model
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.State.Agent = a.Agent().Name
|
||||
a.State.UpdateAgentUsage(agentName)
|
||||
return a, a.SaveState()
|
||||
}
|
||||
|
||||
// findModelByFullID finds a model by its full ID in the format "provider/model"
|
||||
func findModelByFullID(
|
||||
providers []opencode.Provider,
|
||||
@@ -381,7 +500,27 @@ func (a *App) InitializeProvider() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Recent model usage (most recently used model)
|
||||
// Priority 3: Current agent's preferred model
|
||||
if selectedProvider == nil && a.Agent().Model.ModelID != "" {
|
||||
if provider, model := findModelByProviderAndModelID(providers, a.Agent().Model.ProviderID, a.Agent().Model.ModelID); provider != nil &&
|
||||
model != nil {
|
||||
selectedProvider = provider
|
||||
selectedModel = model
|
||||
slog.Debug(
|
||||
"Selected model from current agent",
|
||||
"provider",
|
||||
provider.ID,
|
||||
"model",
|
||||
model.ID,
|
||||
"agent",
|
||||
a.Agent().Name,
|
||||
)
|
||||
} else {
|
||||
slog.Debug("Agent model not found", "provider", a.Agent().Model.ProviderID, "model", a.Agent().Model.ModelID, "agent", a.Agent().Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Recent model usage (most recently used model)
|
||||
if selectedProvider == nil && len(a.State.RecentlyUsedModels) > 0 {
|
||||
recentUsage := a.State.RecentlyUsedModels[0] // Most recent is first
|
||||
if provider, model := findModelByProviderAndModelID(providers, recentUsage.ProviderID, recentUsage.ModelID); provider != nil &&
|
||||
@@ -400,7 +539,7 @@ func (a *App) InitializeProvider() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: State-based model (backwards compatibility)
|
||||
// Priority 5: State-based model (backwards compatibility)
|
||||
if selectedProvider == nil && a.State.Provider != "" && a.State.Model != "" {
|
||||
if provider, model := findModelByProviderAndModelID(providers, a.State.Provider, a.State.Model); provider != nil &&
|
||||
model != nil {
|
||||
@@ -412,7 +551,7 @@ func (a *App) InitializeProvider() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 5: Internal priority fallback (Anthropic preferred, then first available)
|
||||
// Priority 6: Internal priority fallback (Anthropic preferred, then first available)
|
||||
if selectedProvider == nil {
|
||||
// Try Anthropic first as internal priority
|
||||
if provider := findProviderByID(providers, "anthropic"); provider != nil {
|
||||
@@ -457,6 +596,28 @@ func (a *App) InitializeProvider() tea.Cmd {
|
||||
Provider: *selectedProvider,
|
||||
Model: *selectedModel,
|
||||
}))
|
||||
|
||||
// Load initial session if provided
|
||||
if a.InitialSession != nil && *a.InitialSession != "" {
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
// Find the session by ID
|
||||
sessions, err := a.ListSessions(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("Failed to list sessions for initial session", "error", err)
|
||||
return toast.NewErrorToast("Failed to load initial session")()
|
||||
}
|
||||
|
||||
for _, session := range sessions {
|
||||
if session.ID == *a.InitialSession {
|
||||
return SessionSelectedMsg(&session)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Warn("Initial session not found", "sessionID", *a.InitialSession)
|
||||
return toast.NewErrorToast("Session not found: " + *a.InitialSession)()
|
||||
})
|
||||
}
|
||||
|
||||
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
|
||||
cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
|
||||
}
|
||||
@@ -486,7 +647,7 @@ func (a *App) IsBusy() bool {
|
||||
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
|
||||
return casted.Time.Completed == 0
|
||||
}
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) SaveState() tea.Cmd {
|
||||
@@ -566,7 +727,7 @@ func (a *App) MarkProjectInitialized(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
|
||||
session, err := a.Client.Session.New(ctx)
|
||||
session, err := a.Client.Session.New(ctx, opencode.SessionNewParams{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -610,6 +771,38 @@ func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) SendShell(ctx context.Context, command string) (*App, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
if a.Session.ID == "" {
|
||||
session, err := a.CreateSession(ctx)
|
||||
if err != nil {
|
||||
return a, toast.NewErrorToast(err.Error())
|
||||
}
|
||||
a.Session = session
|
||||
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
|
||||
}
|
||||
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
_, err := a.Client.Session.Shell(
|
||||
context.Background(),
|
||||
a.Session.ID,
|
||||
opencode.SessionShellParams{
|
||||
Agent: opencode.F(a.Agent().Name),
|
||||
Command: opencode.F(command),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("Failed to submit shell command", "error", err)
|
||||
return toast.NewErrorToast("Failed to submit shell command")()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// The actual response will come through SSE
|
||||
// For now, just return success
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
||||
// Cancel any running compact operation
|
||||
if a.compactCancel != nil {
|
||||
@@ -646,6 +839,17 @@ func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateSession(ctx context.Context, sessionID string, title string) error {
|
||||
_, err := a.Client.Session.Update(ctx, sessionID, opencode.SessionUpdateParams{
|
||||
Title: opencode.F(title),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to update session", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
|
||||
response, err := a.Client.Session.Messages(ctx, sessionId)
|
||||
if err != nil {
|
||||
|
||||
@@ -16,6 +16,11 @@ type ModelUsage struct {
|
||||
LastUsed time.Time `toml:"last_used"`
|
||||
}
|
||||
|
||||
type AgentUsage struct {
|
||||
AgentName string `toml:"agent_name"`
|
||||
LastUsed time.Time `toml:"last_used"`
|
||||
}
|
||||
|
||||
type AgentModel struct {
|
||||
ProviderID string `toml:"provider_id"`
|
||||
ModelID string `toml:"model_id"`
|
||||
@@ -23,15 +28,15 @@ type AgentModel struct {
|
||||
|
||||
type State struct {
|
||||
Theme string `toml:"theme"`
|
||||
ScrollSpeed *int `toml:"scroll_speed"`
|
||||
AgentModel map[string]AgentModel `toml:"agent_model"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
Agent string `toml:"agent"`
|
||||
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
|
||||
MessagesRight bool `toml:"messages_right"`
|
||||
SplitDiff bool `toml:"split_diff"`
|
||||
RecentlyUsedAgents []AgentUsage `toml:"recently_used_agents"`
|
||||
MessageHistory []Prompt `toml:"message_history"`
|
||||
ShowToolDetails *bool `toml:"show_tool_details"`
|
||||
ShowThinkingBlocks *bool `toml:"show_thinking_blocks"`
|
||||
}
|
||||
|
||||
func NewState() *State {
|
||||
@@ -40,6 +45,7 @@ func NewState() *State {
|
||||
Agent: "build",
|
||||
AgentModel: make(map[string]AgentModel),
|
||||
RecentlyUsedModels: make([]ModelUsage, 0),
|
||||
RecentlyUsedAgents: make([]AgentUsage, 0),
|
||||
MessageHistory: make([]Prompt, 0),
|
||||
}
|
||||
}
|
||||
@@ -81,6 +87,42 @@ func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateAgentUsage updates the recently used agents list with the specified agent
|
||||
func (s *State) UpdateAgentUsage(agentName string) {
|
||||
now := time.Now()
|
||||
|
||||
// Check if this agent is already in the list
|
||||
for i, usage := range s.RecentlyUsedAgents {
|
||||
if usage.AgentName == agentName {
|
||||
s.RecentlyUsedAgents[i].LastUsed = now
|
||||
usage := s.RecentlyUsedAgents[i]
|
||||
copy(s.RecentlyUsedAgents[1:i+1], s.RecentlyUsedAgents[0:i])
|
||||
s.RecentlyUsedAgents[0] = usage
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newUsage := AgentUsage{
|
||||
AgentName: agentName,
|
||||
LastUsed: now,
|
||||
}
|
||||
|
||||
// Prepend to slice and limit to last 20 entries
|
||||
s.RecentlyUsedAgents = append([]AgentUsage{newUsage}, s.RecentlyUsedAgents...)
|
||||
if len(s.RecentlyUsedAgents) > 20 {
|
||||
s.RecentlyUsedAgents = s.RecentlyUsedAgents[:20]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) RemoveAgentFromRecentlyUsed(agentName string) {
|
||||
for i, usage := range s.RecentlyUsedAgents {
|
||||
if usage.AgentName == agentName {
|
||||
s.RecentlyUsedAgents = append(s.RecentlyUsedAgents[:i], s.RecentlyUsedAgents[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) AddPromptToHistory(prompt Prompt) {
|
||||
s.MessageHistory = append([]Prompt{prompt}, s.MessageHistory...)
|
||||
if len(s.MessageHistory) > 50 {
|
||||
|
||||
@@ -64,12 +64,13 @@ func (r CommandRegistry) Sorted() []Command {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
slices.SortFunc(commands, func(a, b Command) int {
|
||||
// Priority order: session_new, session_share, model_list, app_help first, app_exit last
|
||||
// Priority order: session_new, session_share, model_list, agent_list, app_help first, app_exit last
|
||||
priorityOrder := map[CommandName]int{
|
||||
SessionNewCommand: 0,
|
||||
AppHelpCommand: 1,
|
||||
SessionShareCommand: 2,
|
||||
ModelListCommand: 3,
|
||||
AgentListCommand: 4,
|
||||
}
|
||||
|
||||
aPriority, aHasPriority := priorityOrder[a.Name]
|
||||
@@ -106,42 +107,52 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
|
||||
}
|
||||
|
||||
const (
|
||||
AppHelpCommand CommandName = "app_help"
|
||||
SwitchAgentCommand CommandName = "switch_agent"
|
||||
SwitchAgentReverseCommand CommandName = "switch_agent_reverse"
|
||||
EditorOpenCommand CommandName = "editor_open"
|
||||
SessionNewCommand CommandName = "session_new"
|
||||
SessionListCommand CommandName = "session_list"
|
||||
SessionShareCommand CommandName = "session_share"
|
||||
SessionUnshareCommand CommandName = "session_unshare"
|
||||
SessionInterruptCommand CommandName = "session_interrupt"
|
||||
SessionCompactCommand CommandName = "session_compact"
|
||||
SessionExportCommand CommandName = "session_export"
|
||||
ToolDetailsCommand CommandName = "tool_details"
|
||||
ModelListCommand CommandName = "model_list"
|
||||
ThemeListCommand CommandName = "theme_list"
|
||||
FileListCommand CommandName = "file_list"
|
||||
FileCloseCommand CommandName = "file_close"
|
||||
FileSearchCommand CommandName = "file_search"
|
||||
FileDiffToggleCommand CommandName = "file_diff_toggle"
|
||||
ProjectInitCommand CommandName = "project_init"
|
||||
InputClearCommand CommandName = "input_clear"
|
||||
InputPasteCommand CommandName = "input_paste"
|
||||
InputSubmitCommand CommandName = "input_submit"
|
||||
InputNewlineCommand CommandName = "input_newline"
|
||||
MessagesPageUpCommand CommandName = "messages_page_up"
|
||||
MessagesPageDownCommand CommandName = "messages_page_down"
|
||||
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
|
||||
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
|
||||
MessagesPreviousCommand CommandName = "messages_previous"
|
||||
MessagesNextCommand CommandName = "messages_next"
|
||||
MessagesFirstCommand CommandName = "messages_first"
|
||||
MessagesLastCommand CommandName = "messages_last"
|
||||
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
|
||||
MessagesCopyCommand CommandName = "messages_copy"
|
||||
MessagesUndoCommand CommandName = "messages_undo"
|
||||
MessagesRedoCommand CommandName = "messages_redo"
|
||||
AppExitCommand CommandName = "app_exit"
|
||||
|
||||
SessionChildCycleCommand CommandName = "session_child_cycle"
|
||||
SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse"
|
||||
ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse"
|
||||
AgentCycleCommand CommandName = "agent_cycle"
|
||||
AgentCycleReverseCommand CommandName = "agent_cycle_reverse"
|
||||
AppHelpCommand CommandName = "app_help"
|
||||
SwitchAgentCommand CommandName = "switch_agent"
|
||||
SwitchAgentReverseCommand CommandName = "switch_agent_reverse"
|
||||
EditorOpenCommand CommandName = "editor_open"
|
||||
SessionNewCommand CommandName = "session_new"
|
||||
SessionListCommand CommandName = "session_list"
|
||||
SessionNavigationCommand CommandName = "session_navigation"
|
||||
SessionShareCommand CommandName = "session_share"
|
||||
SessionUnshareCommand CommandName = "session_unshare"
|
||||
SessionInterruptCommand CommandName = "session_interrupt"
|
||||
SessionCompactCommand CommandName = "session_compact"
|
||||
SessionExportCommand CommandName = "session_export"
|
||||
ToolDetailsCommand CommandName = "tool_details"
|
||||
ThinkingBlocksCommand CommandName = "thinking_blocks"
|
||||
ModelListCommand CommandName = "model_list"
|
||||
AgentListCommand CommandName = "agent_list"
|
||||
ModelCycleRecentCommand CommandName = "model_cycle_recent"
|
||||
ThemeListCommand CommandName = "theme_list"
|
||||
FileListCommand CommandName = "file_list"
|
||||
FileCloseCommand CommandName = "file_close"
|
||||
FileSearchCommand CommandName = "file_search"
|
||||
FileDiffToggleCommand CommandName = "file_diff_toggle"
|
||||
ProjectInitCommand CommandName = "project_init"
|
||||
InputClearCommand CommandName = "input_clear"
|
||||
InputPasteCommand CommandName = "input_paste"
|
||||
InputSubmitCommand CommandName = "input_submit"
|
||||
InputNewlineCommand CommandName = "input_newline"
|
||||
MessagesPageUpCommand CommandName = "messages_page_up"
|
||||
MessagesPageDownCommand CommandName = "messages_page_down"
|
||||
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
|
||||
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
|
||||
MessagesPreviousCommand CommandName = "messages_previous"
|
||||
MessagesNextCommand CommandName = "messages_next"
|
||||
MessagesFirstCommand CommandName = "messages_first"
|
||||
MessagesLastCommand CommandName = "messages_last"
|
||||
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
|
||||
MessagesCopyCommand CommandName = "messages_copy"
|
||||
MessagesUndoCommand CommandName = "messages_undo"
|
||||
MessagesRedoCommand CommandName = "messages_redo"
|
||||
AppExitCommand CommandName = "app_exit"
|
||||
)
|
||||
|
||||
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
|
||||
@@ -180,16 +191,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
Keybindings: parseBindings("<leader>h"),
|
||||
Trigger: []string{"help"},
|
||||
},
|
||||
{
|
||||
Name: SwitchAgentCommand,
|
||||
Description: "next agent",
|
||||
Keybindings: parseBindings("tab"),
|
||||
},
|
||||
{
|
||||
Name: SwitchAgentReverseCommand,
|
||||
Description: "previous agent",
|
||||
Keybindings: parseBindings("shift+tab"),
|
||||
},
|
||||
{
|
||||
Name: EditorOpenCommand,
|
||||
Description: "open editor",
|
||||
@@ -214,6 +215,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
Keybindings: parseBindings("<leader>l"),
|
||||
Trigger: []string{"sessions", "resume", "continue"},
|
||||
},
|
||||
{
|
||||
Name: SessionNavigationCommand,
|
||||
Description: "jump to message",
|
||||
Keybindings: parseBindings("<leader>g"),
|
||||
Trigger: []string{"jump", "goto", "navigate"},
|
||||
},
|
||||
{
|
||||
Name: SessionShareCommand,
|
||||
Description: "share session",
|
||||
@@ -236,45 +243,66 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
Keybindings: parseBindings("<leader>c"),
|
||||
Trigger: []string{"compact", "summarize"},
|
||||
},
|
||||
{
|
||||
Name: SessionChildCycleCommand,
|
||||
Description: "cycle to next child session",
|
||||
Keybindings: parseBindings("ctrl+right"),
|
||||
},
|
||||
{
|
||||
Name: SessionChildCycleReverseCommand,
|
||||
Description: "cycle to previous child session",
|
||||
Keybindings: parseBindings("ctrl+left"),
|
||||
},
|
||||
{
|
||||
Name: ToolDetailsCommand,
|
||||
Description: "toggle tool details",
|
||||
Keybindings: parseBindings("<leader>d"),
|
||||
Trigger: []string{"details"},
|
||||
},
|
||||
{
|
||||
Name: ThinkingBlocksCommand,
|
||||
Description: "toggle thinking blocks",
|
||||
Keybindings: parseBindings("<leader>b"),
|
||||
Trigger: []string{"thinking"},
|
||||
},
|
||||
{
|
||||
Name: ModelListCommand,
|
||||
Description: "list models",
|
||||
Keybindings: parseBindings("<leader>m"),
|
||||
Trigger: []string{"models"},
|
||||
},
|
||||
{
|
||||
Name: ModelCycleRecentCommand,
|
||||
Description: "next recent model",
|
||||
Keybindings: parseBindings("f2"),
|
||||
},
|
||||
{
|
||||
Name: ModelCycleRecentReverseCommand,
|
||||
Description: "previous recent model",
|
||||
Keybindings: parseBindings("shift+f2"),
|
||||
},
|
||||
{
|
||||
Name: AgentListCommand,
|
||||
Description: "list agents",
|
||||
Keybindings: parseBindings("<leader>a"),
|
||||
Trigger: []string{"agents"},
|
||||
},
|
||||
{
|
||||
Name: AgentCycleCommand,
|
||||
Description: "next agent",
|
||||
Keybindings: parseBindings("tab"),
|
||||
},
|
||||
{
|
||||
Name: AgentCycleReverseCommand,
|
||||
Description: "previous agent",
|
||||
Keybindings: parseBindings("shift+tab"),
|
||||
},
|
||||
{
|
||||
Name: ThemeListCommand,
|
||||
Description: "list themes",
|
||||
Keybindings: parseBindings("<leader>t"),
|
||||
Trigger: []string{"themes"},
|
||||
},
|
||||
// {
|
||||
// Name: FileListCommand,
|
||||
// Description: "list files",
|
||||
// Keybindings: parseBindings("<leader>f"),
|
||||
// Trigger: []string{"files"},
|
||||
// },
|
||||
{
|
||||
Name: FileCloseCommand,
|
||||
Description: "close file",
|
||||
Keybindings: parseBindings("esc"),
|
||||
},
|
||||
{
|
||||
Name: FileSearchCommand,
|
||||
Description: "search file",
|
||||
Keybindings: parseBindings("<leader>/"),
|
||||
},
|
||||
{
|
||||
Name: FileDiffToggleCommand,
|
||||
Description: "split/unified diff",
|
||||
Keybindings: parseBindings("<leader>v"),
|
||||
},
|
||||
{
|
||||
Name: ProjectInitCommand,
|
||||
Description: "create/update AGENTS.md",
|
||||
@@ -321,16 +349,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
Description: "half page down",
|
||||
Keybindings: parseBindings("ctrl+alt+d"),
|
||||
},
|
||||
{
|
||||
Name: MessagesPreviousCommand,
|
||||
Description: "previous message",
|
||||
Keybindings: parseBindings("ctrl+up"),
|
||||
},
|
||||
{
|
||||
Name: MessagesNextCommand,
|
||||
Description: "next message",
|
||||
Keybindings: parseBindings("ctrl+down"),
|
||||
},
|
||||
|
||||
{
|
||||
Name: MessagesFirstCommand,
|
||||
Description: "first message",
|
||||
@@ -341,11 +360,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
Description: "last message",
|
||||
Keybindings: parseBindings("ctrl+alt+g"),
|
||||
},
|
||||
{
|
||||
Name: MessagesLayoutToggleCommand,
|
||||
Description: "toggle layout",
|
||||
Keybindings: parseBindings("<leader>p"),
|
||||
},
|
||||
|
||||
{
|
||||
Name: MessagesCopyCommand,
|
||||
Description: "copy message",
|
||||
|
||||
@@ -27,54 +27,11 @@ import (
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type AttachmentInsertedMsg struct{}
|
||||
|
||||
// unescapeClipboardText trims surrounding quotes from clipboard text and returns the inner content.
|
||||
// It avoids interpreting backslash escape sequences unless the text is explicitly quoted.
|
||||
func (m *editorComponent) unescapeClipboardText(s string) string {
|
||||
t := strings.TrimSpace(s)
|
||||
if len(t) >= 2 {
|
||||
first := t[0]
|
||||
last := t[len(t)-1]
|
||||
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
|
||||
if u, err := strconv.Unquote(t); err == nil {
|
||||
return u
|
||||
}
|
||||
return t[1 : len(t)-1]
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// pathExists checks if the given path exists. Relative paths are resolved against the app CWD.
|
||||
// Supports expanding '~' to the user's home directory.
|
||||
func (m *editorComponent) pathExists(p string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(p, "~") {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if p == "~" {
|
||||
p = home
|
||||
} else if strings.HasPrefix(p, "~/") {
|
||||
p = filepath.Join(home, p[2:])
|
||||
}
|
||||
}
|
||||
}
|
||||
check := p
|
||||
if !filepath.IsAbs(check) {
|
||||
check = filepath.Join(m.app.Info.Path.Cwd, check)
|
||||
}
|
||||
if _, err := os.Stat(check); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type EditorComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
Content() string
|
||||
Cursor() *tea.Cursor
|
||||
Lines() int
|
||||
Value() string
|
||||
Length() int
|
||||
@@ -82,6 +39,7 @@ type EditorComponent interface {
|
||||
Focus() (tea.Model, tea.Cmd)
|
||||
Blur()
|
||||
Submit() (tea.Model, tea.Cmd)
|
||||
SubmitBash() (tea.Model, tea.Cmd)
|
||||
Clear() (tea.Model, tea.Cmd)
|
||||
Paste() (tea.Model, tea.Cmd)
|
||||
Newline() (tea.Model, tea.Cmd)
|
||||
@@ -197,123 +155,60 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
case tea.PasteMsg:
|
||||
// Normalize clipboard text first
|
||||
textRaw := string(msg)
|
||||
text := m.unescapeClipboardText(textRaw)
|
||||
text := string(msg)
|
||||
|
||||
// Case 1: pasted content contains one or more inline @paths -> insert attachments inline
|
||||
// We scan the raw pasted text to preserve original content around attachments.
|
||||
if strings.Contains(textRaw, "@") {
|
||||
last := 0
|
||||
idx := 0
|
||||
inserted := 0
|
||||
for idx < len(textRaw) {
|
||||
r, size := utf8.DecodeRuneInString(textRaw[idx:])
|
||||
if r != '@' {
|
||||
idx += size
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert preceding chunk before attempting to consume a path
|
||||
if idx > last {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(textRaw[last:idx]))
|
||||
}
|
||||
|
||||
// Extract candidate path after '@' up to whitespace
|
||||
start := idx + size
|
||||
end := start
|
||||
for end < len(textRaw) {
|
||||
nr, ns := utf8.DecodeRuneInString(textRaw[end:])
|
||||
if nr == ' ' || nr == '\t' || nr == '\n' || nr == '\r' {
|
||||
break
|
||||
}
|
||||
end += ns
|
||||
}
|
||||
|
||||
if end > start {
|
||||
raw := textRaw[start:end]
|
||||
// Trim common trailing punctuation that may follow paths in prose
|
||||
trimmed := strings.TrimRight(raw, ",.;:)]}\\\"'?!")
|
||||
suffix := raw[len(trimmed):]
|
||||
p := filepath.Clean(trimmed)
|
||||
if m.pathExists(p) {
|
||||
att := m.createAttachmentFromPath(p)
|
||||
if att != nil {
|
||||
m.textarea.InsertAttachment(att)
|
||||
if suffix != "" {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(suffix))
|
||||
}
|
||||
// Insert a trailing space only if the next rune isn't already whitespace
|
||||
insertSpace := true
|
||||
if end < len(textRaw) {
|
||||
nr, _ := utf8.DecodeRuneInString(textRaw[end:])
|
||||
if nr == ' ' || nr == '\t' || nr == '\n' || nr == '\r' {
|
||||
insertSpace = false
|
||||
}
|
||||
}
|
||||
if insertSpace {
|
||||
m.textarea.InsertString(" ")
|
||||
}
|
||||
inserted++
|
||||
last = end
|
||||
idx = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No valid path -> keep the '@' literally
|
||||
m.textarea.InsertRune('@')
|
||||
last = start
|
||||
idx = start
|
||||
if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" {
|
||||
statPath := filePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
statPath = filepath.Join(m.app.Info.Path.Cwd, filePath)
|
||||
}
|
||||
// Insert any trailing content after the last processed segment
|
||||
if last < len(textRaw) {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(textRaw[last:]))
|
||||
}
|
||||
if inserted > 0 {
|
||||
return m, util.CmdHandler(AttachmentInsertedMsg{})
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: user typed '@' and then pasted a valid path -> replace '@' with attachment
|
||||
at := m.textarea.LastRuneIndex('@')
|
||||
if at != -1 && at == m.textarea.CursorColumn()-1 {
|
||||
p := filepath.Clean(text)
|
||||
if m.pathExists(p) {
|
||||
cur := m.textarea.CursorColumn()
|
||||
m.textarea.ReplaceRange(at, cur, "")
|
||||
att := m.createAttachmentFromPath(p)
|
||||
if att != nil {
|
||||
m.textarea.InsertAttachment(att)
|
||||
if _, err := os.Stat(statPath); err == nil {
|
||||
attachment := m.createAttachmentFromPath(filePath)
|
||||
if attachment != nil {
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, util.CmdHandler(AttachmentInsertedMsg{})
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: plain path pasted (e.g., drag-and-drop) -> attach if image or PDF
|
||||
{
|
||||
p := filepath.Clean(text)
|
||||
if m.pathExists(p) {
|
||||
mime := getMediaTypeFromExtension(strings.ToLower(filepath.Ext(p)))
|
||||
if strings.HasPrefix(mime, "image/") || mime == "application/pdf" {
|
||||
if att := m.createAttachmentFromFile(p); att != nil {
|
||||
m.textarea.InsertAttachment(att)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, util.CmdHandler(AttachmentInsertedMsg{})
|
||||
}
|
||||
}
|
||||
text = strings.ReplaceAll(text, "\\", "")
|
||||
text, err := strconv.Unquote(`"` + text + `"`)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unquote text", "error", err)
|
||||
text := string(msg)
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
}
|
||||
|
||||
// Default: do not auto-convert. Insert raw text or summarize long pastes.
|
||||
if m.shouldSummarizePastedText(textRaw) {
|
||||
m.handleLongPaste(textRaw)
|
||||
return m, nil
|
||||
}
|
||||
m.textarea.InsertRunesFromUserInput([]rune(textRaw))
|
||||
return m, nil
|
||||
if _, err := os.Stat(text); err != nil {
|
||||
slog.Error("Failed to paste file", "error", err)
|
||||
text := string(msg)
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
filePath := text
|
||||
|
||||
attachment := m.createAttachmentFromFile(filePath)
|
||||
if attachment == nil {
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
case tea.ClipboardMsg:
|
||||
text := string(msg)
|
||||
// Check if the pasted text is long and should be summarized
|
||||
@@ -340,7 +235,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if atIndex == -1 {
|
||||
// Should not happen, but as a fallback, just insert.
|
||||
m.textarea.InsertString(msg.Item.Value + " ")
|
||||
return m, util.CmdHandler(AttachmentInsertedMsg{})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// The range to replace is from the '@' up to the current cursor position.
|
||||
@@ -354,13 +249,13 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
attachment := m.createAttachmentFromPath(filePath)
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, util.CmdHandler(AttachmentInsertedMsg{})
|
||||
return m, nil
|
||||
case "symbols":
|
||||
atIndex := m.textarea.LastRuneIndex('@')
|
||||
if atIndex == -1 {
|
||||
// Should not happen, but as a fallback, just insert.
|
||||
m.textarea.InsertString(msg.Item.Value + " ")
|
||||
return m, util.CmdHandler(AttachmentInsertedMsg{})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
cursorCol := m.textarea.CursorColumn()
|
||||
@@ -394,13 +289,13 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, util.CmdHandler(AttachmentInsertedMsg{})
|
||||
return m, nil
|
||||
case "agents":
|
||||
atIndex := m.textarea.LastRuneIndex('@')
|
||||
if atIndex == -1 {
|
||||
// Should not happen, but as a fallback, just insert.
|
||||
m.textarea.InsertString(msg.Item.Value + " ")
|
||||
return m, util.CmdHandler(AttachmentInsertedMsg{})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
cursorCol := m.textarea.CursorColumn()
|
||||
@@ -418,7 +313,8 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, util.CmdHandler(AttachmentInsertedMsg{})
|
||||
return m, nil
|
||||
|
||||
default:
|
||||
slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
|
||||
return m, nil
|
||||
@@ -447,6 +343,14 @@ func (m *editorComponent) Content() string {
|
||||
Padding(0, 0, 0, 1).
|
||||
Bold(true)
|
||||
prompt := promptStyle.Render(">")
|
||||
borderForeground := t.Border()
|
||||
if m.app.IsLeaderSequence {
|
||||
borderForeground = t.Accent()
|
||||
}
|
||||
if m.app.IsBashMode {
|
||||
borderForeground = t.Secondary()
|
||||
prompt = promptStyle.Render("!")
|
||||
}
|
||||
|
||||
m.textarea.SetWidth(width - 6)
|
||||
textarea := lipgloss.JoinHorizontal(
|
||||
@@ -454,10 +358,6 @@ func (m *editorComponent) Content() string {
|
||||
prompt,
|
||||
m.textarea.View(),
|
||||
)
|
||||
borderForeground := t.Border()
|
||||
if m.app.IsLeaderSequence {
|
||||
borderForeground = t.Accent()
|
||||
}
|
||||
textarea = styles.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Width(width).
|
||||
@@ -513,6 +413,10 @@ func (m *editorComponent) Content() string {
|
||||
return content
|
||||
}
|
||||
|
||||
func (m *editorComponent) Cursor() *tea.Cursor {
|
||||
return m.textarea.Cursor()
|
||||
}
|
||||
|
||||
func (m *editorComponent) View() string {
|
||||
width := m.width
|
||||
if m.app.Session.ID == "" {
|
||||
@@ -590,6 +494,16 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) SubmitBash() (tea.Model, tea.Cmd) {
|
||||
command := m.textarea.Value()
|
||||
var cmds []tea.Cmd
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, util.CmdHandler(app.SendShell{Command: command}))
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
|
||||
m.textarea.Reset()
|
||||
m.historyIndex = -1
|
||||
@@ -800,6 +714,7 @@ func NewEditorComponent(app *app.App) EditorComponent {
|
||||
ta.Prompt = " "
|
||||
ta.ShowLineNumbers = false
|
||||
ta.CharLimit = -1
|
||||
ta.VirtualCursor = false
|
||||
ta = updateTextareaStyles(ta)
|
||||
|
||||
m := &editorComponent{
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/textarea"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
)
|
||||
|
||||
func newTestEditor() *editorComponent {
|
||||
m := &editorComponent{
|
||||
app: &app.App{},
|
||||
textarea: textarea.New(),
|
||||
spinner: spinner.New(),
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestPasteAtPathWithTrailingComma_PreservesPunctuation_NoDoubleSpace(t *testing.T) {
|
||||
m := newTestEditor()
|
||||
p := createTempTextFile(t, "", "pc.txt", "x")
|
||||
|
||||
paste := "See @" + p + ", next"
|
||||
_, cmd := m.Update(tea.PasteMsg(paste))
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected command to be returned for comma punctuation paste")
|
||||
}
|
||||
if _, ok := cmd().(AttachmentInsertedMsg); !ok {
|
||||
t.Fatalf("expected AttachmentInsertedMsg for comma punctuation paste")
|
||||
}
|
||||
if len(m.textarea.GetAttachments()) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(m.textarea.GetAttachments()))
|
||||
}
|
||||
v := m.Value()
|
||||
if !strings.Contains(v, ", next") {
|
||||
t.Fatalf("expected comma and following text to be preserved, got: %q", v)
|
||||
}
|
||||
if strings.Contains(v, ", next") {
|
||||
t.Fatalf("did not expect double space after comma, got: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteAtPathWithTrailingQuestion_PreservesPunctuation_NoDoubleSpace(t *testing.T) {
|
||||
m := newTestEditor()
|
||||
p := createTempTextFile(t, "", "pq.txt", "x")
|
||||
|
||||
paste := "Check @" + p + "? Done"
|
||||
_, cmd := m.Update(tea.PasteMsg(paste))
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected command to be returned for question punctuation paste")
|
||||
}
|
||||
if _, ok := cmd().(AttachmentInsertedMsg); !ok {
|
||||
t.Fatalf("expected AttachmentInsertedMsg for question punctuation paste")
|
||||
}
|
||||
if len(m.textarea.GetAttachments()) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(m.textarea.GetAttachments()))
|
||||
}
|
||||
v := m.Value()
|
||||
if !strings.Contains(v, "? Done") {
|
||||
t.Fatalf("expected question mark and following text to be preserved, got: %q", v)
|
||||
}
|
||||
if strings.Contains(v, "? Done") {
|
||||
t.Fatalf("did not expect double space after question mark, got: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteMultipleInlineAtPaths_AttachesEach(t *testing.T) {
|
||||
m := newTestEditor()
|
||||
dir := t.TempDir()
|
||||
p1 := createTempTextFile(t, dir, "m1.txt", "one")
|
||||
p2 := createTempTextFile(t, dir, "m2.txt", "two")
|
||||
|
||||
// Build a paste with text around, two @paths, and punctuation after the first
|
||||
paste := "Please check @" + p1 + ", and also @" + p2 + " thanks"
|
||||
|
||||
_, cmd := m.Update(tea.PasteMsg(paste))
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected command to be returned for multi inline paste")
|
||||
}
|
||||
if _, ok := cmd().(AttachmentInsertedMsg); !ok {
|
||||
t.Fatalf("expected AttachmentInsertedMsg for multi inline paste")
|
||||
}
|
||||
|
||||
atts := m.textarea.GetAttachments()
|
||||
if len(atts) != 2 {
|
||||
t.Fatalf("expected 2 attachments, got %d", len(atts))
|
||||
}
|
||||
v := m.Value()
|
||||
if !strings.Contains(v, "Please check") || !strings.Contains(v, "and also") || !strings.Contains(v, "thanks") {
|
||||
t.Fatalf("expected surrounding text to be preserved, got: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func createTempTextFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
if dir == "" {
|
||||
td, err := os.MkdirTemp("", "editor-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to make temp dir: %v", err)
|
||||
}
|
||||
dir = td
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("failed to write temp file: %v", err)
|
||||
}
|
||||
abs, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get abs path: %v", err)
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
func createTempBinFile(t *testing.T, dir, name string, data []byte) string {
|
||||
t.Helper()
|
||||
if dir == "" {
|
||||
td, err := os.MkdirTemp("", "editor-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to make temp dir: %v", err)
|
||||
}
|
||||
dir = td
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, data, 0o600); err != nil {
|
||||
t.Fatalf("failed to write temp bin file: %v", err)
|
||||
}
|
||||
abs, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get abs path: %v", err)
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
func TestPasteStartsWithAt_AttachesAndEmitsMsg(t *testing.T) {
|
||||
m := newTestEditor()
|
||||
p := createTempTextFile(t, "", "a.txt", "hello")
|
||||
|
||||
_, cmd := m.Update(tea.PasteMsg("@" + p))
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected command to be returned")
|
||||
}
|
||||
msg := cmd()
|
||||
if _, ok := msg.(AttachmentInsertedMsg); !ok {
|
||||
t.Fatalf("expected AttachmentInsertedMsg, got %T", msg)
|
||||
}
|
||||
|
||||
atts := m.textarea.GetAttachments()
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(atts))
|
||||
}
|
||||
if v := m.Value(); !strings.HasSuffix(v, " ") {
|
||||
t.Fatalf("expected trailing space after attachment, got value: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteAfterAt_ReplacesAtWithAttachment(t *testing.T) {
|
||||
m := newTestEditor()
|
||||
p := createTempTextFile(t, "", "b.txt", "hello")
|
||||
|
||||
m.textarea.SetValue("@")
|
||||
// Cursor should be at the end after SetValue; paste absolute path
|
||||
_, cmd := m.Update(tea.PasteMsg(p))
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected command to be returned")
|
||||
}
|
||||
if _, ok := cmd().(AttachmentInsertedMsg); !ok {
|
||||
t.Fatalf("expected AttachmentInsertedMsg from paste after '@'")
|
||||
}
|
||||
|
||||
// Ensure the raw '@' rune was removed (attachment inserted in its place)
|
||||
if m.textarea.LastRuneIndex('@') != -1 {
|
||||
t.Fatalf("'@' rune should have been removed from the text slice")
|
||||
}
|
||||
if len(m.textarea.GetAttachments()) != 1 {
|
||||
t.Fatalf("expected 1 attachment inserted")
|
||||
}
|
||||
if v := m.Value(); !strings.HasSuffix(v, " ") {
|
||||
t.Fatalf("expected trailing space after attachment, got value: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainTextPaste_NoAttachment_NoMsg(t *testing.T) {
|
||||
m := newTestEditor()
|
||||
_, cmd := m.Update(tea.PasteMsg("hello"))
|
||||
if cmd != nil {
|
||||
t.Fatalf("expected no command for plain text paste")
|
||||
}
|
||||
if got := m.Value(); got != "hello" {
|
||||
t.Fatalf("expected value 'hello', got %q", got)
|
||||
}
|
||||
if len(m.textarea.GetAttachments()) != 0 {
|
||||
t.Fatalf("expected no attachments for plain text paste")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainPathPng_AttachesImage(t *testing.T) {
|
||||
m := newTestEditor()
|
||||
// Minimal bytes; content isn't validated, extension determines mime
|
||||
p := createTempBinFile(t, "", "img.png", []byte{0x89, 'P', 'N', 'G'})
|
||||
|
||||
_, cmd := m.Update(tea.PasteMsg(p))
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected command to be returned for image path paste")
|
||||
}
|
||||
if _, ok := cmd().(AttachmentInsertedMsg); !ok {
|
||||
t.Fatalf("expected AttachmentInsertedMsg for image path paste")
|
||||
}
|
||||
atts := m.textarea.GetAttachments()
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(atts))
|
||||
}
|
||||
if atts[0].MediaType != "image/png" {
|
||||
t.Fatalf("expected image/png mime, got %q", atts[0].MediaType)
|
||||
}
|
||||
if v := m.Value(); !strings.HasSuffix(v, " ") {
|
||||
t.Fatalf("expected trailing space after attachment, got value: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainPathPdf_AttachesPDF(t *testing.T) {
|
||||
m := newTestEditor()
|
||||
p := createTempBinFile(t, "", "doc.pdf", []byte("%PDF-1.4"))
|
||||
|
||||
_, cmd := m.Update(tea.PasteMsg(p))
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected command to be returned for pdf path paste")
|
||||
}
|
||||
if _, ok := cmd().(AttachmentInsertedMsg); !ok {
|
||||
t.Fatalf("expected AttachmentInsertedMsg for pdf path paste")
|
||||
}
|
||||
atts := m.textarea.GetAttachments()
|
||||
if len(atts) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(atts))
|
||||
}
|
||||
if atts[0].MediaType != "application/pdf" {
|
||||
t.Fatalf("expected application/pdf mime, got %q", atts[0].MediaType)
|
||||
}
|
||||
if v := m.Value(); !strings.HasSuffix(v, " ") {
|
||||
t.Fatalf("expected trailing space after attachment, got value: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionFiles_InsertsAttachment_EmitsMsg(t *testing.T) {
|
||||
m := newTestEditor()
|
||||
p := createTempTextFile(t, "", "c.txt", "hello")
|
||||
m.textarea.SetValue("@")
|
||||
|
||||
item := completions.CompletionSuggestion{
|
||||
ProviderID: "files",
|
||||
Value: p,
|
||||
Display: func(_ styles.Style) string { return p },
|
||||
}
|
||||
// Build the completion selected message as if the user selected from the dialog
|
||||
msg := dialog.CompletionSelectedMsg{Item: item, SearchString: "@"}
|
||||
|
||||
_, cmd := m.Update(msg)
|
||||
if cmd == nil {
|
||||
t.Fatalf("expected command to be returned")
|
||||
}
|
||||
if _, ok := cmd().(AttachmentInsertedMsg); !ok {
|
||||
t.Fatalf("expected AttachmentInsertedMsg from files completion selection")
|
||||
}
|
||||
if len(m.textarea.GetAttachments()) != 1 {
|
||||
t.Fatalf("expected 1 attachment inserted from completion selection")
|
||||
}
|
||||
if v := m.Value(); !strings.HasSuffix(v, " ") {
|
||||
t.Fatalf("expected trailing space after attachment, got value: %q", v)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/diff"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -183,6 +184,8 @@ func renderContentBlock(
|
||||
if renderer.borderRight {
|
||||
style = style.BorderRightForeground(borderColor)
|
||||
}
|
||||
} else {
|
||||
style = style.PaddingLeft(renderer.paddingLeft + 1).PaddingRight(renderer.paddingRight + 1)
|
||||
}
|
||||
|
||||
content = style.Render(content)
|
||||
@@ -209,6 +212,7 @@ func renderText(
|
||||
width int,
|
||||
extra string,
|
||||
isThinking bool,
|
||||
isQueued bool,
|
||||
fileParts []opencode.FilePart,
|
||||
agentParts []opencode.AgentPart,
|
||||
toolCalls ...opencode.ToolPart,
|
||||
@@ -220,14 +224,17 @@ func renderText(
|
||||
var content string
|
||||
switch casted := message.(type) {
|
||||
case opencode.AssistantMessage:
|
||||
bg := t.Background()
|
||||
backgroundColor = t.Background()
|
||||
if isThinking {
|
||||
bg = t.BackgroundPanel()
|
||||
backgroundColor = t.BackgroundPanel()
|
||||
}
|
||||
ts = time.UnixMilli(int64(casted.Time.Created))
|
||||
content = util.ToMarkdown(text, width, bg)
|
||||
if casted.Time.Completed > 0 {
|
||||
ts = time.UnixMilli(int64(casted.Time.Completed))
|
||||
}
|
||||
content = util.ToMarkdown(text, width, backgroundColor)
|
||||
if isThinking {
|
||||
content = styles.NewStyle().Background(bg).Foreground(t.TextMuted()).Render("Thinking") + "\n\n" + content
|
||||
content = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking") + "\n\n" + content
|
||||
}
|
||||
case opencode.UserMessage:
|
||||
ts = time.UnixMilli(int64(casted.Time.Created))
|
||||
@@ -322,8 +329,14 @@ func renderText(
|
||||
|
||||
// wrap styled text
|
||||
styledText := result.String()
|
||||
wrappedText := ansi.WordwrapWc(styledText, width-6, " -")
|
||||
styledText = strings.ReplaceAll(styledText, "-", "\u2011")
|
||||
wrappedText := ansi.WordwrapWc(styledText, width-6, " ")
|
||||
wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-")
|
||||
content = base.Width(width - 6).Render(wrappedText)
|
||||
if isQueued {
|
||||
queuedStyle := styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Bold(true).Padding(0, 1)
|
||||
content = queuedStyle.Render("QUEUED") + "\n\n" + content
|
||||
}
|
||||
}
|
||||
|
||||
timestamp := ts.
|
||||
@@ -332,8 +345,12 @@ func renderText(
|
||||
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
|
||||
timestamp = timestamp[12:]
|
||||
}
|
||||
timestamp = styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
Foreground(t.TextMuted()).
|
||||
Render(" (" + timestamp + ")")
|
||||
|
||||
// Check if this is an assistant message with mode (agent) information
|
||||
// Check if this is an assistant message with agent information
|
||||
var modelAndAgentSuffix string
|
||||
if assistantMsg, ok := message.(opencode.AssistantMessage); ok && assistantMsg.Mode != "" {
|
||||
// Find the agent index by name to get the correct color
|
||||
@@ -349,22 +366,25 @@ func renderText(
|
||||
agentColor := util.GetAgentColor(agentIndex)
|
||||
|
||||
// Style the agent name with the same color as status bar
|
||||
agentName := strings.Title(assistantMsg.Mode)
|
||||
styledAgentName := styles.NewStyle().Foreground(agentColor).Bold(true).Render(agentName)
|
||||
modelAndAgentSuffix = fmt.Sprintf(" | %s | %s", assistantMsg.ModelID, styledAgentName)
|
||||
agentName := cases.Title(language.Und).String(assistantMsg.Mode)
|
||||
styledAgentName := styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
Foreground(agentColor).
|
||||
Render(agentName + " ")
|
||||
styledModelID := styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
Foreground(t.TextMuted()).
|
||||
Render(assistantMsg.ModelID)
|
||||
modelAndAgentSuffix = styledAgentName + styledModelID
|
||||
}
|
||||
|
||||
var info string
|
||||
if modelAndAgentSuffix != "" {
|
||||
// For assistant messages: "timestamp | modelID | agentName"
|
||||
info = fmt.Sprintf("%s%s", timestamp, modelAndAgentSuffix)
|
||||
info = modelAndAgentSuffix + timestamp
|
||||
} else {
|
||||
// For user messages: "author (timestamp)"
|
||||
info = fmt.Sprintf("%s (%s)", author, timestamp)
|
||||
info = author + timestamp
|
||||
}
|
||||
info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
|
||||
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
|
||||
content = content + "\n\n"
|
||||
for _, toolCall := range toolCalls {
|
||||
title := renderToolTitle(toolCall, width-2)
|
||||
style := styles.NewStyle()
|
||||
@@ -372,25 +392,30 @@ func renderText(
|
||||
style = style.Foreground(t.Error())
|
||||
}
|
||||
title = style.Render(title)
|
||||
title = "∟ " + title + "\n"
|
||||
title = "\n∟ " + title
|
||||
content = content + title
|
||||
}
|
||||
}
|
||||
|
||||
sections := []string{content, info}
|
||||
sections := []string{content}
|
||||
if extra != "" {
|
||||
sections = append(sections, "\n"+extra)
|
||||
sections = append(sections, "\n"+extra+"\n")
|
||||
}
|
||||
sections = append(sections, info)
|
||||
content = strings.Join(sections, "\n")
|
||||
|
||||
switch message.(type) {
|
||||
case opencode.UserMessage:
|
||||
borderColor := t.Secondary()
|
||||
if isQueued {
|
||||
borderColor = t.Accent()
|
||||
}
|
||||
return renderContentBlock(
|
||||
app,
|
||||
content,
|
||||
width,
|
||||
WithTextColor(t.Text()),
|
||||
WithBorderColor(t.Secondary()),
|
||||
WithBorderColor(borderColor),
|
||||
)
|
||||
case opencode.AssistantMessage:
|
||||
if isThinking {
|
||||
@@ -455,6 +480,8 @@ func renderToolDetails(
|
||||
backgroundColor := t.BackgroundPanel()
|
||||
borderColor := t.BackgroundPanel()
|
||||
defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
|
||||
baseStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.Text()).Render
|
||||
mutedStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render
|
||||
|
||||
permissionContent := ""
|
||||
if permission.ID != "" {
|
||||
@@ -539,6 +566,17 @@ func renderToolDetails(
|
||||
title := renderToolTitle(toolCall, width)
|
||||
title = style.Render(title)
|
||||
content := title + "\n" + body
|
||||
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusError {
|
||||
errorStyle := styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
Foreground(t.Error()).
|
||||
Padding(1, 2).
|
||||
Width(width - 4)
|
||||
errorContent := errorStyle.Render(toolCall.State.Error)
|
||||
content += "\n" + errorContent
|
||||
}
|
||||
|
||||
if permissionContent != "" {
|
||||
permissionContent = styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
@@ -567,18 +605,15 @@ func renderToolDetails(
|
||||
}
|
||||
}
|
||||
case "bash":
|
||||
command := toolInputMap["command"].(string)
|
||||
body = fmt.Sprintf("```console\n$ %s\n", command)
|
||||
stdout := metadata["stdout"]
|
||||
if stdout != nil {
|
||||
body += ansi.Strip(fmt.Sprintf("%s", stdout))
|
||||
if command, ok := toolInputMap["command"].(string); ok {
|
||||
body = fmt.Sprintf("```console\n$ %s\n", command)
|
||||
output := metadata["output"]
|
||||
if output != nil {
|
||||
body += ansi.Strip(fmt.Sprintf("%s", output))
|
||||
}
|
||||
body += "```"
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
}
|
||||
stderr := metadata["stderr"]
|
||||
if stderr != nil {
|
||||
body += ansi.Strip(fmt.Sprintf("%s", stderr))
|
||||
}
|
||||
body += "```"
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
case "webfetch":
|
||||
if format, ok := toolInputMap["format"].(string); ok && result != nil {
|
||||
body = *result
|
||||
@@ -622,6 +657,12 @@ func renderToolDetails(
|
||||
steps = append(steps, step)
|
||||
}
|
||||
body = strings.Join(steps, "\n")
|
||||
|
||||
body += "\n\n"
|
||||
body += baseStyle(app.Keybind(commands.SessionChildCycleCommand)) +
|
||||
mutedStyle(", ") +
|
||||
baseStyle(app.Keybind(commands.SessionChildCycleReverseCommand)) +
|
||||
mutedStyle(" navigate child sessions")
|
||||
}
|
||||
body = defaultStyle(body)
|
||||
default:
|
||||
@@ -641,11 +682,17 @@ func renderToolDetails(
|
||||
}
|
||||
|
||||
if error != "" {
|
||||
body = styles.NewStyle().
|
||||
errorContent := styles.NewStyle().
|
||||
Width(width - 6).
|
||||
Foreground(t.Error()).
|
||||
Background(backgroundColor).
|
||||
Render(error)
|
||||
|
||||
if body == "" {
|
||||
body = errorContent
|
||||
} else {
|
||||
body += "\n\n" + errorContent
|
||||
}
|
||||
}
|
||||
|
||||
if body == "" && error == "" && result != nil {
|
||||
@@ -676,6 +723,8 @@ func renderToolDetails(
|
||||
|
||||
func renderToolName(name string) string {
|
||||
switch name {
|
||||
case "bash":
|
||||
return "Shell"
|
||||
case "webfetch":
|
||||
return "Fetch"
|
||||
case "invalid":
|
||||
|
||||
@@ -33,28 +33,32 @@ type MessagesComponent interface {
|
||||
HalfPageUp() (tea.Model, tea.Cmd)
|
||||
HalfPageDown() (tea.Model, tea.Cmd)
|
||||
ToolDetailsVisible() bool
|
||||
ThinkingBlocksVisible() bool
|
||||
GotoTop() (tea.Model, tea.Cmd)
|
||||
GotoBottom() (tea.Model, tea.Cmd)
|
||||
CopyLastMessage() (tea.Model, tea.Cmd)
|
||||
UndoLastMessage() (tea.Model, tea.Cmd)
|
||||
RedoLastMessage() (tea.Model, tea.Cmd)
|
||||
ScrollToMessage(messageID string) (tea.Model, tea.Cmd)
|
||||
}
|
||||
|
||||
type messagesComponent struct {
|
||||
width, height int
|
||||
app *app.App
|
||||
header string
|
||||
viewport viewport.Model
|
||||
clipboard []string
|
||||
cache *PartCache
|
||||
loading bool
|
||||
showToolDetails bool
|
||||
rendering bool
|
||||
dirty bool
|
||||
tail bool
|
||||
partCount int
|
||||
lineCount int
|
||||
selection *selection
|
||||
width, height int
|
||||
app *app.App
|
||||
header string
|
||||
viewport viewport.Model
|
||||
clipboard []string
|
||||
cache *PartCache
|
||||
loading bool
|
||||
showToolDetails bool
|
||||
showThinkingBlocks bool
|
||||
rendering bool
|
||||
dirty bool
|
||||
tail bool
|
||||
partCount int
|
||||
lineCount int
|
||||
selection *selection
|
||||
messagePositions map[string]int // map message ID to line position
|
||||
}
|
||||
|
||||
type selection struct {
|
||||
@@ -94,6 +98,7 @@ func (s selection) coords(offset int) *selection {
|
||||
}
|
||||
|
||||
type ToggleToolDetailsMsg struct{}
|
||||
type ToggleThinkingBlocksMsg struct{}
|
||||
|
||||
func (m *messagesComponent) Init() tea.Cmd {
|
||||
return tea.Batch(m.viewport.Init())
|
||||
@@ -160,7 +165,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.renderView()
|
||||
case ToggleToolDetailsMsg:
|
||||
m.showToolDetails = !m.showToolDetails
|
||||
return m, m.renderView()
|
||||
m.app.State.ShowToolDetails = &m.showToolDetails
|
||||
return m, tea.Batch(m.renderView(), m.app.SaveState())
|
||||
case ToggleThinkingBlocksMsg:
|
||||
m.showThinkingBlocks = !m.showThinkingBlocks
|
||||
m.app.State.ShowThinkingBlocks = &m.showThinkingBlocks
|
||||
return m, tea.Batch(m.renderView(), m.app.SaveState())
|
||||
case app.SessionLoadedMsg, app.SessionClearedMsg:
|
||||
m.cache.Clear()
|
||||
m.tail = true
|
||||
@@ -172,6 +182,8 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.tail = true
|
||||
return m, m.renderView()
|
||||
}
|
||||
case app.SessionSelectedMsg:
|
||||
m.viewport.GotoBottom()
|
||||
case app.MessageRevertedMsg:
|
||||
if msg.Session.ID == m.app.Session.ID {
|
||||
m.cache.Clear()
|
||||
@@ -187,10 +199,19 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.Properties.Info.SessionID == m.app.Session.ID {
|
||||
cmds = append(cmds, m.renderView())
|
||||
}
|
||||
case opencode.EventListResponseEventSessionError:
|
||||
if msg.Properties.SessionID == m.app.Session.ID {
|
||||
cmds = append(cmds, m.renderView())
|
||||
}
|
||||
case opencode.EventListResponseEventMessagePartUpdated:
|
||||
if msg.Properties.Part.SessionID == m.app.Session.ID {
|
||||
cmds = append(cmds, m.renderView())
|
||||
}
|
||||
case opencode.EventListResponseEventMessageRemoved:
|
||||
if msg.Properties.SessionID == m.app.Session.ID {
|
||||
m.cache.Clear()
|
||||
cmds = append(cmds, m.renderView())
|
||||
}
|
||||
case opencode.EventListResponseEventMessagePartRemoved:
|
||||
if msg.Properties.SessionID == m.app.Session.ID {
|
||||
// Clear the cache when a part is removed to ensure proper re-rendering
|
||||
@@ -209,6 +230,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.rendering = false
|
||||
m.clipboard = msg.clipboard
|
||||
m.loading = false
|
||||
m.messagePositions = msg.messagePositions
|
||||
m.tail = m.viewport.AtBottom()
|
||||
|
||||
// Preserve scroll across reflow
|
||||
@@ -237,11 +259,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
type renderCompleteMsg struct {
|
||||
viewport viewport.Model
|
||||
clipboard []string
|
||||
header string
|
||||
partCount int
|
||||
lineCount int
|
||||
viewport viewport.Model
|
||||
clipboard []string
|
||||
header string
|
||||
partCount int
|
||||
lineCount int
|
||||
messagePositions map[string]int
|
||||
}
|
||||
|
||||
func (m *messagesComponent) renderView() tea.Cmd {
|
||||
@@ -267,6 +290,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
blocks := make([]string, 0)
|
||||
partCount := 0
|
||||
lineCount := 0
|
||||
messagePositions := make(map[string]int) // Track message ID to line position
|
||||
|
||||
orphanedToolCalls := make([]opencode.ToolPart, 0)
|
||||
|
||||
@@ -285,9 +309,13 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
for _, message := range m.app.Messages {
|
||||
var content string
|
||||
var cached bool
|
||||
error := ""
|
||||
|
||||
switch casted := message.Info.(type) {
|
||||
case opencode.UserMessage:
|
||||
// Track the position of this user message
|
||||
messagePositions[casted.ID] = lineCount
|
||||
|
||||
if casted.ID == m.app.Session.Revert.MessageID {
|
||||
reverted = true
|
||||
revertedMessageCount = 1
|
||||
@@ -355,10 +383,8 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
)
|
||||
|
||||
author := m.app.Config.Username
|
||||
if casted.ID > lastAssistantMessage {
|
||||
author += " [queued]"
|
||||
}
|
||||
key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author)
|
||||
isQueued := casted.ID > lastAssistantMessage
|
||||
key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author, isQueued)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderText(
|
||||
@@ -370,15 +396,10 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
width,
|
||||
files,
|
||||
false,
|
||||
isQueued,
|
||||
fileParts,
|
||||
agentParts,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
if content != "" {
|
||||
@@ -396,6 +417,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
revertedToolCount = 0
|
||||
}
|
||||
hasTextPart := false
|
||||
hasContent := false
|
||||
for partIndex, p := range message.Parts {
|
||||
switch part := p.(type) {
|
||||
case opencode.TextPart:
|
||||
@@ -438,7 +460,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
}
|
||||
|
||||
if finished {
|
||||
key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails)
|
||||
key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, toolCallParts)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderText(
|
||||
@@ -450,16 +472,11 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
width,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
[]opencode.FilePart{},
|
||||
[]opencode.AgentPart{},
|
||||
toolCallParts...,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
} else {
|
||||
@@ -472,21 +489,17 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
width,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
[]opencode.FilePart{},
|
||||
[]opencode.AgentPart{},
|
||||
toolCallParts...,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
}
|
||||
if content != "" {
|
||||
partCount++
|
||||
lineCount += lipgloss.Height(content) + 1
|
||||
blocks = append(blocks, content)
|
||||
hasContent = true
|
||||
}
|
||||
case opencode.ToolPart:
|
||||
if reverted {
|
||||
@@ -521,12 +534,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
permission,
|
||||
width,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
} else {
|
||||
@@ -537,54 +544,44 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
permission,
|
||||
width,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
}
|
||||
if content != "" {
|
||||
partCount++
|
||||
lineCount += lipgloss.Height(content) + 1
|
||||
blocks = append(blocks, content)
|
||||
hasContent = true
|
||||
}
|
||||
case opencode.ReasoningPart:
|
||||
if reverted {
|
||||
continue
|
||||
}
|
||||
text := "..."
|
||||
if part.Text != "" {
|
||||
text = part.Text
|
||||
if !m.showThinkingBlocks {
|
||||
continue
|
||||
}
|
||||
if part.Text != "" {
|
||||
text := part.Text
|
||||
content = renderText(
|
||||
m.app,
|
||||
message.Info,
|
||||
text,
|
||||
casted.ModelID,
|
||||
m.showToolDetails,
|
||||
width,
|
||||
"",
|
||||
true,
|
||||
false,
|
||||
[]opencode.FilePart{},
|
||||
[]opencode.AgentPart{},
|
||||
)
|
||||
partCount++
|
||||
lineCount += lipgloss.Height(content) + 1
|
||||
blocks = append(blocks, content)
|
||||
hasContent = true
|
||||
}
|
||||
content = renderText(
|
||||
m.app,
|
||||
message.Info,
|
||||
text,
|
||||
casted.ModelID,
|
||||
m.showToolDetails,
|
||||
width,
|
||||
"",
|
||||
true,
|
||||
[]opencode.FilePart{},
|
||||
[]opencode.AgentPart{},
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
partCount++
|
||||
lineCount += lipgloss.Height(content) + 1
|
||||
blocks = append(blocks, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error := ""
|
||||
if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
|
||||
switch err := assistant.Error.AsUnion().(type) {
|
||||
switch err := casted.Error.AsUnion().(type) {
|
||||
case nil:
|
||||
case opencode.AssistantMessageErrorMessageOutputLengthError:
|
||||
error = "Message output length exceeded"
|
||||
@@ -595,6 +592,25 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
case opencode.UnknownError:
|
||||
error = err.Data.Message
|
||||
}
|
||||
|
||||
if !hasContent && error == "" && !reverted {
|
||||
content = renderText(
|
||||
m.app,
|
||||
message.Info,
|
||||
"Generating...",
|
||||
casted.ModelID,
|
||||
m.showToolDetails,
|
||||
width,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
[]opencode.FilePart{},
|
||||
[]opencode.AgentPart{},
|
||||
)
|
||||
partCount++
|
||||
lineCount += lipgloss.Height(content) + 1
|
||||
blocks = append(blocks, content)
|
||||
}
|
||||
}
|
||||
|
||||
if error != "" && !reverted {
|
||||
@@ -605,12 +621,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
width,
|
||||
WithBorderColor(t.Error()),
|
||||
)
|
||||
error = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
error,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
blocks = append(blocks, error)
|
||||
lineCount += lipgloss.Height(error) + 1
|
||||
}
|
||||
@@ -702,12 +712,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
m.app.CurrentPermission,
|
||||
width,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
if content != "" {
|
||||
partCount++
|
||||
lineCount += lipgloss.Height(content) + 1
|
||||
@@ -771,11 +775,12 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
}
|
||||
|
||||
return renderCompleteMsg{
|
||||
header: header,
|
||||
clipboard: clipboard,
|
||||
viewport: viewport,
|
||||
partCount: partCount,
|
||||
lineCount: lineCount,
|
||||
header: header,
|
||||
clipboard: clipboard,
|
||||
viewport: viewport,
|
||||
partCount: partCount,
|
||||
lineCount: lineCount,
|
||||
messagePositions: messagePositions,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -788,8 +793,17 @@ func (m *messagesComponent) renderHeader() string {
|
||||
headerWidth := m.width
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||
bgColor := t.Background()
|
||||
borderColor := t.BackgroundElement()
|
||||
|
||||
isChildSession := m.app.Session.ParentID != ""
|
||||
if isChildSession {
|
||||
bgColor = t.BackgroundElement()
|
||||
borderColor = t.Accent()
|
||||
}
|
||||
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render
|
||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render
|
||||
|
||||
sessionInfo := ""
|
||||
tokens := float64(0)
|
||||
@@ -821,20 +835,44 @@ func (m *messagesComponent) renderHeader() string {
|
||||
sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)
|
||||
sessionInfo = styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.Background()).
|
||||
Background(bgColor).
|
||||
Render(sessionInfoText)
|
||||
|
||||
shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
|
||||
|
||||
navHint := ""
|
||||
if isChildSession {
|
||||
navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back")
|
||||
}
|
||||
|
||||
headerTextWidth := headerWidth
|
||||
if !shareEnabled {
|
||||
// +1 is to ensure there is always at least one space between header and session info
|
||||
headerTextWidth -= len(sessionInfoText) + 1
|
||||
if isChildSession {
|
||||
headerTextWidth -= lipgloss.Width(navHint)
|
||||
} else if !shareEnabled {
|
||||
headerTextWidth -= lipgloss.Width(sessionInfoText)
|
||||
}
|
||||
headerText := util.ToMarkdown(
|
||||
"# "+m.app.Session.Title,
|
||||
headerTextWidth,
|
||||
t.Background(),
|
||||
bgColor,
|
||||
)
|
||||
if isChildSession {
|
||||
headerText = layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &bgColor,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Align: layout.AlignStretch,
|
||||
Width: headerTextWidth,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: headerText,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: navHint,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var items []layout.FlexItem
|
||||
if shareEnabled {
|
||||
@@ -847,10 +885,9 @@ func (m *messagesComponent) renderHeader() string {
|
||||
items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
|
||||
}
|
||||
|
||||
background := t.Background()
|
||||
headerRow := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &background,
|
||||
Background: &bgColor,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Align: layout.AlignStretch,
|
||||
@@ -866,22 +903,16 @@ func (m *messagesComponent) renderHeader() string {
|
||||
|
||||
header := strings.Join(headerLines, "\n")
|
||||
header = styles.NewStyle().
|
||||
Background(t.Background()).
|
||||
Background(bgColor).
|
||||
Width(headerWidth).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.BackgroundElement()).
|
||||
BorderForeground(borderColor).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
Render(header)
|
||||
header = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
header,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
|
||||
return "\n" + header + "\n"
|
||||
}
|
||||
@@ -926,7 +957,7 @@ func formatTokensAndCost(
|
||||
|
||||
formattedCost := fmt.Sprintf("$%.2f", cost)
|
||||
return fmt.Sprintf(
|
||||
"%s/%d%% (%s)",
|
||||
" %s/%d%% (%s)",
|
||||
formattedTokens,
|
||||
int(percentage),
|
||||
formattedCost,
|
||||
@@ -935,20 +966,22 @@ func formatTokensAndCost(
|
||||
|
||||
func (m *messagesComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.Background()
|
||||
|
||||
if m.loading {
|
||||
return lipgloss.Place(
|
||||
m.width,
|
||||
m.height,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
styles.NewStyle().Background(t.Background()).Render(""),
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
styles.NewStyle().Background(bgColor).Render(""),
|
||||
styles.WhitespaceStyle(bgColor),
|
||||
)
|
||||
}
|
||||
|
||||
viewport := m.viewport.View()
|
||||
return styles.NewStyle().
|
||||
Background(t.Background()).
|
||||
Background(bgColor).
|
||||
Render(m.header + "\n" + viewport)
|
||||
}
|
||||
|
||||
@@ -976,6 +1009,10 @@ func (m *messagesComponent) ToolDetailsVisible() bool {
|
||||
return m.showToolDetails
|
||||
}
|
||||
|
||||
func (m *messagesComponent) ThinkingBlocksVisible() bool {
|
||||
return m.showThinkingBlocks
|
||||
}
|
||||
|
||||
func (m *messagesComponent) GotoTop() (tea.Model, tea.Cmd) {
|
||||
m.viewport.GotoTop()
|
||||
return m, nil
|
||||
@@ -1162,21 +1199,42 @@ func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) {
|
||||
if m.messagePositions == nil {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if position, exists := m.messagePositions[messageID]; exists {
|
||||
m.viewport.SetYOffset(position)
|
||||
m.tail = false // Stop auto-scrolling to bottom when manually navigating
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func NewMessagesComponent(app *app.App) MessagesComponent {
|
||||
vp := viewport.New()
|
||||
vp.KeyMap = viewport.KeyMap{}
|
||||
|
||||
if app.State.ScrollSpeed != nil && *app.State.ScrollSpeed > 0 {
|
||||
vp.MouseWheelDelta = *app.State.ScrollSpeed
|
||||
} else {
|
||||
vp.MouseWheelDelta = 4
|
||||
vp.MouseWheelDelta = app.ScrollSpeed
|
||||
|
||||
// Default to showing tool details, hidden thinking blocks
|
||||
showToolDetails := true
|
||||
if app.State.ShowToolDetails != nil {
|
||||
showToolDetails = *app.State.ShowToolDetails
|
||||
}
|
||||
|
||||
showThinkingBlocks := false
|
||||
if app.State.ShowThinkingBlocks != nil {
|
||||
showThinkingBlocks = *app.State.ShowThinkingBlocks
|
||||
}
|
||||
|
||||
return &messagesComponent{
|
||||
app: app,
|
||||
viewport: vp,
|
||||
showToolDetails: true,
|
||||
cache: NewPartCache(),
|
||||
tail: true,
|
||||
app: app,
|
||||
viewport: vp,
|
||||
showToolDetails: showToolDetails,
|
||||
showThinkingBlocks: showThinkingBlocks,
|
||||
cache: NewPartCache(),
|
||||
tail: true,
|
||||
messagePositions: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +83,10 @@ func (c *commandsComponent) View() string {
|
||||
}
|
||||
commandsToShow = append(commandsToShow,
|
||||
// empty line
|
||||
commands.Command{
|
||||
Name: "",
|
||||
Description: "",
|
||||
},
|
||||
// commands.Command{
|
||||
// Name: "",
|
||||
// Description: "",
|
||||
// },
|
||||
commands.Command{
|
||||
Name: commands.CommandName(util.Ide()),
|
||||
Description: "open opencode",
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
numVisibleAgents = 10
|
||||
minAgentDialogWidth = 40
|
||||
maxAgentDialogWidth = 60
|
||||
maxDescriptionLength = 60
|
||||
maxRecentAgents = 5
|
||||
)
|
||||
|
||||
// AgentDialog interface for the agent selection dialog
|
||||
type AgentDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
type agentDialog struct {
|
||||
app *app.App
|
||||
allAgents []agentSelectItem
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
searchDialog *SearchDialog
|
||||
dialogWidth int
|
||||
}
|
||||
|
||||
// agentSelectItem combines the visual improvements with code patterns
|
||||
type agentSelectItem struct {
|
||||
name string
|
||||
displayName string
|
||||
description string
|
||||
mode string // "primary", "subagent", "all"
|
||||
isCurrent bool
|
||||
agentIndex int
|
||||
agent opencode.Agent // Keep original agent for compatibility
|
||||
}
|
||||
|
||||
func (a agentSelectItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundPanel()).
|
||||
Foreground(t.Text())
|
||||
|
||||
if selected {
|
||||
// Use agent color for highlighting when selected (visual improvement)
|
||||
agentColor := util.GetAgentColor(a.agentIndex)
|
||||
itemStyle = itemStyle.Foreground(agentColor)
|
||||
}
|
||||
|
||||
descStyle := baseStyle.
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundPanel())
|
||||
|
||||
// Calculate available width (accounting for padding and margins)
|
||||
availableWidth := width - 2 // Account for left padding
|
||||
|
||||
agentName := a.displayName
|
||||
|
||||
// Determine if agent is built-in or custom using the agent's builtIn field
|
||||
var displayText string
|
||||
if a.agent.BuiltIn {
|
||||
displayText = "(built-in)"
|
||||
} else {
|
||||
if a.description != "" {
|
||||
displayText = a.description
|
||||
} else {
|
||||
displayText = "(user)"
|
||||
}
|
||||
}
|
||||
|
||||
separator := " - "
|
||||
|
||||
// Calculate how much space we have for the description (visual improvement)
|
||||
nameAndSeparatorLength := len(agentName) + len(separator)
|
||||
descriptionMaxLength := availableWidth - nameAndSeparatorLength
|
||||
|
||||
// Cap description length to the maximum allowed
|
||||
if descriptionMaxLength > maxDescriptionLength {
|
||||
descriptionMaxLength = maxDescriptionLength
|
||||
}
|
||||
|
||||
// Truncate description if it's too long (visual improvement)
|
||||
if len(displayText) > descriptionMaxLength && descriptionMaxLength > 3 {
|
||||
displayText = displayText[:descriptionMaxLength-3] + "..."
|
||||
}
|
||||
|
||||
namePart := itemStyle.Render(agentName)
|
||||
descPart := descStyle.Render(separator + displayText)
|
||||
combinedText := namePart + descPart
|
||||
|
||||
return baseStyle.
|
||||
Background(t.BackgroundPanel()).
|
||||
PaddingLeft(1).
|
||||
Width(width).
|
||||
Render(combinedText)
|
||||
}
|
||||
|
||||
func (a agentSelectItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type agentKeyMap struct {
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
}
|
||||
|
||||
var agentKeys = agentKeyMap{
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select agent"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "close"),
|
||||
),
|
||||
}
|
||||
|
||||
func (a *agentDialog) Init() tea.Cmd {
|
||||
a.setupAllAgents()
|
||||
return a.searchDialog.Init()
|
||||
}
|
||||
|
||||
func (a *agentDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
a.width = msg.Width
|
||||
a.height = msg.Height
|
||||
a.searchDialog.SetWidth(a.dialogWidth)
|
||||
a.searchDialog.SetHeight(msg.Height)
|
||||
|
||||
case SearchSelectionMsg:
|
||||
// Handle selection from search dialog
|
||||
if item, ok := msg.Item.(agentSelectItem); ok {
|
||||
if !item.isCurrent {
|
||||
// Switch to selected agent (using their better pattern)
|
||||
return a, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(app.AgentSelectedMsg{AgentName: item.name}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return a, util.CmdHandler(modal.CloseModalMsg{})
|
||||
case SearchCancelledMsg:
|
||||
return a, util.CmdHandler(modal.CloseModalMsg{})
|
||||
|
||||
case SearchRemoveItemMsg:
|
||||
if item, ok := msg.Item.(agentSelectItem); ok {
|
||||
if a.isAgentInRecentSection(item, msg.Index) {
|
||||
a.app.State.RemoveAgentFromRecentlyUsed(item.name)
|
||||
items := a.buildDisplayList(a.searchDialog.GetQuery())
|
||||
a.searchDialog.SetItems(items)
|
||||
return a, a.app.SaveState()
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case SearchQueryChangedMsg:
|
||||
// Update the list based on search query
|
||||
items := a.buildDisplayList(msg.Query)
|
||||
a.searchDialog.SetItems(items)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
updatedDialog, cmd := a.searchDialog.Update(msg)
|
||||
a.searchDialog = updatedDialog.(*SearchDialog)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a *agentDialog) SetSize(width, height int) {
|
||||
a.width = width
|
||||
a.height = height
|
||||
}
|
||||
|
||||
func (a *agentDialog) View() string {
|
||||
return a.searchDialog.View()
|
||||
}
|
||||
|
||||
func (a *agentDialog) calculateOptimalWidth(agents []agentSelectItem) int {
|
||||
maxWidth := minAgentDialogWidth
|
||||
|
||||
for _, agent := range agents {
|
||||
// Calculate the width needed for this item: "AgentName - Description" (visual improvement)
|
||||
itemWidth := len(agent.displayName)
|
||||
|
||||
if agent.agent.BuiltIn {
|
||||
itemWidth += len("(built-in)") + 3 // " - "
|
||||
} else {
|
||||
if agent.description != "" {
|
||||
descLength := len(agent.description)
|
||||
if descLength > maxDescriptionLength {
|
||||
descLength = maxDescriptionLength
|
||||
}
|
||||
itemWidth += descLength + 3 // " - "
|
||||
} else {
|
||||
itemWidth += len("(user)") + 3 // " - "
|
||||
}
|
||||
}
|
||||
|
||||
if itemWidth > maxWidth {
|
||||
maxWidth = itemWidth
|
||||
}
|
||||
}
|
||||
|
||||
maxWidth = min(maxWidth, maxAgentDialogWidth)
|
||||
return maxWidth
|
||||
}
|
||||
|
||||
func (a *agentDialog) setupAllAgents() {
|
||||
currentAgentName := a.app.Agent().Name
|
||||
|
||||
// Build agent items from app.Agents (no API call needed) - their pattern
|
||||
a.allAgents = make([]agentSelectItem, 0, len(a.app.Agents))
|
||||
for i, agent := range a.app.Agents {
|
||||
if agent.Mode == "subagent" {
|
||||
continue // Skip subagents entirely
|
||||
}
|
||||
isCurrent := agent.Name == currentAgentName
|
||||
|
||||
// Create display name (capitalize first letter)
|
||||
displayName := strings.Title(agent.Name)
|
||||
|
||||
a.allAgents = append(a.allAgents, agentSelectItem{
|
||||
name: agent.Name,
|
||||
displayName: displayName,
|
||||
description: agent.Description, // Keep for search but don't use in display
|
||||
mode: string(agent.Mode),
|
||||
isCurrent: isCurrent,
|
||||
agentIndex: i,
|
||||
agent: agent, // Keep original for compatibility
|
||||
})
|
||||
}
|
||||
|
||||
a.sortAgents()
|
||||
|
||||
// Calculate optimal width based on all agents (visual improvement)
|
||||
a.dialogWidth = a.calculateOptimalWidth(a.allAgents)
|
||||
|
||||
// Ensure minimum width to prevent textinput issues
|
||||
a.dialogWidth = max(a.dialogWidth, minAgentDialogWidth)
|
||||
|
||||
a.searchDialog = NewSearchDialog("Search agents...", numVisibleAgents)
|
||||
a.searchDialog.SetWidth(a.dialogWidth)
|
||||
|
||||
// Build initial display list (empty query shows grouped view)
|
||||
items := a.buildDisplayList("")
|
||||
a.searchDialog.SetItems(items)
|
||||
}
|
||||
|
||||
func (a *agentDialog) sortAgents() {
|
||||
sort.Slice(a.allAgents, func(i, j int) bool {
|
||||
agentA := a.allAgents[i]
|
||||
agentB := a.allAgents[j]
|
||||
|
||||
// Current agent goes first (your preference)
|
||||
if agentA.name == a.app.Agent().Name {
|
||||
return true
|
||||
}
|
||||
if agentB.name == a.app.Agent().Name {
|
||||
return false
|
||||
}
|
||||
|
||||
// Alphabetical order for all other agents
|
||||
return agentA.name < agentB.name
|
||||
})
|
||||
}
|
||||
|
||||
// buildDisplayList creates the list items based on search query
|
||||
func (a *agentDialog) buildDisplayList(query string) []list.Item {
|
||||
if query != "" {
|
||||
// Search mode: use fuzzy matching
|
||||
return a.buildSearchResults(query)
|
||||
} else {
|
||||
// Grouped mode: show Recent agents section and alphabetical list (their pattern)
|
||||
return a.buildGroupedResults()
|
||||
}
|
||||
}
|
||||
|
||||
// buildSearchResults creates a flat list of search results using fuzzy matching
|
||||
func (a *agentDialog) buildSearchResults(query string) []list.Item {
|
||||
agentNames := []string{}
|
||||
agentMap := make(map[string]agentSelectItem)
|
||||
|
||||
for _, agent := range a.allAgents {
|
||||
// Only include non-subagents in search
|
||||
if agent.mode == "subagent" {
|
||||
continue
|
||||
}
|
||||
searchStr := agent.name
|
||||
agentNames = append(agentNames, searchStr)
|
||||
agentMap[searchStr] = agent
|
||||
}
|
||||
|
||||
matches := fuzzy.RankFindFold(query, agentNames)
|
||||
sort.Sort(matches)
|
||||
|
||||
items := []list.Item{}
|
||||
seenAgents := make(map[string]bool)
|
||||
|
||||
for _, match := range matches {
|
||||
agent := agentMap[match.Target]
|
||||
// Create a unique key to avoid duplicates
|
||||
key := agent.name
|
||||
if seenAgents[key] {
|
||||
continue
|
||||
}
|
||||
seenAgents[key] = true
|
||||
items = append(items, agent)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// buildGroupedResults creates a grouped list with Recent agents section and categorized agents
|
||||
func (a *agentDialog) buildGroupedResults() []list.Item {
|
||||
var items []list.Item
|
||||
|
||||
// Add Recent section (their pattern)
|
||||
recentAgents := a.getRecentAgents(maxRecentAgents)
|
||||
if len(recentAgents) > 0 {
|
||||
items = append(items, list.HeaderItem("Recent"))
|
||||
for _, agent := range recentAgents {
|
||||
items = append(items, agent)
|
||||
}
|
||||
}
|
||||
|
||||
// Create map of recent agent names for filtering
|
||||
recentAgentNames := make(map[string]bool)
|
||||
for _, recent := range recentAgents {
|
||||
recentAgentNames[recent.name] = true
|
||||
}
|
||||
|
||||
// Only show non-subagents (primary/user) in the main section
|
||||
mainAgents := make([]agentSelectItem, 0)
|
||||
for _, agent := range a.allAgents {
|
||||
if !recentAgentNames[agent.name] {
|
||||
mainAgents = append(mainAgents, agent)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort main agents alphabetically
|
||||
sort.Slice(mainAgents, func(i, j int) bool {
|
||||
return mainAgents[i].name < mainAgents[j].name
|
||||
})
|
||||
|
||||
// Add main agents section
|
||||
if len(mainAgents) > 0 {
|
||||
items = append(items, list.HeaderItem("Agents"))
|
||||
for _, agent := range mainAgents {
|
||||
items = append(items, agent)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func (a *agentDialog) Render(background string) string {
|
||||
return a.modal.Render(a.View(), background)
|
||||
}
|
||||
|
||||
func (a *agentDialog) Close() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRecentAgents returns the most recently used agents (their pattern)
|
||||
func (a *agentDialog) getRecentAgents(limit int) []agentSelectItem {
|
||||
var recentAgents []agentSelectItem
|
||||
|
||||
// Get recent agents from app state
|
||||
for _, usage := range a.app.State.RecentlyUsedAgents {
|
||||
if len(recentAgents) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
// Find the corresponding agent
|
||||
for _, agent := range a.allAgents {
|
||||
if agent.name == usage.AgentName {
|
||||
recentAgents = append(recentAgents, agent)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no recent agents, use the current agent
|
||||
if len(recentAgents) == 0 {
|
||||
currentAgentName := a.app.Agent().Name
|
||||
for _, agent := range a.allAgents {
|
||||
if agent.name == currentAgentName {
|
||||
recentAgents = append(recentAgents, agent)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return recentAgents
|
||||
}
|
||||
|
||||
func (a *agentDialog) isAgentInRecentSection(agent agentSelectItem, index int) bool {
|
||||
// Only check if we're in grouped mode (no search query)
|
||||
if a.searchDialog.GetQuery() != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
recentAgents := a.getRecentAgents(maxRecentAgents)
|
||||
if len(recentAgents) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Index 0 is the "Recent" header, so recent agents are at indices 1 to len(recentAgents)
|
||||
if index >= 1 && index <= len(recentAgents) {
|
||||
if index-1 < len(recentAgents) {
|
||||
recentAgent := recentAgents[index-1]
|
||||
return recentAgent.name == agent.name
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func NewAgentDialog(app *app.App) AgentDialog {
|
||||
dialog := &agentDialog{
|
||||
app: app,
|
||||
}
|
||||
|
||||
dialog.setupAllAgents()
|
||||
|
||||
dialog.modal = modal.New(
|
||||
modal.WithTitle("Select Agent"),
|
||||
modal.WithMaxWidth(dialog.dialogWidth+4),
|
||||
)
|
||||
|
||||
return dialog
|
||||
}
|
||||
@@ -99,7 +99,10 @@ func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
|
||||
baseStyle := styles.NewStyle().Background(t.BackgroundElement())
|
||||
|
||||
// Ensure stable provider order just in case
|
||||
sort.SliceStable(itemsByProvider, func(i, j int) bool { return itemsByProvider[i].idx < itemsByProvider[j].idx })
|
||||
sort.SliceStable(
|
||||
itemsByProvider,
|
||||
func(i, j int) bool { return itemsByProvider[i].idx < itemsByProvider[j].idx },
|
||||
)
|
||||
|
||||
final := make([]completions.CompletionSuggestion, 0)
|
||||
for _, entry := range itemsByProvider {
|
||||
@@ -167,6 +170,16 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
width := lipgloss.Width(value)
|
||||
triggerWidth := lipgloss.Width(c.trigger)
|
||||
|
||||
if msg.String() == "space" || msg.String() == " " {
|
||||
item, i := c.list.GetSelectedItem()
|
||||
if i > -1 {
|
||||
return c, c.complete(item)
|
||||
}
|
||||
// If no exact match, close the dialog
|
||||
return c, c.close()
|
||||
}
|
||||
|
||||
// Only close on backspace when there are no characters left, unless we're back to just the trigger
|
||||
if (msg.String() != "backspace" && msg.String() != "ctrl+h") || (width <= triggerWidth && value != c.trigger) {
|
||||
return c, c.close()
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
findDialogWidth = 76
|
||||
)
|
||||
|
||||
type FindSelectedMsg struct {
|
||||
FilePath string
|
||||
}
|
||||
|
||||
type FindDialogCloseMsg struct{}
|
||||
|
||||
type findInitialSuggestionsMsg struct {
|
||||
suggestions []completions.CompletionSuggestion
|
||||
}
|
||||
|
||||
type FindDialog interface {
|
||||
layout.Modal
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
SetWidth(width int)
|
||||
SetHeight(height int)
|
||||
IsEmpty() bool
|
||||
}
|
||||
|
||||
// findItem is a custom list item for file suggestions
|
||||
type findItem struct {
|
||||
suggestion completions.CompletionSuggestion
|
||||
}
|
||||
|
||||
func (f findItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundPanel()).
|
||||
Foreground(t.TextMuted())
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.Foreground(t.Primary())
|
||||
}
|
||||
|
||||
return itemStyle.PaddingLeft(1).Render(f.suggestion.Display(itemStyle))
|
||||
}
|
||||
|
||||
func (f findItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type findDialogComponent struct {
|
||||
completionProvider completions.CompletionProvider
|
||||
allSuggestions []completions.CompletionSuggestion
|
||||
width, height int
|
||||
modal *modal.Modal
|
||||
searchDialog *SearchDialog
|
||||
dialogWidth int
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
f.loadInitialSuggestions(),
|
||||
f.searchDialog.Init(),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) loadInitialSuggestions() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := f.completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error("Failed to get initial completion items", "error", err)
|
||||
return findInitialSuggestionsMsg{suggestions: []completions.CompletionSuggestion{}}
|
||||
}
|
||||
return findInitialSuggestionsMsg{suggestions: items}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case findInitialSuggestionsMsg:
|
||||
// Handle initial suggestions setup
|
||||
f.allSuggestions = msg.suggestions
|
||||
|
||||
// Calculate dialog width
|
||||
f.dialogWidth = f.calculateDialogWidth()
|
||||
|
||||
// Initialize search dialog with calculated width
|
||||
f.searchDialog = NewSearchDialog("Search files...", 10)
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
|
||||
// Convert to list items
|
||||
items := make([]list.Item, len(f.allSuggestions))
|
||||
for i, suggestion := range f.allSuggestions {
|
||||
items[i] = findItem{suggestion: suggestion}
|
||||
}
|
||||
f.searchDialog.SetItems(items)
|
||||
|
||||
// Update modal with calculated width
|
||||
f.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(f.dialogWidth+4),
|
||||
)
|
||||
|
||||
return f, f.searchDialog.Init()
|
||||
|
||||
case []completions.CompletionSuggestion:
|
||||
// Store suggestions and convert to findItem for the search dialog
|
||||
f.allSuggestions = msg
|
||||
items := make([]list.Item, len(msg))
|
||||
for i, suggestion := range msg {
|
||||
items[i] = findItem{suggestion: suggestion}
|
||||
}
|
||||
f.searchDialog.SetItems(items)
|
||||
return f, nil
|
||||
|
||||
case SearchSelectionMsg:
|
||||
// Handle selection from search dialog - now we can directly access the suggestion
|
||||
if item, ok := msg.Item.(findItem); ok {
|
||||
return f, f.selectFile(item.suggestion)
|
||||
}
|
||||
return f, nil
|
||||
|
||||
case SearchCancelledMsg:
|
||||
return f, f.Close()
|
||||
|
||||
case SearchQueryChangedMsg:
|
||||
// Update completion items based on search query
|
||||
return f, func() tea.Msg {
|
||||
items, err := f.completionProvider.GetChildEntries(msg.Query)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
return []completions.CompletionSuggestion{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
f.width = msg.Width
|
||||
f.height = msg.Height
|
||||
// Recalculate width based on new viewport size
|
||||
oldWidth := f.dialogWidth
|
||||
f.dialogWidth = f.calculateDialogWidth()
|
||||
if oldWidth != f.dialogWidth {
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
// Update modal max width too
|
||||
f.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(f.dialogWidth+4),
|
||||
)
|
||||
}
|
||||
f.searchDialog.SetHeight(msg.Height)
|
||||
}
|
||||
|
||||
// Forward all other messages to the search dialog
|
||||
updatedDialog, cmd := f.searchDialog.Update(msg)
|
||||
f.searchDialog = updatedDialog.(*SearchDialog)
|
||||
return f, cmd
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) View() string {
|
||||
return f.searchDialog.View()
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) calculateDialogWidth() int {
|
||||
// Use fixed width unless viewport is smaller
|
||||
if f.width > 0 && f.width < findDialogWidth+10 {
|
||||
return f.width - 10
|
||||
}
|
||||
return findDialogWidth
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) SetWidth(width int) {
|
||||
f.width = width
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) SetHeight(height int) {
|
||||
f.height = height
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) IsEmpty() bool {
|
||||
return f.searchDialog.GetQuery() == ""
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) selectFile(item completions.CompletionSuggestion) tea.Cmd {
|
||||
return tea.Sequence(
|
||||
f.Close(),
|
||||
util.CmdHandler(FindSelectedMsg{
|
||||
FilePath: item.Value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Render(background string) string {
|
||||
return f.modal.Render(f.View(), background)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Close() tea.Cmd {
|
||||
f.searchDialog.SetQuery("")
|
||||
f.searchDialog.Blur()
|
||||
return util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
|
||||
func NewFindDialog(completionProvider completions.CompletionProvider) FindDialog {
|
||||
component := &findDialogComponent{
|
||||
completionProvider: completionProvider,
|
||||
dialogWidth: findDialogWidth,
|
||||
allSuggestions: []completions.CompletionSuggestion{},
|
||||
}
|
||||
|
||||
// Create search dialog and modal with fixed width
|
||||
component.searchDialog = NewSearchDialog("Search files...", 10)
|
||||
component.searchDialog.SetWidth(findDialogWidth)
|
||||
|
||||
component.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(findDialogWidth+4),
|
||||
)
|
||||
|
||||
return component
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
// InitDialogCmp is a component that asks the user if they want to initialize the project.
|
||||
type InitDialogCmp struct {
|
||||
width, height int
|
||||
selected int
|
||||
keys initDialogKeyMap
|
||||
}
|
||||
|
||||
// NewInitDialogCmp creates a new InitDialogCmp.
|
||||
func NewInitDialogCmp() InitDialogCmp {
|
||||
return InitDialogCmp{
|
||||
selected: 0,
|
||||
keys: initDialogKeyMap{},
|
||||
}
|
||||
}
|
||||
|
||||
type initDialogKeyMap struct {
|
||||
Tab key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
Y key.Binding
|
||||
N key.Binding
|
||||
}
|
||||
|
||||
// ShortHelp implements key.Map.
|
||||
func (k initDialogKeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
key.NewBinding(
|
||||
key.WithKeys("tab", "left", "right"),
|
||||
key.WithHelp("tab/←/→", "toggle selection"),
|
||||
),
|
||||
key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "confirm"),
|
||||
),
|
||||
key.NewBinding(
|
||||
key.WithKeys("esc", "q"),
|
||||
key.WithHelp("esc/q", "cancel"),
|
||||
),
|
||||
key.NewBinding(
|
||||
key.WithKeys("y", "n"),
|
||||
key.WithHelp("y/n", "yes/no"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// FullHelp implements key.Map.
|
||||
func (k initDialogKeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{k.ShortHelp()}
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (m InitDialogCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update implements tea.Model.
|
||||
func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
|
||||
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
|
||||
m.selected = (m.selected + 1) % 2
|
||||
return m, nil
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
|
||||
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
|
||||
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View implements tea.Model.
|
||||
func (m InitDialogCmp) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||
|
||||
// Calculate width needed for content
|
||||
maxWidth := 60 // Width for explanation text
|
||||
|
||||
title := baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
Bold(true).
|
||||
Width(maxWidth).
|
||||
Padding(0, 1).
|
||||
Render("Initialize Project")
|
||||
|
||||
explanation := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Width(maxWidth).
|
||||
Padding(0, 1).
|
||||
Render("Initialization generates a new AGENTS.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
|
||||
|
||||
question := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Width(maxWidth).
|
||||
Padding(1, 1).
|
||||
Render("Would you like to initialize this project?")
|
||||
|
||||
maxWidth = min(maxWidth, m.width-10)
|
||||
yesStyle := baseStyle
|
||||
noStyle := baseStyle
|
||||
|
||||
if m.selected == 0 {
|
||||
yesStyle = yesStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.Background()).
|
||||
Bold(true)
|
||||
noStyle = noStyle.
|
||||
Background(t.Background()).
|
||||
Foreground(t.Primary())
|
||||
} else {
|
||||
noStyle = noStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.Background()).
|
||||
Bold(true)
|
||||
yesStyle = yesStyle.
|
||||
Background(t.Background()).
|
||||
Foreground(t.Primary())
|
||||
}
|
||||
|
||||
yes := yesStyle.Padding(0, 3).Render("Yes")
|
||||
no := noStyle.Padding(0, 3).Render("No")
|
||||
|
||||
buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render(" "), no)
|
||||
buttons = baseStyle.
|
||||
Width(maxWidth).
|
||||
Padding(1, 0).
|
||||
Render(buttons)
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
baseStyle.Width(maxWidth).Render(""),
|
||||
explanation,
|
||||
question,
|
||||
buttons,
|
||||
baseStyle.Width(maxWidth).Render(""),
|
||||
)
|
||||
|
||||
return baseStyle.Padding(1, 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.TextMuted()).
|
||||
Width(lipgloss.Width(content) + 4).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
// SetSize sets the size of the component.
|
||||
func (m *InitDialogCmp) SetSize(width, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
}
|
||||
|
||||
// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
|
||||
type CloseInitDialogMsg struct {
|
||||
Initialize bool
|
||||
}
|
||||
|
||||
// ShowInitDialogMsg is a message that is sent to show the init dialog.
|
||||
type ShowInitDialogMsg struct {
|
||||
Show bool
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
// NavigationDialog interface for the session navigation dialog
|
||||
type NavigationDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
// ScrollToMessageMsg is sent when a message should be scrolled to
|
||||
type ScrollToMessageMsg struct {
|
||||
MessageID string
|
||||
}
|
||||
|
||||
// RestoreToMessageMsg is sent when conversation should be restored to a specific message
|
||||
type RestoreToMessageMsg struct {
|
||||
MessageID string
|
||||
Index int
|
||||
}
|
||||
|
||||
// navigationItem represents a user message in the navigation list
|
||||
type navigationItem struct {
|
||||
messageID string
|
||||
content string
|
||||
timestamp time.Time
|
||||
index int // Index in the full message list
|
||||
toolCount int // Number of tools used in this message
|
||||
}
|
||||
|
||||
func (n navigationItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
isFirstInViewport bool,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render
|
||||
textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render
|
||||
|
||||
// Format timestamp - only apply color when not selected
|
||||
var timeStr string
|
||||
var timeVisualLen int
|
||||
if selected {
|
||||
timeStr = n.timestamp.Format("15:04") + " "
|
||||
timeVisualLen = lipgloss.Width(timeStr)
|
||||
} else {
|
||||
timeStr = infoStyle(n.timestamp.Format("15:04") + " ")
|
||||
timeVisualLen = lipgloss.Width(timeStr)
|
||||
}
|
||||
|
||||
// Tool count display (fixed width for alignment) - only apply color when not selected
|
||||
toolInfo := ""
|
||||
toolInfoVisualLen := 0
|
||||
if n.toolCount > 0 {
|
||||
toolInfoText := fmt.Sprintf("(%d tools)", n.toolCount)
|
||||
if selected {
|
||||
toolInfo = toolInfoText
|
||||
} else {
|
||||
toolInfo = infoStyle(toolInfoText)
|
||||
}
|
||||
toolInfoVisualLen = lipgloss.Width(toolInfo)
|
||||
}
|
||||
|
||||
// Calculate available space for content
|
||||
// Reserve space for: timestamp + space + toolInfo + padding + some buffer
|
||||
reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
|
||||
contentWidth := max(width-reservedSpace, 8)
|
||||
|
||||
truncatedContent := truncate.StringWithTail(
|
||||
strings.Split(n.content, "\n")[0],
|
||||
uint(contentWidth),
|
||||
"...",
|
||||
)
|
||||
|
||||
// Apply normal text color to content for non-selected items
|
||||
var styledContent string
|
||||
if selected {
|
||||
styledContent = truncatedContent
|
||||
} else {
|
||||
styledContent = textStyle(truncatedContent)
|
||||
}
|
||||
|
||||
// Create the line with proper spacing - content left-aligned, tools right-aligned
|
||||
var text string
|
||||
text = timeStr + styledContent
|
||||
if toolInfo != "" {
|
||||
bgColor := t.BackgroundPanel()
|
||||
if selected {
|
||||
bgColor = t.Primary()
|
||||
}
|
||||
text = layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &bgColor,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Align: layout.AlignStretch,
|
||||
Width: width - 2,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: text,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: toolInfo,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var itemStyle styles.Style
|
||||
if selected {
|
||||
itemStyle = baseStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingLeft(1)
|
||||
} else {
|
||||
itemStyle = baseStyle.PaddingLeft(1)
|
||||
}
|
||||
|
||||
return itemStyle.Render(text)
|
||||
}
|
||||
|
||||
func (n navigationItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type navigationDialog struct {
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
list list.List[navigationItem]
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func (n *navigationDialog) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *navigationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
n.width = msg.Width
|
||||
n.height = msg.Height
|
||||
n.list.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "up", "down":
|
||||
// Handle navigation and immediately scroll to selected message
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := n.list.Update(msg)
|
||||
n.list = listModel.(list.List[navigationItem])
|
||||
|
||||
// Get the newly selected item and scroll to it immediately
|
||||
if item, idx := n.list.GetSelectedItem(); idx >= 0 {
|
||||
return n, tea.Sequence(
|
||||
cmd,
|
||||
util.CmdHandler(ScrollToMessageMsg{MessageID: item.messageID}),
|
||||
)
|
||||
}
|
||||
return n, cmd
|
||||
case "r":
|
||||
// Restore conversation to selected message
|
||||
if item, idx := n.list.GetSelectedItem(); idx >= 0 {
|
||||
return n, tea.Sequence(
|
||||
util.CmdHandler(RestoreToMessageMsg{MessageID: item.messageID, Index: item.index}),
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
)
|
||||
}
|
||||
case "enter":
|
||||
// Keep Enter functionality for closing the modal
|
||||
if _, idx := n.list.GetSelectedItem(); idx >= 0 {
|
||||
return n, util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := n.list.Update(msg)
|
||||
n.list = listModel.(list.List[navigationItem])
|
||||
return n, cmd
|
||||
}
|
||||
|
||||
func (n *navigationDialog) Render(background string) string {
|
||||
listView := n.list.View()
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
keyStyle := styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundPanel()).
|
||||
Bold(true).
|
||||
Render
|
||||
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
|
||||
|
||||
helpText := keyStyle(
|
||||
"↑/↓",
|
||||
) + mutedStyle(
|
||||
" jump ",
|
||||
) + keyStyle(
|
||||
"r",
|
||||
) + mutedStyle(
|
||||
" restore",
|
||||
)
|
||||
|
||||
bgColor := t.BackgroundPanel()
|
||||
helpView := styles.NewStyle().
|
||||
Background(bgColor).
|
||||
Width(layout.Current.Container.Width - 14).
|
||||
PaddingLeft(1).
|
||||
PaddingTop(1).
|
||||
Render(helpText)
|
||||
|
||||
content := strings.Join([]string{listView, helpView}, "\n")
|
||||
|
||||
return n.modal.Render(content, background)
|
||||
}
|
||||
|
||||
func (n *navigationDialog) Close() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractMessagePreview extracts a preview from message parts
|
||||
func extractMessagePreview(parts []opencode.PartUnion) string {
|
||||
for _, part := range parts {
|
||||
switch casted := part.(type) {
|
||||
case opencode.TextPart:
|
||||
text := strings.TrimSpace(casted.Text)
|
||||
if text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return "No text content"
|
||||
}
|
||||
|
||||
// countToolsInResponse counts tools in the assistant's response to a user message
|
||||
func countToolsInResponse(messages []app.Message, userMessageIndex int) int {
|
||||
count := 0
|
||||
// Look at subsequent messages to find the assistant's response
|
||||
for i := userMessageIndex + 1; i < len(messages); i++ {
|
||||
message := messages[i]
|
||||
// If we hit another user message, stop looking
|
||||
if _, isUser := message.Info.(opencode.UserMessage); isUser {
|
||||
break
|
||||
}
|
||||
// Count tools in this assistant message
|
||||
for _, part := range message.Parts {
|
||||
switch part.(type) {
|
||||
case opencode.ToolPart:
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// NewNavigationDialog creates a new session navigation dialog
|
||||
func NewNavigationDialog(app *app.App) NavigationDialog {
|
||||
var items []navigationItem
|
||||
|
||||
// Filter to only user messages and extract relevant info
|
||||
for i, message := range app.Messages {
|
||||
if userMsg, ok := message.Info.(opencode.UserMessage); ok {
|
||||
preview := extractMessagePreview(message.Parts)
|
||||
toolCount := countToolsInResponse(app.Messages, i)
|
||||
|
||||
items = append(items, navigationItem{
|
||||
messageID: userMsg.ID,
|
||||
content: preview,
|
||||
timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
|
||||
index: i,
|
||||
toolCount: toolCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
listComponent := list.NewListComponent(
|
||||
list.WithItems(items),
|
||||
list.WithMaxVisibleHeight[navigationItem](12),
|
||||
list.WithFallbackMessage[navigationItem]("No user messages in this session"),
|
||||
list.WithAlphaNumericKeys[navigationItem](true),
|
||||
list.WithRenderFunc(
|
||||
func(item navigationItem, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, false, baseStyle)
|
||||
},
|
||||
),
|
||||
list.WithSelectableFunc(func(item navigationItem) bool {
|
||||
return true
|
||||
}),
|
||||
)
|
||||
listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
|
||||
return &navigationDialog{
|
||||
list: listComponent,
|
||||
app: app,
|
||||
modal: modal.New(
|
||||
modal.WithTitle("Jump to Message"),
|
||||
modal.WithMaxWidth(layout.Current.Container.Width-8),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"slices"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
@@ -110,6 +111,9 @@ type sessionDialog struct {
|
||||
list list.List[sessionItem]
|
||||
app *app.App
|
||||
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
|
||||
renameMode bool
|
||||
renameInput textinput.Model
|
||||
renameIndex int // index of session being renamed
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Init() tea.Cmd {
|
||||
@@ -123,70 +127,136 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.height = msg.Height
|
||||
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if s.deleteConfirmation >= 0 {
|
||||
s.deleteConfirmation = -1
|
||||
if s.renameMode {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) && idx == s.renameIndex {
|
||||
newTitle := s.renameInput.Value()
|
||||
if strings.TrimSpace(newTitle) != "" {
|
||||
sessionToUpdate := s.sessions[idx]
|
||||
return s, tea.Sequence(
|
||||
func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
err := s.app.UpdateSession(ctx, sessionToUpdate.ID, newTitle)
|
||||
if err != nil {
|
||||
return toast.NewErrorToast("Failed to rename session: " + err.Error())()
|
||||
}
|
||||
s.sessions[idx].Title = newTitle
|
||||
s.renameMode = false
|
||||
s.modal.SetTitle("Switch Session")
|
||||
s.updateListItems()
|
||||
return toast.NewSuccessToast("Session renamed successfully")()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
s.renameMode = false
|
||||
s.modal.SetTitle("Switch Session")
|
||||
s.updateListItems()
|
||||
return s, nil
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
s.renameInput, cmd = s.renameInput.Update(msg)
|
||||
return s, cmd
|
||||
}
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||
selectedSession := s.sessions[idx]
|
||||
} else {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if s.deleteConfirmation >= 0 {
|
||||
s.deleteConfirmation = -1
|
||||
s.updateListItems()
|
||||
return s, nil
|
||||
}
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||
selectedSession := s.sessions[idx]
|
||||
return s, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
|
||||
)
|
||||
}
|
||||
case "n":
|
||||
return s, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
|
||||
util.CmdHandler(app.SessionClearedMsg{}),
|
||||
)
|
||||
}
|
||||
case "n":
|
||||
return s, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(app.SessionClearedMsg{}),
|
||||
)
|
||||
case "x", "delete", "backspace":
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||
if s.deleteConfirmation == idx {
|
||||
// Second press - actually delete the session
|
||||
sessionToDelete := s.sessions[idx]
|
||||
return s, tea.Sequence(
|
||||
func() tea.Msg {
|
||||
s.sessions = slices.Delete(s.sessions, idx, idx+1)
|
||||
s.deleteConfirmation = -1
|
||||
s.updateListItems()
|
||||
return nil
|
||||
},
|
||||
s.deleteSession(sessionToDelete.ID),
|
||||
)
|
||||
} else {
|
||||
// First press - enter delete confirmation mode
|
||||
s.deleteConfirmation = idx
|
||||
case "r":
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||
s.renameMode = true
|
||||
s.renameIndex = idx
|
||||
s.setupRenameInput(s.sessions[idx].Title)
|
||||
s.modal.SetTitle("Rename Session")
|
||||
s.updateListItems()
|
||||
return s, textinput.Blink
|
||||
}
|
||||
case "x", "delete", "backspace":
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||
if s.deleteConfirmation == idx {
|
||||
// Second press - actually delete the session
|
||||
sessionToDelete := s.sessions[idx]
|
||||
return s, tea.Sequence(
|
||||
func() tea.Msg {
|
||||
s.sessions = slices.Delete(s.sessions, idx, idx+1)
|
||||
s.deleteConfirmation = -1
|
||||
s.updateListItems()
|
||||
return nil
|
||||
},
|
||||
s.deleteSession(sessionToDelete.ID),
|
||||
)
|
||||
} else {
|
||||
// First press - enter delete confirmation mode
|
||||
s.deleteConfirmation = idx
|
||||
s.updateListItems()
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
case "esc":
|
||||
if s.deleteConfirmation >= 0 {
|
||||
s.deleteConfirmation = -1
|
||||
s.updateListItems()
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
case "esc":
|
||||
if s.deleteConfirmation >= 0 {
|
||||
s.deleteConfirmation = -1
|
||||
s.updateListItems()
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[sessionItem])
|
||||
return s, cmd
|
||||
if !s.renameMode {
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[sessionItem])
|
||||
return s, cmd
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Render(background string) string {
|
||||
if s.renameMode {
|
||||
// Show rename input instead of list
|
||||
t := theme.CurrentTheme()
|
||||
renameView := s.renameInput.View()
|
||||
|
||||
mutedStyle := styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundPanel()).
|
||||
Render
|
||||
helpText := mutedStyle("Enter to confirm, Esc to cancel")
|
||||
helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
|
||||
|
||||
content := strings.Join([]string{renameView, helpText}, "\n")
|
||||
return s.modal.Render(content, background)
|
||||
}
|
||||
|
||||
listView := s.list.View()
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
|
||||
keyStyle := styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundPanel()).
|
||||
Bold(true).
|
||||
Render
|
||||
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
|
||||
|
||||
leftHelp := keyStyle("n") + mutedStyle(" new session")
|
||||
rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
|
||||
leftHelp := keyStyle("n") + mutedStyle(" new ") + keyStyle("r") + mutedStyle(" rename")
|
||||
rightHelp := keyStyle("x/del") + mutedStyle(" delete")
|
||||
|
||||
bgColor := t.BackgroundPanel()
|
||||
helpText := layout.Render(layout.FlexOptions{
|
||||
@@ -203,6 +273,39 @@ func (s *sessionDialog) Render(background string) string {
|
||||
return s.modal.Render(content, background)
|
||||
}
|
||||
|
||||
func (s *sessionDialog) setupRenameInput(currentTitle string) {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundPanel()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
s.renameInput = textinput.New()
|
||||
s.renameInput.SetValue(currentTitle)
|
||||
s.renameInput.Focus()
|
||||
s.renameInput.CharLimit = 100
|
||||
s.renameInput.SetWidth(layout.Current.Container.Width - 20)
|
||||
|
||||
s.renameInput.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
s.renameInput.Styles.Blurred.Text = styles.NewStyle().
|
||||
Foreground(textColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
s.renameInput.Styles.Focused.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
s.renameInput.Styles.Focused.Text = styles.NewStyle().
|
||||
Foreground(textColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
s.renameInput.Styles.Focused.Prompt = styles.NewStyle().
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
}
|
||||
|
||||
func (s *sessionDialog) updateListItems() {
|
||||
_, currentIdx := s.list.GetSelectedItem()
|
||||
|
||||
@@ -229,7 +332,22 @@ func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// ReopenSessionModalMsg is emitted when the session modal should be reopened
|
||||
type ReopenSessionModalMsg struct{}
|
||||
|
||||
func (s *sessionDialog) Close() tea.Cmd {
|
||||
if s.renameMode {
|
||||
// If in rename mode, exit rename mode and return a command to reopen the modal
|
||||
s.renameMode = false
|
||||
s.modal.SetTitle("Switch Session")
|
||||
s.updateListItems()
|
||||
|
||||
// Return a command that will reopen the session modal
|
||||
return func() tea.Msg {
|
||||
return ReopenSessionModalMsg{}
|
||||
}
|
||||
}
|
||||
// Normal close behavior
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -272,6 +390,8 @@ func NewSessionDialog(app *app.App) SessionDialog {
|
||||
list: listComponent,
|
||||
app: app,
|
||||
deleteConfirmation: -1,
|
||||
renameMode: false,
|
||||
renameIndex: -1,
|
||||
modal: modal.New(
|
||||
modal.WithTitle("Switch Session"),
|
||||
modal.WithMaxWidth(layout.Current.Container.Width-8),
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
package fileviewer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/diff"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/internal/viewport"
|
||||
)
|
||||
|
||||
type DiffStyle int
|
||||
|
||||
const (
|
||||
DiffStyleSplit DiffStyle = iota
|
||||
DiffStyleUnified
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
viewport viewport.Model
|
||||
filename *string
|
||||
content *string
|
||||
isDiff *bool
|
||||
diffStyle DiffStyle
|
||||
}
|
||||
|
||||
type fileRenderedMsg struct {
|
||||
content string
|
||||
}
|
||||
|
||||
func New(app *app.App) Model {
|
||||
vp := viewport.New()
|
||||
m := Model{
|
||||
app: app,
|
||||
viewport: vp,
|
||||
diffStyle: DiffStyleUnified,
|
||||
}
|
||||
if app.State.SplitDiff {
|
||||
m.diffStyle = DiffStyleSplit
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return m.viewport.Init()
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case fileRenderedMsg:
|
||||
m.viewport.SetContent(msg.content)
|
||||
return m, util.CmdHandler(app.FileRenderedMsg{
|
||||
FilePath: *m.filename,
|
||||
})
|
||||
case dialog.ThemeSelectedMsg:
|
||||
return m, m.render()
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
vp, cmd := m.viewport.Update(msg)
|
||||
m.viewport = vp
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if !m.HasFile() {
|
||||
return ""
|
||||
}
|
||||
|
||||
header := *m.filename
|
||||
header = styles.NewStyle().
|
||||
Padding(1, 2).
|
||||
Width(m.width).
|
||||
Background(theme.CurrentTheme().BackgroundElement()).
|
||||
Foreground(theme.CurrentTheme().Text()).
|
||||
Render(header)
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
close := m.app.Key(commands.FileCloseCommand)
|
||||
diffToggle := m.app.Key(commands.FileDiffToggleCommand)
|
||||
if m.isDiff == nil || *m.isDiff == false {
|
||||
diffToggle = ""
|
||||
}
|
||||
layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand)
|
||||
|
||||
background := t.Background()
|
||||
footer := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &background,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifyCenter,
|
||||
Align: layout.AlignStretch,
|
||||
Width: m.width - 2,
|
||||
Gap: 5,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: close,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: layoutToggle,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: diffToggle,
|
||||
},
|
||||
)
|
||||
footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer)
|
||||
|
||||
return header + "\n" + m.viewport.View() + "\n" + footer
|
||||
}
|
||||
|
||||
func (m *Model) Clear() (Model, tea.Cmd) {
|
||||
m.filename = nil
|
||||
m.content = nil
|
||||
m.isDiff = nil
|
||||
return *m, m.render()
|
||||
}
|
||||
|
||||
func (m *Model) ToggleDiff() (Model, tea.Cmd) {
|
||||
switch m.diffStyle {
|
||||
case DiffStyleSplit:
|
||||
m.diffStyle = DiffStyleUnified
|
||||
default:
|
||||
m.diffStyle = DiffStyleSplit
|
||||
}
|
||||
return *m, m.render()
|
||||
}
|
||||
|
||||
func (m *Model) DiffStyle() DiffStyle {
|
||||
return m.diffStyle
|
||||
}
|
||||
|
||||
func (m Model) HasFile() bool {
|
||||
return m.filename != nil && m.content != nil
|
||||
}
|
||||
|
||||
func (m Model) Filename() string {
|
||||
if m.filename == nil {
|
||||
return ""
|
||||
}
|
||||
return *m.filename
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(width, height int) (Model, tea.Cmd) {
|
||||
if m.width != width || m.height != height {
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.viewport.SetWidth(width)
|
||||
m.viewport.SetHeight(height - 4)
|
||||
return *m, m.render()
|
||||
}
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) {
|
||||
m.filename = &filename
|
||||
m.content = &content
|
||||
m.isDiff = &isDiff
|
||||
return *m, m.render()
|
||||
}
|
||||
|
||||
func (m *Model) render() tea.Cmd {
|
||||
if m.filename == nil || m.content == nil {
|
||||
m.viewport.SetContent("")
|
||||
return nil
|
||||
}
|
||||
|
||||
return func() tea.Msg {
|
||||
t := theme.CurrentTheme()
|
||||
var rendered string
|
||||
|
||||
if m.isDiff != nil && *m.isDiff {
|
||||
diffResult := ""
|
||||
var err error
|
||||
if m.diffStyle == DiffStyleSplit {
|
||||
diffResult, err = diff.FormatDiff(
|
||||
*m.filename,
|
||||
*m.content,
|
||||
diff.WithWidth(m.width),
|
||||
)
|
||||
} else if m.diffStyle == DiffStyleUnified {
|
||||
diffResult, err = diff.FormatUnifiedDiff(
|
||||
*m.filename,
|
||||
*m.content,
|
||||
diff.WithWidth(m.width),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
rendered = styles.NewStyle().
|
||||
Foreground(t.Error()).
|
||||
Render(fmt.Sprintf("Error rendering diff: %v", err))
|
||||
} else {
|
||||
rendered = strings.TrimRight(diffResult, "\n")
|
||||
}
|
||||
} else {
|
||||
rendered = util.RenderFile(
|
||||
*m.filename,
|
||||
*m.content,
|
||||
m.width,
|
||||
)
|
||||
}
|
||||
|
||||
rendered = styles.NewStyle().
|
||||
Width(m.width).
|
||||
Background(t.BackgroundPanel()).
|
||||
Render(rendered)
|
||||
|
||||
return fileRenderedMsg{
|
||||
content: rendered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) ScrollTo(line int) {
|
||||
m.viewport.SetYOffset(line)
|
||||
}
|
||||
|
||||
func (m *Model) ScrollToBottom() {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
|
||||
func (m *Model) ScrollToTop() {
|
||||
m.viewport.GotoTop()
|
||||
}
|
||||
|
||||
func (m *Model) PageUp() (Model, tea.Cmd) {
|
||||
m.viewport.ViewUp()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) PageDown() (Model, tea.Cmd) {
|
||||
m.viewport.ViewDown()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) HalfPageUp() (Model, tea.Cmd) {
|
||||
m.viewport.HalfViewUp()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) HalfPageDown() (Model, tea.Cmd) {
|
||||
m.viewport.HalfViewDown()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m Model) AtTop() bool {
|
||||
return m.viewport.AtTop()
|
||||
}
|
||||
|
||||
func (m Model) AtBottom() bool {
|
||||
return m.viewport.AtBottom()
|
||||
}
|
||||
|
||||
func (m Model) ScrollPercent() float64 {
|
||||
return m.viewport.ScrollPercent()
|
||||
}
|
||||
|
||||
func (m Model) TotalLineCount() int {
|
||||
return m.viewport.TotalLineCount()
|
||||
}
|
||||
|
||||
func (m Model) VisibleLineCount() int {
|
||||
return m.viewport.VisibleLineCount()
|
||||
}
|
||||
@@ -173,7 +173,13 @@ func (c *listComponent[T]) moveUp() {
|
||||
}
|
||||
}
|
||||
|
||||
// If no selectable item found above, stay at current position
|
||||
// If no selectable item found above, wrap to the bottom
|
||||
for i := len(c.items) - 1; i > c.selectedIdx; i-- {
|
||||
if c.isSelectable(c.items[i]) {
|
||||
c.selectedIdx = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// moveDown moves the selection down, skipping non-selectable items
|
||||
@@ -183,20 +189,19 @@ func (c *listComponent[T]) moveDown() {
|
||||
}
|
||||
|
||||
originalIdx := c.selectedIdx
|
||||
for {
|
||||
if c.selectedIdx < len(c.items)-1 {
|
||||
c.selectedIdx++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if c.isSelectable(c.items[c.selectedIdx]) {
|
||||
// First try moving down from current position
|
||||
for i := c.selectedIdx + 1; i < len(c.items); i++ {
|
||||
if c.isSelectable(c.items[i]) {
|
||||
c.selectedIdx = i
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent infinite loop
|
||||
if c.selectedIdx == originalIdx {
|
||||
break
|
||||
// If no selectable item found below, wrap to the top
|
||||
for i := 0; i < originalIdx; i++ {
|
||||
if c.isSelectable(c.items[i]) {
|
||||
c.selectedIdx = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user