Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2090b26c1 | ||
|
|
fca0166488 | ||
|
|
686dd330a0 | ||
|
|
193013a44d | ||
|
|
824ab4cecc | ||
|
|
7a42ecdddb | ||
|
|
dd011e879c | ||
|
|
04cf2b8268 | ||
|
|
1a1437e78b | ||
|
|
c76a81434d | ||
|
|
49cc872c44 | ||
|
|
f8dad0ae17 | ||
|
|
40a939f5f0 | ||
|
|
7729c6d895 | ||
|
|
7fb2081dce | ||
|
|
3d9f6c0fe0 | ||
|
|
190d2957eb | ||
|
|
b64d0768ba | ||
|
|
1867f1acaa | ||
|
|
00c079868a | ||
|
|
8b99648790 | ||
|
|
cb8b74d3f1 | ||
|
|
7e35d0c610 | ||
|
|
5364ab74a2 | ||
|
|
91f8dd5f57 | ||
|
|
850402f093 | ||
|
|
af72010e9f | ||
|
|
50883cc1e9 | ||
|
|
f2858a42ba | ||
|
|
3c21735b35 | ||
|
|
56dda4c98c | ||
|
|
6b8902e8b9 | ||
|
|
08a2d002b8 | ||
|
|
02a9495063 | ||
|
|
0fcba68d4c | ||
|
|
338393c016 | ||
|
|
8ebdbe0ea2 | ||
|
|
38f7071da9 | ||
|
|
d2d5f3c04b | ||
|
|
885d71636f | ||
|
|
d07f09925f | ||
|
|
c7b35342dd | ||
|
|
308e500832 | ||
|
|
4b878f6aeb | ||
|
|
1893473148 | ||
|
|
3a416f6f33 | ||
|
|
11a37834c2 | ||
|
|
d620455531 | ||
|
|
568eccb4c6 | ||
|
|
3a07dd8d96 | ||
|
|
802ccd3788 | ||
|
|
6042785c57 | ||
|
|
5d8664c13e | ||
|
|
3d0f24067c | ||
|
|
44049540b0 | ||
|
|
40f00ccc1c | ||
|
|
a301051263 | ||
|
|
fd61be4078 | ||
|
|
4a8bdc3c75 | ||
|
|
9c7629ce61 | ||
|
|
ba53c56a21 | ||
|
|
14c0989411 | ||
|
|
36bc07a5af | ||
|
|
270b807cdf | ||
|
|
bd52ce5640 | ||
|
|
a624871ccd | ||
|
|
819d09e64e | ||
|
|
9e6cb89101 | ||
|
|
a8347c3762 | ||
|
|
57b63ea83d | ||
|
|
c162074888 | ||
|
|
088eac9d4e | ||
|
|
5fe237a3fd | ||
|
|
ae398539c5 | ||
|
|
359360ad86 | ||
|
|
5d12eb9528 | ||
|
|
6fb4f2a7a5 | ||
|
|
48dfa45a9a | ||
|
|
97520c827e | ||
|
|
b75a89776d | ||
|
|
b909679367 | ||
|
|
639d1dd8fe | ||
|
|
7033b4d0a8 | ||
|
|
87c16374aa | ||
|
|
d366a1430f | ||
|
|
cfea5c73de | ||
|
|
2589eb207f | ||
|
|
ec7c72da3f | ||
|
|
a4b36a72ad | ||
|
|
e37a9081a6 | ||
|
|
a2469d933e | ||
|
|
3cde93bf2d | ||
|
|
898bcdec87 | ||
|
|
d5971e2da5 | ||
|
|
c71f4d4847 | ||
|
|
dec7827548 | ||
|
|
7faa8cb110 | ||
|
|
d8a4a125c0 | ||
|
|
50923f06f1 | ||
|
|
ba919fb619 | ||
|
|
47b4de3531 | ||
|
|
bb6d1d502f | ||
|
|
31e964e7cf | ||
|
|
06b2304a5f | ||
|
|
1b67339e4d | ||
|
|
1571246ba8 | ||
|
|
d730d8be01 | ||
|
|
e42cc85112 | ||
|
|
c7a79f1877 | ||
|
|
431f5347af | ||
|
|
1ed4a98233 | ||
|
|
db4ff89579 | ||
|
|
2f56761060 | ||
|
|
09286ccae0 | ||
|
|
4e959849f6 | ||
|
|
3690cafeb8 | ||
|
|
bcca253dec | ||
|
|
6d69ad5574 | ||
|
|
1f9be63e96 | ||
|
|
0873908030 | ||
|
|
4db2d94854 | ||
|
|
e5d52e4eb5 | ||
|
|
f20c0bffd3 | ||
|
|
9110e6a2a7 | ||
|
|
0888c02379 | ||
|
|
24ce49d9d7 | ||
|
|
5d69f00282 | ||
|
|
12016c8eb4 | ||
|
|
d6331cf792 | ||
|
|
2d7c9c9692 | ||
|
|
1aa18c6cd6 | ||
|
|
de25703e9d | ||
|
|
1133d87be0 | ||
|
|
42aa28d512 | ||
|
|
c6bd320003 | ||
|
|
24a9841322 | ||
|
|
8bf06cbcc1 | ||
|
|
be2e6f1926 | ||
|
|
72c12d59af | ||
|
|
8408e4702e | ||
|
|
ef14f64f9e | ||
|
|
3f60a6c2a4 | ||
|
|
d447b7694a | ||
|
|
5638b782c5 | ||
|
|
eb3f337695 | ||
|
|
c88ff3c08b | ||
|
|
e0e8b94384 | ||
|
|
8f4a72c57a | ||
|
|
ef155f3766 | ||
|
|
82a323ef70 | ||
|
|
a500eaa2d4 | ||
|
|
b714bb21d2 | ||
|
|
472d01fbaf | ||
|
|
a5c15a23e4 | ||
|
|
3d189b42a3 | ||
|
|
91a3ee642d | ||
|
|
37b24f4870 | ||
|
|
38572b8175 | ||
|
|
fc1addb8f4 | ||
|
|
83b7d8e04c | ||
|
|
d27dbfe062 | ||
|
|
f8904e3972 | ||
|
|
4a5823562c | ||
|
|
3aaf29b693 | ||
|
|
6b29896a35 | ||
|
|
1bb8574179 | ||
|
|
2611c35acc | ||
|
|
00c238777a | ||
|
|
e4b548fa76 | ||
|
|
e132dd2c70 | ||
|
|
fbe9669c57 | ||
|
|
c34ad7223a | ||
|
|
cc86a64bb5 | ||
|
|
3394402aef | ||
|
|
6cd3a59022 | ||
|
|
5aeb305344 | ||
|
|
6eb043aedb | ||
|
|
e96f6385c2 | ||
|
|
1109a282e0 | ||
|
|
25f3eef957 | ||
|
|
0ca75544ab | ||
|
|
572a037e5d | ||
|
|
ad92181fa7 | ||
|
|
c56f4aa5d8 | ||
|
|
a344a766fd | ||
|
|
bca793d064 | ||
|
|
ad3c192837 | ||
|
|
5512231ca8 | ||
|
|
bad394cd49 | ||
|
|
3b97580621 | ||
|
|
cb88fe26aa | ||
|
|
e345b89ce5 | ||
|
|
26c7b240ba | ||
|
|
d327a2b1cf | ||
|
|
c1b03b728a | ||
|
|
2a2437bf22 | ||
|
|
4ccb82e81a | ||
|
|
92912219df | ||
|
|
bab3124e8b | ||
|
|
7a66ec6bc9 | ||
|
|
3a505b2691 | ||
|
|
20f43372f6 | ||
|
|
fb79dd7bf8 | ||
|
|
4025b655a4 | ||
|
|
7379903568 | ||
|
|
a685e7a805 | ||
|
|
ce7484b4f5 | ||
|
|
0bc1dcbe1b | ||
|
|
a69b339baf | ||
|
|
26f835cdd2 | ||
|
|
bd3d1413fd | ||
|
|
2c17a980ff | ||
|
|
b784c923a8 | ||
|
|
ea96f898c0 | ||
|
|
47435f6e17 | ||
|
|
df59d1412b | ||
|
|
46739ca7cd | ||
|
|
d055c1cad6 | ||
|
|
adfbfe350d | ||
|
|
652a776554 | ||
|
|
1d78100f63 | ||
|
|
57a5d5fd34 | ||
|
|
14684d8e75 | ||
|
|
2cac848823 | ||
|
|
5a3e0ef13a | ||
|
|
7ed4499748 | ||
|
|
4d5e86d8a5 | ||
|
|
222b6cda96 | ||
|
|
8e243c6500 | ||
|
|
98f3ff6273 | ||
|
|
ce08442732 | ||
|
|
8fcfbd697a | ||
|
|
a8669aba8f | ||
|
|
d31e9cff6a | ||
|
|
0cb11c2412 | ||
|
|
9b1d7047d4 | ||
|
|
703d634744 | ||
|
|
e273a31e70 | ||
|
|
277c68d8e5 | ||
|
|
10985671ad | ||
|
|
3dfbb70593 | ||
|
|
07947bab7d | ||
|
|
4eed55973f | ||
|
|
6e984378d7 | ||
|
|
4fd3141ab5 | ||
|
|
8d0a303af4 | ||
|
|
0186a85063 | ||
|
|
ed4e4843c2 | ||
|
|
a93a1b93e1 | ||
|
|
ace63b3ddb | ||
|
|
d338bd528c | ||
|
|
ea2d089db0 | ||
|
|
4226097228 | ||
|
|
e35a4131d0 | ||
|
|
0e669b6016 | ||
|
|
9163611989 | ||
|
|
d93cefd47a | ||
|
|
a580fb47d2 | ||
|
|
9d3c81a683 | ||
|
|
86e545a23e | ||
|
|
b0afdf6ea4 | ||
|
|
d8c25bfeb4 | ||
|
|
160ba295a8 | ||
|
|
16332a8583 | ||
|
|
ae6e85b2a4 | ||
|
|
fdad823edc | ||
|
|
5cc1d6097e | ||
|
|
8c1af9b445 | ||
|
|
ef979ccfa8 | ||
|
|
bb30e06855 | ||
|
|
b055f973df | ||
|
|
45fa5e7199 | ||
|
|
3ebf27aab9 | ||
|
|
1d041c8861 | ||
|
|
089ab9defa | ||
|
|
f7708efa5b | ||
|
|
60807846a9 | ||
|
|
afd0716cbd | ||
|
|
920255e8c6 | ||
|
|
21e0778002 | ||
|
|
d9363da9ee | ||
|
|
9b23130ac4 | ||
|
|
62a24c2dda | ||
|
|
3a3aa300bb | ||
|
|
cf50a289db | ||
|
|
3c85cf4fac | ||
|
|
878ddc6a0a | ||
|
|
3761121728 | ||
|
|
3aaa34be1e | ||
|
|
985c2a3d15 | ||
|
|
eb553f53ac | ||
|
|
d1482e1483 | ||
|
|
45f0050372 | ||
|
|
b5c8bd3421 | ||
|
|
2bab5e8c39 | ||
|
|
85b5f5b705 | ||
|
|
460a87f359 |
@@ -0,0 +1,15 @@
|
|||||||
|
adamdotdevin
|
||||||
|
Brendonovich
|
||||||
|
fwang
|
||||||
|
Hona
|
||||||
|
iamdavidhill
|
||||||
|
jayair
|
||||||
|
jlongster
|
||||||
|
kitlangton
|
||||||
|
kommander
|
||||||
|
MrMushrooooom
|
||||||
|
nexxeln
|
||||||
|
R44VC0RP
|
||||||
|
rekram1-node
|
||||||
|
RhysSullivan
|
||||||
|
thdxr
|
||||||
@@ -3,11 +3,13 @@ description: "Setup Bun with caching and install dependencies"
|
|||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Mount Bun Cache
|
- name: Cache Bun dependencies
|
||||||
uses: useblacksmith/stickydisk@v1
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
|
path: ~/.bun/install/cache
|
||||||
path: ~/.bun
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|||||||
@@ -1,7 +1,29 @@
|
|||||||
|
### Issue for this PR
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
### Type of change
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Refactor / code improvement
|
||||||
|
- [ ] Documentation
|
||||||
|
|
||||||
### What does this PR do?
|
### What does this PR do?
|
||||||
|
|
||||||
Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
|
Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
|
||||||
|
|
||||||
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
|
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
|
||||||
|
|
||||||
### How did you verify your code works?
|
### How did you verify your code works?
|
||||||
|
|
||||||
|
### Screenshots / recordings
|
||||||
|
|
||||||
|
_If this is a UI change, please include a screenshot or recording._
|
||||||
|
|
||||||
|
### Checklist
|
||||||
|
|
||||||
|
- [ ] I have tested my changes locally
|
||||||
|
- [ ] I have not included unrelated changes in this PR
|
||||||
|
|
||||||
|
_If you do not follow this template your PR will be automatically rejected._
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ name: duplicate-issues
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [opened]
|
types: [opened, edited]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-duplicates:
|
check-duplicates:
|
||||||
|
if: github.event.action == 'opened'
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -34,7 +35,7 @@ jobs:
|
|||||||
"webfetch": "deny"
|
"webfetch": "deny"
|
||||||
}
|
}
|
||||||
run: |
|
run: |
|
||||||
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
|
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
||||||
|
|
||||||
Issue number: ${{ github.event.issue.number }}
|
Issue number: ${{ github.event.issue.number }}
|
||||||
|
|
||||||
@@ -115,3 +116,62 @@ jobs:
|
|||||||
If you believe this was flagged incorrectly, please let a maintainer know.
|
If you believe this was flagged incorrectly, please let a maintainer know.
|
||||||
|
|
||||||
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
||||||
|
|
||||||
|
recheck-compliance:
|
||||||
|
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Install opencode
|
||||||
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
- name: Recheck compliance
|
||||||
|
env:
|
||||||
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
OPENCODE_PERMISSION: |
|
||||||
|
{
|
||||||
|
"bash": {
|
||||||
|
"*": "deny",
|
||||||
|
"gh issue*": "allow"
|
||||||
|
},
|
||||||
|
"webfetch": "deny"
|
||||||
|
}
|
||||||
|
run: |
|
||||||
|
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
||||||
|
|
||||||
|
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||||
|
|
||||||
|
Re-check whether the issue now follows our contributing guidelines and issue templates.
|
||||||
|
|
||||||
|
This project has three issue templates that every issue MUST use one of:
|
||||||
|
|
||||||
|
1. Bug Report - requires a Description field with real content
|
||||||
|
2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
|
||||||
|
3. Question - requires the Question field with real content
|
||||||
|
|
||||||
|
Additionally check:
|
||||||
|
- No AI-generated walls of text (long, AI-generated descriptions are not acceptable)
|
||||||
|
- The issue has real content, not just template placeholder text left unchanged
|
||||||
|
- Bug reports should include some context about how to reproduce
|
||||||
|
- Feature requests should explain the problem or need
|
||||||
|
- We want to push for having the user provide system description & information
|
||||||
|
|
||||||
|
Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
|
||||||
|
|
||||||
|
If the issue is NOW compliant:
|
||||||
|
1. Remove the needs:compliance label: gh issue edit ${{ github.event.issue.number }} --remove-label needs:compliance
|
||||||
|
2. Find and delete the previous compliance comment (the one containing <!-- issue-compliance -->) using: gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments --jq '.[] | select(.body | contains(\"<!-- issue-compliance -->\")) | .id' then delete it with: gh api -X DELETE repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments/{id}
|
||||||
|
3. Post a short comment thanking them for updating the issue.
|
||||||
|
|
||||||
|
If the issue is STILL not compliant:
|
||||||
|
Post a comment explaining what still needs to be fixed. Keep the needs:compliance label."
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
name: nix-desktop
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev]
|
|
||||||
paths:
|
|
||||||
- "flake.nix"
|
|
||||||
- "flake.lock"
|
|
||||||
- "nix/**"
|
|
||||||
- "packages/app/**"
|
|
||||||
- "packages/desktop/**"
|
|
||||||
- ".github/workflows/nix-desktop.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "flake.nix"
|
|
||||||
- "flake.lock"
|
|
||||||
- "nix/**"
|
|
||||||
- "packages/app/**"
|
|
||||||
- "packages/desktop/**"
|
|
||||||
- ".github/workflows/nix-desktop.yml"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
nix-desktop:
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os:
|
|
||||||
- blacksmith-4vcpu-ubuntu-2404
|
|
||||||
- blacksmith-4vcpu-ubuntu-2404-arm
|
|
||||||
- macos-15-intel
|
|
||||||
- macos-latest
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Nix
|
|
||||||
uses: nixbuild/nix-quick-install-action@v34
|
|
||||||
|
|
||||||
- name: Build desktop via flake
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
nix --version
|
|
||||||
nix build .#desktop -L
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
name: nix-eval
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
nix-eval:
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Nix
|
||||||
|
uses: nixbuild/nix-quick-install-action@v34
|
||||||
|
|
||||||
|
- name: Evaluate flake outputs (all systems)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
nix --version
|
||||||
|
|
||||||
|
echo "=== Flake metadata ==="
|
||||||
|
nix flake metadata
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Flake structure ==="
|
||||||
|
nix flake show --all-systems
|
||||||
|
|
||||||
|
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
|
||||||
|
PACKAGES="opencode"
|
||||||
|
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
|
||||||
|
OPTIONAL_PACKAGES="desktop"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Evaluating packages for all systems ==="
|
||||||
|
for system in $SYSTEMS; do
|
||||||
|
echo ""
|
||||||
|
echo "--- $system ---"
|
||||||
|
for pkg in $PACKAGES; do
|
||||||
|
printf " %s: " "$pkg"
|
||||||
|
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
|
||||||
|
echo "✓"
|
||||||
|
else
|
||||||
|
echo "✗"
|
||||||
|
echo "::error::Evaluation failed for packages.$system.$pkg"
|
||||||
|
echo "$output"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Evaluating optional packages ==="
|
||||||
|
for system in $SYSTEMS; do
|
||||||
|
echo ""
|
||||||
|
echo "--- $system ---"
|
||||||
|
for pkg in $OPTIONAL_PACKAGES; do
|
||||||
|
printf " %s: " "$pkg"
|
||||||
|
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
|
||||||
|
echo "✓"
|
||||||
|
else
|
||||||
|
echo "✗"
|
||||||
|
echo "::warning::Evaluation failed for packages.$system.$pkg"
|
||||||
|
echo "$output"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Evaluating devShells for all systems ==="
|
||||||
|
for system in $SYSTEMS; do
|
||||||
|
printf "%s: " "$system"
|
||||||
|
if output=$(nix eval ".#devShells.$system.default.drvPath" --raw 2>&1); then
|
||||||
|
echo "✓"
|
||||||
|
else
|
||||||
|
echo "✗"
|
||||||
|
echo "::error::Evaluation failed for devShells.$system.default"
|
||||||
|
echo "$output"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== All evaluations passed ==="
|
||||||
@@ -6,7 +6,7 @@ permissions:
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [dev]
|
branches: [dev, beta]
|
||||||
paths:
|
paths:
|
||||||
- "bun.lock"
|
- "bun.lock"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
|
|||||||
@@ -6,17 +6,6 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-duplicates:
|
check-duplicates:
|
||||||
if: |
|
|
||||||
github.event.pull_request.user.login != 'actions-user' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode' &&
|
|
||||||
github.event.pull_request.user.login != 'rekram1-node' &&
|
|
||||||
github.event.pull_request.user.login != 'thdxr' &&
|
|
||||||
github.event.pull_request.user.login != 'kommander' &&
|
|
||||||
github.event.pull_request.user.login != 'jayair' &&
|
|
||||||
github.event.pull_request.user.login != 'fwang' &&
|
|
||||||
github.event.pull_request.user.login != 'adamdotdevin' &&
|
|
||||||
github.event.pull_request.user.login != 'iamdavidhill' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode-agent[bot]'
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -27,16 +16,31 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Check team membership
|
||||||
|
id: team-check
|
||||||
|
run: |
|
||||||
|
LOGIN="${{ github.event.pull_request.user.login }}"
|
||||||
|
if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then
|
||||||
|
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Skipping: $LOGIN is a team member or bot"
|
||||||
|
else
|
||||||
|
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
|
if: steps.team-check.outputs.is_team != 'true'
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: steps.team-check.outputs.is_team != 'true'
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
- name: Install opencode
|
- name: Install opencode
|
||||||
|
if: steps.team-check.outputs.is_team != 'true'
|
||||||
run: curl -fsSL https://opencode.ai/install | bash
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
- name: Build prompt
|
- name: Build prompt
|
||||||
|
if: steps.team-check.outputs.is_team != 'true'
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
@@ -53,6 +57,7 @@ jobs:
|
|||||||
} > pr_info.txt
|
} > pr_info.txt
|
||||||
|
|
||||||
- name: Check for duplicate PRs
|
- name: Check for duplicate PRs
|
||||||
|
if: steps.team-check.outputs.is_team != 'true'
|
||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -6,19 +6,9 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-standards:
|
check-standards:
|
||||||
if: |
|
|
||||||
github.event.pull_request.user.login != 'actions-user' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode' &&
|
|
||||||
github.event.pull_request.user.login != 'rekram1-node' &&
|
|
||||||
github.event.pull_request.user.login != 'thdxr' &&
|
|
||||||
github.event.pull_request.user.login != 'kommander' &&
|
|
||||||
github.event.pull_request.user.login != 'jayair' &&
|
|
||||||
github.event.pull_request.user.login != 'fwang' &&
|
|
||||||
github.event.pull_request.user.login != 'adamdotdevin' &&
|
|
||||||
github.event.pull_request.user.login != 'iamdavidhill' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode-agent[bot]'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check PR standards
|
- name: Check PR standards
|
||||||
@@ -26,6 +16,30 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
|
const login = pr.user.login;
|
||||||
|
|
||||||
|
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
|
||||||
|
const cutoff = new Date('2026-02-19T00:00:00Z');
|
||||||
|
const prCreated = new Date(pr.created_at);
|
||||||
|
if (prCreated < cutoff) {
|
||||||
|
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if author is a team member or bot
|
||||||
|
if (login === 'opencode-agent[bot]') return;
|
||||||
|
const { data: file } = await github.rest.repos.getContent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
path: '.github/TEAM_MEMBERS',
|
||||||
|
ref: 'dev'
|
||||||
|
});
|
||||||
|
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
if (members.includes(login)) {
|
||||||
|
console.log(`Skipping: ${login} is a team member`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const title = pr.title;
|
const title = pr.title;
|
||||||
|
|
||||||
async function addLabel(label) {
|
async function addLabel(label) {
|
||||||
@@ -137,3 +151,201 @@ jobs:
|
|||||||
|
|
||||||
await removeLabel('needs:issue');
|
await removeLabel('needs:issue');
|
||||||
console.log('PR meets all standards');
|
console.log('PR meets all standards');
|
||||||
|
|
||||||
|
check-compliance:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Check PR template compliance
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const pr = context.payload.pull_request;
|
||||||
|
const login = pr.user.login;
|
||||||
|
|
||||||
|
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
|
||||||
|
const cutoff = new Date('2026-02-19T00:00:00Z');
|
||||||
|
const prCreated = new Date(pr.created_at);
|
||||||
|
if (prCreated < cutoff) {
|
||||||
|
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if author is a team member or bot
|
||||||
|
if (login === 'opencode-agent[bot]') return;
|
||||||
|
const { data: file } = await github.rest.repos.getContent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
path: '.github/TEAM_MEMBERS',
|
||||||
|
ref: 'dev'
|
||||||
|
});
|
||||||
|
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
if (members.includes(login)) {
|
||||||
|
console.log(`Skipping: ${login} is a team member`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = pr.body || '';
|
||||||
|
const title = pr.title;
|
||||||
|
const isDocsOrRefactor = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
|
||||||
|
// Check: template sections exist
|
||||||
|
const hasWhatSection = /### What does this PR do\?/.test(body);
|
||||||
|
const hasTypeSection = /### Type of change/.test(body);
|
||||||
|
const hasVerifySection = /### How did you verify your code works\?/.test(body);
|
||||||
|
const hasChecklistSection = /### Checklist/.test(body);
|
||||||
|
const hasIssueSection = /### Issue for this PR/.test(body);
|
||||||
|
|
||||||
|
if (!hasWhatSection || !hasTypeSection || !hasVerifySection || !hasChecklistSection || !hasIssueSection) {
|
||||||
|
issues.push('PR description is missing required template sections. Please use the [PR template](../blob/dev/.github/pull_request_template.md).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: "What does this PR do?" has real content (not just placeholder text)
|
||||||
|
if (hasWhatSection) {
|
||||||
|
const whatMatch = body.match(/### What does this PR do\?\s*\n([\s\S]*?)(?=###|$)/);
|
||||||
|
const whatContent = whatMatch ? whatMatch[1].trim() : '';
|
||||||
|
const placeholder = 'Please provide a description of the issue';
|
||||||
|
const onlyPlaceholder = whatContent.includes(placeholder) && whatContent.replace(placeholder, '').replace(/[*\s]/g, '').length < 20;
|
||||||
|
if (!whatContent || onlyPlaceholder) {
|
||||||
|
issues.push('"What does this PR do?" section is empty or only contains placeholder text. Please describe your changes.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: at least one "Type of change" checkbox is checked
|
||||||
|
if (hasTypeSection) {
|
||||||
|
const typeMatch = body.match(/### Type of change\s*\n([\s\S]*?)(?=###|$)/);
|
||||||
|
const typeContent = typeMatch ? typeMatch[1] : '';
|
||||||
|
const hasCheckedBox = /- \[x\]/i.test(typeContent);
|
||||||
|
if (!hasCheckedBox) {
|
||||||
|
issues.push('No "Type of change" checkbox is checked. Please select at least one.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: issue reference (skip for docs/refactor)
|
||||||
|
if (!isDocsOrRefactor && hasIssueSection) {
|
||||||
|
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
|
||||||
|
const issueContent = issueMatch ? issueMatch[1].trim() : '';
|
||||||
|
const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
|
||||||
|
if (!hasIssueRef) {
|
||||||
|
issues.push('No issue referenced. Please add `Closes #<number>` linking to the relevant issue.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: "How did you verify" has content
|
||||||
|
if (hasVerifySection) {
|
||||||
|
const verifyMatch = body.match(/### How did you verify your code works\?\s*\n([\s\S]*?)(?=###|$)/);
|
||||||
|
const verifyContent = verifyMatch ? verifyMatch[1].trim() : '';
|
||||||
|
if (!verifyContent) {
|
||||||
|
issues.push('"How did you verify your code works?" section is empty. Please explain how you tested.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: checklist boxes are checked
|
||||||
|
if (hasChecklistSection) {
|
||||||
|
const checklistMatch = body.match(/### Checklist\s*\n([\s\S]*?)(?=###|$)/);
|
||||||
|
const checklistContent = checklistMatch ? checklistMatch[1] : '';
|
||||||
|
const unchecked = (checklistContent.match(/- \[ \]/g) || []).length;
|
||||||
|
const checked = (checklistContent.match(/- \[x\]/gi) || []).length;
|
||||||
|
if (checked < 2) {
|
||||||
|
issues.push('Not all checklist items are checked. Please confirm you have tested locally and have not included unrelated changes.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
async function addLabel(label) {
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
labels: [label]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLabel(label) {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
name: label
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasComplianceLabel = pr.labels.some(l => l.name === 'needs:compliance');
|
||||||
|
|
||||||
|
if (issues.length > 0) {
|
||||||
|
// Non-compliant
|
||||||
|
if (!hasComplianceLabel) {
|
||||||
|
await addLabel('needs:compliance');
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = '<!-- issue-compliance -->';
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body.includes(marker));
|
||||||
|
|
||||||
|
const body_text = `${marker}
|
||||||
|
This PR doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) and [PR template](../blob/dev/.github/pull_request_template.md).
|
||||||
|
|
||||||
|
**What needs to be fixed:**
|
||||||
|
${issues.map(i => `- ${i}`).join('\n')}
|
||||||
|
|
||||||
|
Please edit this PR description to address the above within **2 hours**, or it will be automatically closed.
|
||||||
|
|
||||||
|
If you believe this was flagged incorrectly, please let a maintainer know.`;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body: body_text
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
body: body_text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`PR #${pr.number} is non-compliant: ${issues.join(', ')}`);
|
||||||
|
} else if (hasComplianceLabel) {
|
||||||
|
// Was non-compliant, now fixed
|
||||||
|
await removeLabel('needs:compliance');
|
||||||
|
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number
|
||||||
|
});
|
||||||
|
const marker = '<!-- issue-compliance -->';
|
||||||
|
const existing = comments.find(c => c.body.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.deleteComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
body: 'Thanks for updating your PR! It now meets our contributing guidelines. :+1:'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`PR #${pr.number} is now compliant, label removed`);
|
||||||
|
} else {
|
||||||
|
console.log(`PR #${pr.number} is compliant`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: /var/cache/apt/archives
|
path: ~/apt-cache
|
||||||
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
|
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ matrix.settings.target }}-apt-
|
${{ runner.os }}-${{ matrix.settings.target }}-apt-
|
||||||
@@ -145,8 +145,10 @@ jobs:
|
|||||||
- name: install dependencies (ubuntu only)
|
- name: install dependencies (ubuntu only)
|
||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
sudo chmod -R a+rw ~/apt-cache
|
||||||
|
|
||||||
- name: install Rust stable
|
- name: install Rust stable
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -169,13 +171,23 @@ jobs:
|
|||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Resolve tauri portable SHA
|
||||||
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
|
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
||||||
- name: Install tauri-cli from portable appimage branch
|
- name: Install tauri-cli from portable appimage branch
|
||||||
|
uses: taiki-e/cache-cargo-install-action@v3
|
||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
run: |
|
with:
|
||||||
cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
|
tool: tauri-cli
|
||||||
echo "Installed tauri-cli version:"
|
git: https://github.com/tauri-apps/tauri
|
||||||
cargo tauri --version
|
# branch: feat/truly-portable-appimage
|
||||||
|
rev: ${{ env.TAURI_PORTABLE_SHA }}
|
||||||
|
|
||||||
|
- name: Show tauri-cli version
|
||||||
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
|
run: cargo tauri --version
|
||||||
|
|
||||||
- name: Build and upload artifacts
|
- name: Build and upload artifacts
|
||||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ opencode serve --hostname 0.0.0.0 --port 4096
|
|||||||
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
||||||
opencode session [command]
|
opencode session [command]
|
||||||
opencode session list
|
opencode session list
|
||||||
|
opencode session delete <sessionID>
|
||||||
opencode stats
|
opencode stats
|
||||||
opencode uninstall
|
opencode uninstall
|
||||||
opencode upgrade
|
opencode upgrade
|
||||||
@@ -598,6 +599,7 @@ OPENCODE_EXPERIMENTAL_MARKDOWN
|
|||||||
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
||||||
OPENCODE_EXPERIMENTAL_OXFMT
|
OPENCODE_EXPERIMENTAL_OXFMT
|
||||||
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
||||||
|
OPENCODE_ENABLE_QUESTION_TOOL
|
||||||
OPENCODE_FAKE_VCS
|
OPENCODE_FAKE_VCS
|
||||||
OPENCODE_GIT_BASH_PATH
|
OPENCODE_GIT_BASH_PATH
|
||||||
OPENCODE_MODEL
|
OPENCODE_MODEL
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
mode: primary
|
mode: primary
|
||||||
hidden: true
|
hidden: true
|
||||||
model: opencode/claude-haiku-4-5
|
model: opencode/minimax-m2.5
|
||||||
color: "#44BA81"
|
color: "#44BA81"
|
||||||
tools:
|
tools:
|
||||||
"*": false
|
"*": false
|
||||||
@@ -12,6 +12,8 @@ You are a triage agent responsible for triaging github issues.
|
|||||||
|
|
||||||
Use your github-triage tool to triage issues.
|
Use your github-triage tool to triage issues.
|
||||||
|
|
||||||
|
This file is the source of truth for ownership/routing rules.
|
||||||
|
|
||||||
## Labels
|
## Labels
|
||||||
|
|
||||||
### windows
|
### windows
|
||||||
@@ -43,12 +45,34 @@ Desktop app issues:
|
|||||||
|
|
||||||
**Only** add if the issue explicitly mentions nix.
|
**Only** add if the issue explicitly mentions nix.
|
||||||
|
|
||||||
|
If the issue does not mention nix, do not add nix.
|
||||||
|
|
||||||
|
If the issue mentions nix, assign to `rekram1-node`.
|
||||||
|
|
||||||
#### zen
|
#### zen
|
||||||
|
|
||||||
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
|
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
|
||||||
|
|
||||||
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
|
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
|
||||||
|
|
||||||
|
#### core
|
||||||
|
|
||||||
|
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- LSP server behavior
|
||||||
|
- Harness behavior (agent + tools)
|
||||||
|
- Feature requests for server behavior
|
||||||
|
- Agent context construction
|
||||||
|
- API endpoints
|
||||||
|
- Provider integration issues
|
||||||
|
- New, broken, or poor-quality models
|
||||||
|
|
||||||
|
#### acp
|
||||||
|
|
||||||
|
If the issue mentions acp support, assign acp label.
|
||||||
|
|
||||||
#### docs
|
#### docs
|
||||||
|
|
||||||
Add if the issue requests better documentation or docs updates.
|
Add if the issue requests better documentation or docs updates.
|
||||||
@@ -66,13 +90,51 @@ TUI issues potentially caused by our underlying TUI library:
|
|||||||
|
|
||||||
When assigning to people here are the following rules:
|
When assigning to people here are the following rules:
|
||||||
|
|
||||||
adamdotdev:
|
Desktop / Web:
|
||||||
ONLY assign adam if the issue will have the "desktop" label.
|
Use for desktop-labeled issues only.
|
||||||
|
|
||||||
fwang:
|
- adamdotdevin
|
||||||
ONLY assign fwang if the issue will have the "zen" label.
|
- iamdavidhill
|
||||||
|
- Brendonovich
|
||||||
|
- nexxeln
|
||||||
|
|
||||||
jayair:
|
Zen:
|
||||||
ONLY assign jayair if the issue will have the "docs" label.
|
ONLY assign if the issue will have the "zen" label.
|
||||||
|
|
||||||
In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node.
|
- fwang
|
||||||
|
- MrMushrooooom
|
||||||
|
|
||||||
|
TUI (`packages/opencode/src/cli/cmd/tui/...`):
|
||||||
|
|
||||||
|
- thdxr for TUI UX/UI product decisions and interaction flow
|
||||||
|
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
|
||||||
|
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
|
||||||
|
|
||||||
|
Core (`packages/opencode/...`, excluding TUI subtree):
|
||||||
|
|
||||||
|
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
|
||||||
|
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
|
||||||
|
- rekram1-node for harness issues, provider issues, and other bug-squashing
|
||||||
|
|
||||||
|
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
|
||||||
|
|
||||||
|
Docs:
|
||||||
|
|
||||||
|
- R44VC0RP
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
|
||||||
|
|
||||||
|
Determinism rules:
|
||||||
|
|
||||||
|
- If title + body does not contain "zen", do not add the "zen" label
|
||||||
|
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
|
||||||
|
- If title + body mentions nix/nixos, assign to `rekram1-node`
|
||||||
|
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
|
||||||
|
|
||||||
|
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||||
|
|
||||||
|
ACP:
|
||||||
|
|
||||||
|
- rekram1-node (assign any acp issues to rekram1-node)
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
// "enterprise": {
|
|
||||||
// "url": "https://enterprise.dev.opencode.ai",
|
|
||||||
// },
|
|
||||||
"provider": {
|
"provider": {
|
||||||
"opencode": {
|
"opencode": {
|
||||||
"options": {},
|
"options": {},
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
name: bun-file-io
|
|
||||||
description: Use this when you are working on file operations like reading, writing, scanning, or deleting files. It summarizes the preferred file APIs and patterns used in this repo. It also notes when to use filesystem helpers for directories.
|
|
||||||
---
|
|
||||||
|
|
||||||
## Use this when
|
|
||||||
|
|
||||||
- Editing file I/O or scans in `packages/opencode`
|
|
||||||
- Handling directory operations or external tools
|
|
||||||
|
|
||||||
## Bun file APIs (from Bun docs)
|
|
||||||
|
|
||||||
- `Bun.file(path)` is lazy; call `text`, `json`, `stream`, `arrayBuffer`, `bytes`, `exists` to read.
|
|
||||||
- Metadata: `file.size`, `file.type`, `file.name`.
|
|
||||||
- `Bun.write(dest, input)` writes strings, buffers, Blobs, Responses, or files.
|
|
||||||
- `Bun.file(...).delete()` deletes a file.
|
|
||||||
- `file.writer()` returns a FileSink for incremental writes.
|
|
||||||
- `Bun.Glob` + `Array.fromAsync(glob.scan({ cwd, absolute, onlyFiles, dot }))` for scans.
|
|
||||||
- Use `Bun.which` to find a binary, then `Bun.spawn` to run it.
|
|
||||||
- `Bun.readableStreamToText/Bytes/JSON` for stream output.
|
|
||||||
|
|
||||||
## When to use node:fs
|
|
||||||
|
|
||||||
- Use `node:fs/promises` for directories (`mkdir`, `readdir`, recursive operations).
|
|
||||||
|
|
||||||
## Repo patterns
|
|
||||||
|
|
||||||
- Prefer Bun APIs over Node `fs` for file access.
|
|
||||||
- Check `Bun.file(...).exists()` before reading.
|
|
||||||
- For binary/large files use `arrayBuffer()` and MIME checks via `file.type`.
|
|
||||||
- Use `Bun.Glob` + `Array.fromAsync` for scans.
|
|
||||||
- Decode tool stderr with `Bun.readableStreamToText`.
|
|
||||||
- For large writes, use `Bun.write(Bun.file(path), text)`.
|
|
||||||
|
|
||||||
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
|
|
||||||
Use Filesystem.exists(...) instead if path can be file or directory
|
|
||||||
|
|
||||||
## Quick checklist
|
|
||||||
|
|
||||||
- Use Bun APIs first.
|
|
||||||
- Use `path.join`/`path.resolve` for paths.
|
|
||||||
- Prefer promise `.catch(...)` over `try/catch` when possible.
|
|
||||||
@@ -1,8 +1,22 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
// import { Octokit } from "@octokit/rest"
|
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-triage.txt"
|
import DESCRIPTION from "./github-triage.txt"
|
||||||
|
|
||||||
|
const TEAM = {
|
||||||
|
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||||
|
zen: ["fwang", "MrMushrooooom"],
|
||||||
|
tui: ["thdxr", "kommander", "rekram1-node"],
|
||||||
|
core: ["thdxr", "rekram1-node", "jlongster"],
|
||||||
|
docs: ["R44VC0RP"],
|
||||||
|
windows: ["Hona"],
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
|
||||||
|
|
||||||
|
function pick<T>(items: readonly T[]) {
|
||||||
|
return items[Math.floor(Math.random() * items.length)]!
|
||||||
|
}
|
||||||
|
|
||||||
function getIssueNumber(): number {
|
function getIssueNumber(): number {
|
||||||
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
|
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
|
||||||
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
|
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
|
||||||
@@ -29,60 +43,69 @@ export default tool({
|
|||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
args: {
|
args: {
|
||||||
assignee: tool.schema
|
assignee: tool.schema
|
||||||
.enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"])
|
.enum(ASSIGNEES as [string, ...string[]])
|
||||||
.describe("The username of the assignee")
|
.describe("The username of the assignee")
|
||||||
.default("rekram1-node"),
|
.default("rekram1-node"),
|
||||||
labels: tool.schema
|
labels: tool.schema
|
||||||
.array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"]))
|
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||||
.describe("The labels(s) to add to the issue")
|
.describe("The labels(s) to add to the issue")
|
||||||
.default([]),
|
.default([]),
|
||||||
},
|
},
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
const issue = getIssueNumber()
|
const issue = getIssueNumber()
|
||||||
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
|
|
||||||
const owner = "anomalyco"
|
const owner = "anomalyco"
|
||||||
const repo = "opencode"
|
const repo = "opencode"
|
||||||
|
|
||||||
const results: string[] = []
|
const results: string[] = []
|
||||||
|
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
|
||||||
|
const web = labels.includes("web")
|
||||||
|
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
|
||||||
|
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
|
||||||
|
const nix = /\bnix(os)?\b/.test(text)
|
||||||
|
|
||||||
if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) {
|
if (labels.includes("nix") && !nix) {
|
||||||
throw new Error("Only desktop issues should be assigned to adamdotdevin")
|
labels = labels.filter((x) => x !== "nix")
|
||||||
|
results.push("Dropped label: nix (issue does not mention nix)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.assignee === "fwang" && !args.labels.includes("zen")) {
|
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||||
throw new Error("Only zen issues should be assigned to fwang")
|
|
||||||
|
if (labels.includes("zen") && !zen) {
|
||||||
|
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.assignee === "kommander" && !args.labels.includes("opentui")) {
|
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
|
||||||
|
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
|
||||||
|
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignee === "Hona" && !labels.includes("windows")) {
|
||||||
|
throw new Error("Only windows issues should be assigned to Hona")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
|
||||||
|
throw new Error("Only docs issues should be assigned to R44VC0RP")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignee === "kommander" && !labels.includes("opentui")) {
|
||||||
throw new Error("Only opentui issues should be assigned to kommander")
|
throw new Error("Only opentui issues should be assigned to kommander")
|
||||||
}
|
}
|
||||||
|
|
||||||
// await octokit.rest.issues.addAssignees({
|
|
||||||
// owner,
|
|
||||||
// repo,
|
|
||||||
// issue_number: issue,
|
|
||||||
// assignees: [args.assignee],
|
|
||||||
// })
|
|
||||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
|
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ assignees: [args.assignee] }),
|
body: JSON.stringify({ assignees: [assignee] }),
|
||||||
})
|
})
|
||||||
results.push(`Assigned @${args.assignee} to issue #${issue}`)
|
results.push(`Assigned @${assignee} to issue #${issue}`)
|
||||||
|
|
||||||
const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label))
|
|
||||||
|
|
||||||
if (labels.length > 0) {
|
if (labels.length > 0) {
|
||||||
// await octokit.rest.issues.addLabels({
|
|
||||||
// owner,
|
|
||||||
// repo,
|
|
||||||
// issue_number: issue,
|
|
||||||
// labels,
|
|
||||||
// })
|
|
||||||
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
|
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ labels }),
|
body: JSON.stringify({ labels }),
|
||||||
})
|
})
|
||||||
results.push(`Added labels: ${args.labels.join(", ")}`)
|
results.push(`Added labels: ${labels.join(", ")}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return results.join("\n")
|
return results.join("\n")
|
||||||
|
|||||||
@@ -1,88 +1,6 @@
|
|||||||
Use this tool to assign and/or label a GitHub issue.
|
Use this tool to assign and/or label a GitHub issue.
|
||||||
|
|
||||||
You can assign the following users:
|
Choose labels and assignee using the current triage policy and ownership rules.
|
||||||
- thdxr
|
Pick the most fitting labels for the issue and assign one owner.
|
||||||
- adamdotdevin
|
|
||||||
- fwang
|
|
||||||
- jayair
|
|
||||||
- kommander
|
|
||||||
- rekram1-node
|
|
||||||
|
|
||||||
|
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||||
You can use the following labels:
|
|
||||||
- nix
|
|
||||||
- opentui
|
|
||||||
- perf
|
|
||||||
- web
|
|
||||||
- zen
|
|
||||||
- docs
|
|
||||||
|
|
||||||
Always try to assign an issue, if in doubt, assign rekram1-node to it.
|
|
||||||
|
|
||||||
## Breakdown of responsibilities:
|
|
||||||
|
|
||||||
### thdxr
|
|
||||||
|
|
||||||
Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him.
|
|
||||||
|
|
||||||
This relates to OpenCode server primarily but has overlap with just about anything
|
|
||||||
|
|
||||||
### adamdotdevin
|
|
||||||
|
|
||||||
Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him.
|
|
||||||
|
|
||||||
|
|
||||||
### fwang
|
|
||||||
|
|
||||||
Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue.
|
|
||||||
|
|
||||||
### jayair
|
|
||||||
|
|
||||||
Jay is responsible for documentation. If there is an issue relating to documentation assign him.
|
|
||||||
|
|
||||||
### kommander
|
|
||||||
|
|
||||||
Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about:
|
|
||||||
- random characters on screen
|
|
||||||
- keybinds not working on different terminals
|
|
||||||
- general terminal stuff
|
|
||||||
Then assign the issue to Him.
|
|
||||||
|
|
||||||
### rekram1-node
|
|
||||||
|
|
||||||
ALL BUGS SHOULD BE assigned to rekram1-node unless they have the `opentui` label.
|
|
||||||
|
|
||||||
Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things.
|
|
||||||
If no one else makes sense to assign, assign rekram1-node to it.
|
|
||||||
|
|
||||||
Always assign to aiden if the issue mentions "acp", "zed", or model performance issues
|
|
||||||
|
|
||||||
## Breakdown of Labels:
|
|
||||||
|
|
||||||
### nix
|
|
||||||
|
|
||||||
Any issue that mentions nix, or nixos should have a nix label
|
|
||||||
|
|
||||||
### opentui
|
|
||||||
|
|
||||||
Anything relating to the TUI itself should have an opentui label
|
|
||||||
|
|
||||||
### perf
|
|
||||||
|
|
||||||
Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label
|
|
||||||
|
|
||||||
### desktop
|
|
||||||
|
|
||||||
Anything related to `opencode web` command or the desktop app should have a desktop label. Never add this label for anything terminal/tui related
|
|
||||||
|
|
||||||
### zen
|
|
||||||
|
|
||||||
Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label
|
|
||||||
|
|
||||||
### docs
|
|
||||||
|
|
||||||
Anything related to the documentation should have a docs label
|
|
||||||
|
|
||||||
### windows
|
|
||||||
|
|
||||||
Use for any issue that involves the windows OS
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"format_on_save": "on",
|
||||||
|
"formatter": {
|
||||||
|
"external": {
|
||||||
|
"command": "bunx",
|
||||||
|
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ If you are unsure if a PR would be accepted, feel free to ask a maintainer or lo
|
|||||||
|
|
||||||
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
|
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
|
||||||
|
|
||||||
|
## Adding New Providers
|
||||||
|
|
||||||
|
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
|
||||||
|
https://github.com/anomalyco/models.dev
|
||||||
|
|
||||||
## Developing OpenCode
|
## Developing OpenCode
|
||||||
|
|
||||||
- Requirements: Bun 1.3+
|
- Requirements: Bun 1.3+
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -32,7 +32,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai">
|
||||||
|
<picture>
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||||
|
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">AI-агент для програмування з відкритим кодом.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||||
|
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||||
|
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md">English</a> |
|
||||||
|
<a href="README.zh.md">简体中文</a> |
|
||||||
|
<a href="README.zht.md">繁體中文</a> |
|
||||||
|
<a href="README.ko.md">한국어</a> |
|
||||||
|
<a href="README.de.md">Deutsch</a> |
|
||||||
|
<a href="README.es.md">Español</a> |
|
||||||
|
<a href="README.fr.md">Français</a> |
|
||||||
|
<a href="README.it.md">Italiano</a> |
|
||||||
|
<a href="README.da.md">Dansk</a> |
|
||||||
|
<a href="README.ja.md">日本語</a> |
|
||||||
|
<a href="README.pl.md">Polski</a> |
|
||||||
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
|
<a href="README.ar.md">العربية</a> |
|
||||||
|
<a href="README.no.md">Norsk</a> |
|
||||||
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://opencode.ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Встановлення
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# YOLO
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
# Менеджери пакетів
|
||||||
|
npm i -g opencode-ai@latest # або bun/pnpm/yarn
|
||||||
|
scoop install opencode # Windows
|
||||||
|
choco install opencode # Windows
|
||||||
|
brew install anomalyco/tap/opencode # macOS і Linux (рекомендовано, завжди актуально)
|
||||||
|
brew install opencode # macOS і Linux (офіційна формула Homebrew, оновлюється рідше)
|
||||||
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
|
mise use -g opencode # Будь-яка ОС
|
||||||
|
nix run nixpkgs#opencode # або github:anomalyco/opencode для найновішої dev-гілки
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Перед встановленням видаліть версії старші за 0.1.x.
|
||||||
|
|
||||||
|
### Десктопний застосунок (BETA)
|
||||||
|
|
||||||
|
OpenCode також доступний як десктопний застосунок. Завантажуйте напряму зі [сторінки релізів](https://github.com/anomalyco/opencode/releases) або [opencode.ai/download](https://opencode.ai/download).
|
||||||
|
|
||||||
|
| Платформа | Завантаження |
|
||||||
|
| --------------------- | ------------------------------------- |
|
||||||
|
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||||
|
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||||
|
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||||
|
| Linux | `.deb`, `.rpm` або AppImage |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew)
|
||||||
|
brew install --cask opencode-desktop
|
||||||
|
# Windows (Scoop)
|
||||||
|
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Каталог встановлення
|
||||||
|
|
||||||
|
Скрипт встановлення дотримується такого порядку пріоритету для шляху встановлення:
|
||||||
|
|
||||||
|
1. `$OPENCODE_INSTALL_DIR` - Користувацький каталог встановлення
|
||||||
|
2. `$XDG_BIN_DIR` - Шлях, сумісний зі специфікацією XDG Base Directory
|
||||||
|
3. `$HOME/bin` - Стандартний каталог користувацьких бінарників (якщо існує або його можна створити)
|
||||||
|
4. `$HOME/.opencode/bin` - Резервний варіант за замовчуванням
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Приклади
|
||||||
|
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Агенти
|
||||||
|
|
||||||
|
OpenCode містить два вбудовані агенти, між якими можна перемикатися клавішею `Tab`.
|
||||||
|
|
||||||
|
- **build** - Агент за замовчуванням із повним доступом для завдань розробки
|
||||||
|
- **plan** - Агент лише для читання для аналізу та дослідження коду
|
||||||
|
- За замовчуванням забороняє редагування файлів
|
||||||
|
- Запитує дозвіл перед запуском bash-команд
|
||||||
|
- Ідеально підходить для дослідження незнайомих кодових баз або планування змін
|
||||||
|
|
||||||
|
Також доступний допоміжний агент **general** для складного пошуку та багатокрокових завдань.
|
||||||
|
Він використовується всередині системи й може бути викликаний у повідомленнях через `@general`.
|
||||||
|
|
||||||
|
Дізнайтеся більше про [agents](https://opencode.ai/docs/agents).
|
||||||
|
|
||||||
|
### Документація
|
||||||
|
|
||||||
|
Щоб дізнатися більше про налаштування OpenCode, [**перейдіть до нашої документації**](https://opencode.ai/docs).
|
||||||
|
|
||||||
|
### Внесок
|
||||||
|
|
||||||
|
Якщо ви хочете зробити внесок в OpenCode, будь ласка, прочитайте нашу [документацію для контриб'юторів](./CONTRIBUTING.md) перед надсиланням pull request.
|
||||||
|
|
||||||
|
### Проєкти на базі OpenCode
|
||||||
|
|
||||||
|
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
|
||||||
|
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
|
||||||
|
|
||||||
|
### FAQ
|
||||||
|
|
||||||
|
#### Чим це відрізняється від Claude Code?
|
||||||
|
|
||||||
|
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
|
||||||
|
|
||||||
|
- 100% open source
|
||||||
|
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення.
|
||||||
|
- Підтримка LSP з коробки
|
||||||
|
- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі.
|
||||||
|
- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a>
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Security
|
# Security
|
||||||
|
|
||||||
|
## IMPORTANT
|
||||||
|
|
||||||
|
We do not accept AI generated security reports. We receive a large number of
|
||||||
|
these and we absolutely do not have the resources to review them all. If you
|
||||||
|
submit one that will be an automatic ban from the project.
|
||||||
|
|
||||||
## Threat Model
|
## Threat Model
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|||||||
Vendored
+1
@@ -2,6 +2,7 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* deno-fmt-ignore-file */
|
/* deno-fmt-ignore-file */
|
||||||
|
/* biome-ignore-all lint: auto-generated */
|
||||||
|
|
||||||
/// <reference path="../sst-env.d.ts" />
|
/// <reference path="../sst-env.d.ts" />
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -145,6 +145,16 @@ const ZEN_MODELS = [
|
|||||||
new sst.Secret("ZEN_MODELS18"),
|
new sst.Secret("ZEN_MODELS18"),
|
||||||
new sst.Secret("ZEN_MODELS19"),
|
new sst.Secret("ZEN_MODELS19"),
|
||||||
new sst.Secret("ZEN_MODELS20"),
|
new sst.Secret("ZEN_MODELS20"),
|
||||||
|
new sst.Secret("ZEN_MODELS21"),
|
||||||
|
new sst.Secret("ZEN_MODELS22"),
|
||||||
|
new sst.Secret("ZEN_MODELS23"),
|
||||||
|
new sst.Secret("ZEN_MODELS24"),
|
||||||
|
new sst.Secret("ZEN_MODELS25"),
|
||||||
|
new sst.Secret("ZEN_MODELS26"),
|
||||||
|
new sst.Secret("ZEN_MODELS27"),
|
||||||
|
new sst.Secret("ZEN_MODELS28"),
|
||||||
|
new sst.Secret("ZEN_MODELS29"),
|
||||||
|
new sst.Secret("ZEN_MODELS30"),
|
||||||
]
|
]
|
||||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||||
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
||||||
@@ -204,9 +214,9 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
},
|
},
|
||||||
transform: {
|
transform: {
|
||||||
server: {
|
server: {
|
||||||
|
placement: { region: "aws:us-east-1" },
|
||||||
transform: {
|
transform: {
|
||||||
worker: {
|
worker: {
|
||||||
placement: { mode: "smart" },
|
|
||||||
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-5pgd2xuvIIkTbIOGIdK5MIXo6O9qRpvk1RKQZ1e1R+8=",
|
"x86_64-linux": "sha256-fjrvCgQ2PHYxzw8NsiEHOcor46qN95/cfilFHFqCp/k=",
|
||||||
"aarch64-linux": "sha256-FZiHwihM4b82ipQ9XfW08X+sd5CvZhx/+pU/8X1zsns=",
|
"aarch64-linux": "sha256-xWp4LLJrbrCPFL1F6SSbProq/t/az4CqhTcymPvjOBQ=",
|
||||||
"aarch64-darwin": "sha256-iZv0w1NthV53pY5uvuf3JlI14GeKmCu7WHwGSRdEQeM=",
|
"aarch64-darwin": "sha256-Wbfyy/bruFHKUWsyJ2aiPXAzLkk5MNBfN6QdGPQwZS0=",
|
||||||
"x86_64-darwin": "sha256-c3Zm3P1goFPgg3vNAZPMFOhHX/gyTmsCN/PKbGO/v0E="
|
"x86_64-darwin": "sha256-wDnMbiaBCRj5STkaLoVCZTdXVde+/YKfwWzwJZ1AJXQ="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -69,10 +69,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/artifact": "5.0.1",
|
"@actions/artifact": "5.0.1",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
|
"@types/mime-types": "3.0.1",
|
||||||
|
"glob": "13.0.5",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
"sst": "3.17.23",
|
"sst": "3.18.10",
|
||||||
"turbo": "2.5.6"
|
"turbo": "2.5.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -332,6 +332,163 @@ export async function withSession<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seedSystem = [
|
||||||
|
"You are seeding deterministic e2e UI state.",
|
||||||
|
"Follow the user's instruction exactly.",
|
||||||
|
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
|
||||||
|
"Do not call any extra tools.",
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
|
||||||
|
const timeout = input.timeout ?? 30_000
|
||||||
|
const end = Date.now() + timeout
|
||||||
|
while (Date.now() < end) {
|
||||||
|
const value = await input.probe()
|
||||||
|
if (value !== undefined) return value
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seed = async <T>(input: {
|
||||||
|
sessionID: string
|
||||||
|
prompt: string
|
||||||
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
probe: () => Promise<T | undefined>
|
||||||
|
timeout?: number
|
||||||
|
attempts?: number
|
||||||
|
}) => {
|
||||||
|
for (let i = 0; i < (input.attempts ?? 2); i++) {
|
||||||
|
await input.sdk.session.promptAsync({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: "build",
|
||||||
|
system: seedSystem,
|
||||||
|
parts: [{ type: "text", text: input.prompt }],
|
||||||
|
})
|
||||||
|
const value = await wait({ probe: input.probe, timeout: input.timeout })
|
||||||
|
if (value !== undefined) return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSessionQuestion(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
questions: Array<{
|
||||||
|
header: string
|
||||||
|
question: string
|
||||||
|
options: Array<{ label: string; description: string }>
|
||||||
|
multiple?: boolean
|
||||||
|
custom?: boolean
|
||||||
|
}>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const first = input.questions[0]
|
||||||
|
if (!first) throw new Error("Question seed requires at least one question")
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one question tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
"After calling the tool, wait for the user response.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const list = await sdk.question.list().then((x) => x.data ?? [])
|
||||||
|
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding question request")
|
||||||
|
return { id: result.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSessionPermission(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
permission: string
|
||||||
|
patterns: string[]
|
||||||
|
description?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one bash tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({
|
||||||
|
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
|
||||||
|
workdir: "/",
|
||||||
|
description: input.description ?? `seed ${input.permission} permission request`,
|
||||||
|
})}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const list = await sdk.permission.list().then((x) => x.data ?? [])
|
||||||
|
return list.find((item) => item.sessionID === input.sessionID)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding permission request")
|
||||||
|
return { id: result.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSessionTodos(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
todos: Array<{ content: string; status: string; priority: string }>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one todowrite tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n")
|
||||||
|
const target = JSON.stringify(input.todos)
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
|
||||||
|
if (JSON.stringify(todos) !== target) return
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding todos")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const [questions, permissions] = await Promise.all([
|
||||||
|
sdk.question.list().then((x) => x.data ?? []),
|
||||||
|
sdk.permission.list().then((x) => x.data ?? []),
|
||||||
|
])
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...questions
|
||||||
|
.filter((item) => item.sessionID === sessionID)
|
||||||
|
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
|
||||||
|
...permissions
|
||||||
|
.filter((item) => item.sessionID === sessionID)
|
||||||
|
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
|
||||||
|
])
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export async function openStatusPopover(page: Page) {
|
export async function openStatusPopover(page: Page) {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
await expect(prompt).not.toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+L")
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
const expanded = async (el: { getAttribute: (name: string) => Promise<string | null> }) => {
|
||||||
|
const value = await el.getAttribute("aria-expanded")
|
||||||
|
if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`)
|
||||||
|
return value === "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
||||||
|
await expect(treeToggle).toBeVisible()
|
||||||
|
if (await expanded(treeToggle)) await treeToggle.click()
|
||||||
|
await expect(treeToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
|
const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first()
|
||||||
|
await expect(reviewToggle).toBeVisible()
|
||||||
|
if (await expanded(reviewToggle)) await reviewToggle.click()
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(page.locator("#review-panel")).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
test("mod+w closes the active file tab", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await dialog.getByRole("textbox").first().fill("package.json")
|
||||||
|
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||||
|
await expect(item).toBeVisible({ timeout: 30_000 })
|
||||||
|
await item.click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: "package.json" }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+W`)
|
||||||
|
await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -28,7 +28,6 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
|||||||
const key = await target.getAttribute("data-key")
|
const key = await target.getAttribute("data-key")
|
||||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||||
|
|
||||||
const name = (await target.locator("span").first().innerText()).trim()
|
|
||||||
const model = key.split(":").slice(1).join(":")
|
const model = key.split(":").slice(1).join(":")
|
||||||
|
|
||||||
await input.fill(model)
|
await input.fill(model)
|
||||||
@@ -37,6 +36,13 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
|||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
const form = page.locator(promptSelector).locator("xpath=ancestor::form[1]")
|
await page.locator(promptSelector).click()
|
||||||
await expect(form.locator('[data-component="button"]').filter({ hasText: name }).first()).toBeVisible()
|
await page.keyboard.type("/model")
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialogAgain = page.getByRole("dialog")
|
||||||
|
await expect(dialogAgain).toBeVisible()
|
||||||
|
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { defocus, createTestProject, cleanupTestProject } from "../actions"
|
import {
|
||||||
import { projectSwitchSelector } from "../selectors"
|
defocus,
|
||||||
import { dirSlug } from "../utils"
|
createTestProject,
|
||||||
|
cleanupTestProject,
|
||||||
|
openSidebar,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
} from "../actions"
|
||||||
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
|
import { createSdk, dirSlug } from "../utils"
|
||||||
|
|
||||||
|
function slugFromUrl(url: string) {
|
||||||
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
@@ -33,3 +45,94 @@ test("can switch between projects from sidebar", async ({ page, withProject }) =
|
|||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
const stamp = Date.now()
|
||||||
|
let rootDir: string | undefined
|
||||||
|
let workspaceDir: string | undefined
|
||||||
|
let sessionID: string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withProject(
|
||||||
|
async ({ directory, slug }) => {
|
||||||
|
rootDir = directory
|
||||||
|
await defocus(page)
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const next = slugFromUrl(page.url())
|
||||||
|
if (!next) return ""
|
||||||
|
if (next === slug) return ""
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
|
||||||
|
const workspaceSlug = slugFromUrl(page.url())
|
||||||
|
workspaceDir = base64Decode(workspaceSlug)
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||||
|
await expect(workspace).toBeVisible()
|
||||||
|
await workspace.hover()
|
||||||
|
|
||||||
|
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
|
||||||
|
await expect(newSession).toBeVisible()
|
||||||
|
await newSession.click({ force: true })
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await prompt.fill(`project switch remembers workspace ${stamp}`)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
const created = sessionIDFromUrl(page.url())
|
||||||
|
if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
|
||||||
|
sessionID = created
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click()
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
|
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
|
await expect(rootButton).toBeVisible()
|
||||||
|
await rootButton.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (sessionID) {
|
||||||
|
const id = sessionID
|
||||||
|
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
|
||||||
|
await Promise.all(
|
||||||
|
dirs.map((directory) =>
|
||||||
|
createSdk(directory)
|
||||||
|
.session.delete({ sessionID: id })
|
||||||
|
.catch(() => undefined),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (workspaceDir) {
|
||||||
|
await cleanupTestProject(workspaceDir)
|
||||||
|
}
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
|
||||||
|
const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
|
||||||
|
const dt = await page.evaluateHandle((text) => {
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
dt.setData("text/plain", text)
|
||||||
|
return dt
|
||||||
|
}, `file:${path}`)
|
||||||
|
|
||||||
|
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
|
||||||
|
|
||||||
|
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
|
||||||
|
await expect(pill).toBeVisible()
|
||||||
|
await expect(pill).toHaveAttribute("data-path", path)
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
|
||||||
|
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
|
||||||
|
const dt = await page.evaluateHandle((b64) => {
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
|
||||||
|
const file = new File([bytes], "drop.png", { type: "image/png" })
|
||||||
|
dt.items.add(file)
|
||||||
|
return dt
|
||||||
|
}, png)
|
||||||
|
|
||||||
|
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
|
||||||
|
|
||||||
|
const img = page.locator('img[alt="drop.png"]').first()
|
||||||
|
await expect(img).toBeVisible()
|
||||||
|
|
||||||
|
const remove = page.getByRole("button", { name: "Remove attachment" }).first()
|
||||||
|
await expect(remove).toBeVisible()
|
||||||
|
|
||||||
|
await img.hover()
|
||||||
|
await remove.click()
|
||||||
|
await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("line one")
|
||||||
|
await page.keyboard.press("Shift+Enter")
|
||||||
|
await page.keyboard.type("line two")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
await expect(prompt).toContainText("line one")
|
||||||
|
await expect(prompt).toContainText("line two")
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const terminal = page.locator(terminalSelector)
|
||||||
|
|
||||||
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/terminal")
|
||||||
|
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/terminal")
|
||||||
|
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await expect(terminal).not.toBeVisible()
|
||||||
|
})
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
export const promptSelector = '[data-component="prompt-input"]'
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
export const terminalSelector = '[data-component="terminal"]'
|
export const terminalSelector = '[data-component="terminal"]'
|
||||||
|
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||||
|
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||||
|
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||||
|
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
|
||||||
|
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
|
||||||
|
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
|
||||||
|
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
|
||||||
|
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
|
||||||
|
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
||||||
|
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
||||||
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||||
@@ -30,6 +40,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
|||||||
|
|
||||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectClearNotificationsSelector = (slug: string) =>
|
||||||
|
`[data-action="project-clear-notifications"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||||
|
import {
|
||||||
|
permissionDockSelector,
|
||||||
|
promptSelector,
|
||||||
|
questionDockSelector,
|
||||||
|
sessionComposerDockSelector,
|
||||||
|
sessionTodoDockSelector,
|
||||||
|
sessionTodoListSelector,
|
||||||
|
sessionTodoToggleButtonSelector,
|
||||||
|
} from "../selectors"
|
||||||
|
|
||||||
|
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||||
|
|
||||||
|
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
|
||||||
|
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||||
|
if (!session?.id) throw new Error("Session create did not return an id")
|
||||||
|
return fn(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await expect(page.locator(promptSelector)).toBeFocused()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [
|
||||||
|
{ label: "Continue", description: "Continue now" },
|
||||||
|
{ label: "Stop", description: "Stop here" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionPermission(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["README.md"],
|
||||||
|
description: "Need permission for command",
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(permissionDockSelector)
|
||||||
|
.getByRole("button", { name: /allow once/i })
|
||||||
|
.click()
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionPermission(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["REJECT.md"],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionPermission(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["README.md"],
|
||||||
|
description: "Need permission for command",
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(permissionDockSelector)
|
||||||
|
.getByRole("button", { name: /allow always/i })
|
||||||
|
.click()
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionTodos(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
todos: [
|
||||||
|
{ content: "first task", status: "pending", priority: "high" },
|
||||||
|
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||||
|
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
|
||||||
|
|
||||||
|
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||||
|
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await seedSessionTodos(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
todos: [
|
||||||
|
{ content: "first task", status: "completed", priority: "high" },
|
||||||
|
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [{ label: "Continue", description: "Continue now" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
await page.keyboard.type("abc")
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.3",
|
"version": "1.2.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
+37
-64
@@ -1,35 +1,36 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
|
|
||||||
import { Router, Route, Navigate } from "@solidjs/router"
|
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
|
||||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
|
||||||
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
|
||||||
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
|
||||||
import { Diff } from "@opencode-ai/ui/diff"
|
|
||||||
import { Code } from "@opencode-ai/ui/code"
|
import { Code } from "@opencode-ai/ui/code"
|
||||||
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
|
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
||||||
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
||||||
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
|
import { Diff } from "@opencode-ai/ui/diff"
|
||||||
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
import { Navigate, Route, Router } from "@solidjs/router"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||||
|
import { CommandProvider } from "@/context/command"
|
||||||
|
import { CommentsProvider } from "@/context/comments"
|
||||||
|
import { FileProvider } from "@/context/file"
|
||||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||||
import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server"
|
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||||
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
|
import { LanguageProvider, useLanguage } from "@/context/language"
|
||||||
|
import { LayoutProvider } from "@/context/layout"
|
||||||
|
import { ModelsProvider } from "@/context/models"
|
||||||
|
import { NotificationProvider } from "@/context/notification"
|
||||||
|
import { PermissionProvider } from "@/context/permission"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { PromptProvider } from "@/context/prompt"
|
||||||
|
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TerminalProvider } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
|
||||||
import { FileProvider } from "@/context/file"
|
|
||||||
import { CommentsProvider } from "@/context/comments"
|
|
||||||
import { NotificationProvider } from "@/context/notification"
|
|
||||||
import { ModelsProvider } from "@/context/models"
|
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { CommandProvider } from "@/context/command"
|
|
||||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
|
||||||
import Layout from "@/pages/layout"
|
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
const Loading = () => <div class="size-full" />
|
const Loading = () => <div class="size-full" />
|
||||||
@@ -57,7 +58,11 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
|
__OPENCODE__?: {
|
||||||
|
updaterEnabled?: boolean
|
||||||
|
deepLinks?: string[]
|
||||||
|
wsl?: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,30 +112,6 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
|
|
||||||
if (platform.platform !== "web") return
|
|
||||||
const result = platform.getDefaultServerUrl?.()
|
|
||||||
if (result instanceof Promise) return
|
|
||||||
if (!result) return
|
|
||||||
return normalizeServerUrl(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveDefaultServerUrl = (props: {
|
|
||||||
defaultUrl?: string
|
|
||||||
storedDefaultServerUrl?: string
|
|
||||||
hostname: string
|
|
||||||
origin: string
|
|
||||||
isDev: boolean
|
|
||||||
devHost?: string
|
|
||||||
devPort?: string
|
|
||||||
}) => {
|
|
||||||
if (props.defaultUrl) return props.defaultUrl
|
|
||||||
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
|
|
||||||
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
|
||||||
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
|
|
||||||
return props.origin
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps) {
|
export function AppBaseProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
@@ -157,27 +138,19 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
function ServerKey(props: ParentProps) {
|
function ServerKey(props: ParentProps) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
return (
|
return (
|
||||||
<Show when={server.url} keyed>
|
<Show when={server.key} keyed>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
export function AppInterface(props: {
|
||||||
const platform = usePlatform()
|
children?: JSX.Element
|
||||||
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
|
defaultServer: ServerConnection.Key
|
||||||
const defaultServerUrl = resolveDefaultServerUrl({
|
servers?: Array<ServerConnection.Any>
|
||||||
defaultUrl: props.defaultUrl,
|
}) {
|
||||||
storedDefaultServerUrl,
|
|
||||||
hostname: location.hostname,
|
|
||||||
origin: window.location.origin,
|
|
||||||
isDev: import.meta.env.DEV,
|
|
||||||
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
|
|
||||||
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
|
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
}}
|
}}
|
||||||
modal={false}
|
modal={false}
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={8}
|
gutter={4}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { normalizeServerUrl, useServer } from "@/context/server"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||||
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { ServerRow } from "@/components/server/server-row"
|
import { ServerRow } from "@/components/server/server-row"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
interface AddRowProps {
|
interface AddRowProps {
|
||||||
@@ -89,7 +88,7 @@ function useServerPreview(fetcher: typeof fetch) {
|
|||||||
if (!looksComplete(value)) return
|
if (!looksComplete(value)) return
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
if (!normalized) return
|
if (!normalized) return
|
||||||
const result = await checkServerHealth(normalized, fetcher)
|
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||||
setStatus(result.healthy)
|
setStatus(result.healthy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,14 +170,13 @@ export function DialogSelectServer() {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const globalSDK = useGlobalSDK()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||||
const { previewStatus } = useServerPreview(fetcher)
|
const { previewStatus } = useServerPreview(fetcher)
|
||||||
let listRoot: HTMLDivElement | undefined
|
let listRoot: HTMLDivElement | undefined
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<string, ServerHealth | undefined>,
|
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
url: "",
|
url: "",
|
||||||
adding: false,
|
adding: false,
|
||||||
@@ -214,24 +212,25 @@ export function DialogSelectServer() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceServer = (original: string, next: string) => {
|
const replaceServer = (original: ServerConnection.Http, next: string) => {
|
||||||
const active = server.url
|
const active = server.key
|
||||||
const nextActive = active === original ? next : active
|
const newConn = server.add(next)
|
||||||
|
if (!newConn) return
|
||||||
|
|
||||||
server.add(next)
|
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
|
||||||
if (nextActive) server.setActive(nextActive)
|
if (nextActive) server.setActive(nextActive)
|
||||||
server.remove(original)
|
server.remove(ServerConnection.key(original))
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = createMemo(() => {
|
const items = createMemo(() => {
|
||||||
const current = server.url
|
const current = server.current
|
||||||
const list = server.list
|
const list = server.list
|
||||||
if (!current) return list
|
if (!current) return list
|
||||||
if (!list.includes(current)) return [current, ...list]
|
if (!list.includes(current)) return [current, ...list]
|
||||||
return [current, ...list.filter((x) => x !== current)]
|
return [current, ...list.filter((x) => x !== current)]
|
||||||
})
|
})
|
||||||
|
|
||||||
const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
|
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
|
||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const list = items()
|
const list = items()
|
||||||
@@ -246,17 +245,17 @@ export function DialogSelectServer() {
|
|||||||
return list.slice().sort((a, b) => {
|
return list.slice().sort((a, b) => {
|
||||||
if (a === active) return -1
|
if (a === active) return -1
|
||||||
if (b === active) return 1
|
if (b === active) return 1
|
||||||
const diff = rank(store.status[a]) - rank(store.status[b])
|
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
|
||||||
if (diff !== 0) return diff
|
if (diff !== 0) return diff
|
||||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function refreshHealth() {
|
async function refreshHealth() {
|
||||||
const results: Record<string, ServerHealth> = {}
|
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items().map(async (url) => {
|
items().map(async (conn) => {
|
||||||
results[url] = await checkServerHealth(url, fetcher)
|
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("status", reconcile(results))
|
setStore("status", reconcile(results))
|
||||||
@@ -269,15 +268,15 @@ export function DialogSelectServer() {
|
|||||||
onCleanup(() => clearInterval(interval))
|
onCleanup(() => clearInterval(interval))
|
||||||
})
|
})
|
||||||
|
|
||||||
async function select(value: string, persist?: boolean) {
|
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||||
if (!persist && store.status[value]?.healthy === false) return
|
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
||||||
dialog.close()
|
dialog.close()
|
||||||
if (persist) {
|
if (persist) {
|
||||||
server.add(value)
|
server.add(conn.http.url)
|
||||||
navigate("/")
|
navigate("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
server.setActive(value)
|
server.setActive(ServerConnection.key(conn))
|
||||||
navigate("/")
|
navigate("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +310,7 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
setStore("addServer", { adding: true, error: "" })
|
setStore("addServer", { adding: true, error: "" })
|
||||||
|
|
||||||
const result = await checkServerHealth(normalized, fetcher)
|
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||||
setStore("addServer", { adding: false })
|
setStore("addServer", { adding: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
@@ -320,25 +319,25 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
await select(normalized, true)
|
await select({ type: "http", http: { url: normalized } }, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEdit(original: string, value: string) {
|
async function handleEdit(original: ServerConnection.Any, value: string) {
|
||||||
if (store.editServer.busy) return
|
if (store.editServer.busy || original.type !== "http") return
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
resetEdit()
|
resetEdit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized === original) {
|
if (normalized === original.http.url) {
|
||||||
resetEdit()
|
resetEdit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("editServer", { busy: true, error: "" })
|
setStore("editServer", { busy: true, error: "" })
|
||||||
|
|
||||||
const result = await checkServerHealth(normalized, fetcher)
|
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||||
setStore("editServer", { busy: false })
|
setStore("editServer", { busy: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
@@ -366,7 +365,7 @@ export function DialogSelectServer() {
|
|||||||
handleAdd(store.addServer.url)
|
handleAdd(store.addServer.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditKey = (event: KeyboardEvent, original: string) => {
|
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -378,7 +377,7 @@ export function DialogSelectServer() {
|
|||||||
handleEdit(original, store.editServer.value)
|
handleEdit(original, store.editServer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemove(url: string) {
|
async function handleRemove(url: ServerConnection.Key) {
|
||||||
server.remove(url)
|
server.remove(url)
|
||||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
if ((await platform.getDefaultServerUrl?.()) === url) {
|
||||||
platform.setDefaultServerUrl?.(null)
|
platform.setDefaultServerUrl?.(null)
|
||||||
@@ -390,11 +389,14 @@ export function DialogSelectServer() {
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div ref={(el) => (listRoot = el)}>
|
<div ref={(el) => (listRoot = el)}>
|
||||||
<List
|
<List
|
||||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
search={{
|
||||||
|
placeholder: language.t("dialog.server.search.placeholder"),
|
||||||
|
autofocus: false,
|
||||||
|
}}
|
||||||
noInitialSelection
|
noInitialSelection
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
items={sortedItems}
|
items={sortedItems}
|
||||||
key={(x) => x}
|
key={(x) => x.http.url}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x) select(x)
|
if (x) select(x)
|
||||||
}}
|
}}
|
||||||
@@ -425,10 +427,11 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
|
const key = ServerConnection.key(i)
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||||
<Show
|
<Show
|
||||||
when={store.editServer.id !== i}
|
when={store.editServer.id !== i.http.url}
|
||||||
fallback={
|
fallback={
|
||||||
<EditRow
|
<EditRow
|
||||||
value={store.editServer.value}
|
value={store.editServer.value}
|
||||||
@@ -443,12 +446,12 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
url={i}
|
conn={i}
|
||||||
status={store.status[i]}
|
status={store.status[key]}
|
||||||
dimmed={store.status[i]?.healthy === false}
|
dimmed={store.status[key]?.healthy === false}
|
||||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={defaultUrl() === i}>
|
<Show when={defaultUrl() === i.http.url}>
|
||||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -456,59 +459,63 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={store.editServer.id !== i}>
|
<Show when={store.editServer.id !== i.http.url}>
|
||||||
<div class="flex items-center justify-center gap-5 pl-4">
|
<div class="flex items-center justify-center gap-5 pl-4">
|
||||||
<Show when={current() === i}>
|
<Show when={ServerConnection.key(current()) === key}>
|
||||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<DropdownMenu>
|
<Show when={i.type === "http"}>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu>
|
||||||
as={IconButton}
|
<DropdownMenu.Trigger
|
||||||
icon="dot-grid"
|
as={IconButton}
|
||||||
variant="ghost"
|
icon="dot-grid"
|
||||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
variant="ghost"
|
||||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||||
/>
|
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||||
<DropdownMenu.Portal>
|
/>
|
||||||
<DropdownMenu.Content class="mt-1">
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Content class="mt-1">
|
||||||
onSelect={() => {
|
<DropdownMenu.Item
|
||||||
setStore("editServer", {
|
onSelect={() => {
|
||||||
id: i,
|
setStore("editServer", {
|
||||||
value: i,
|
id: i.http.url,
|
||||||
error: "",
|
value: i.http.url,
|
||||||
status: store.status[i]?.healthy,
|
error: "",
|
||||||
})
|
status: store.status[ServerConnection.key(i)]?.healthy,
|
||||||
}}
|
})
|
||||||
>
|
}}
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
>
|
||||||
</DropdownMenu.Item>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
<Show when={canDefault() && defaultUrl() !== i}>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(i)}>
|
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
||||||
|
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.default")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
||||||
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => handleRemove(ServerConnection.key(i))}
|
||||||
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.delete")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</DropdownMenu.Content>
|
||||||
<Show when={canDefault() && defaultUrl() === i}>
|
</DropdownMenu.Portal>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
</DropdownMenu>
|
||||||
<DropdownMenu.ItemLabel>
|
</Show>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</Show>
|
|
||||||
<DropdownMenu.Separator />
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => handleRemove(i)}
|
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
const MAX_DEPTH = 128
|
||||||
|
|
||||||
function pathToFileUrl(filepath: string): string {
|
function pathToFileUrl(filepath: string): string {
|
||||||
return `file://${encodeFilePath(filepath)}`
|
return `file://${encodeFilePath(filepath)}`
|
||||||
}
|
}
|
||||||
@@ -69,13 +71,13 @@ const kindLabel = (kind: Kind) => {
|
|||||||
const kindTextColor = (kind: Kind) => {
|
const kindTextColor = (kind: Kind) => {
|
||||||
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
||||||
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
||||||
return "color: var(--icon-warning-active)"
|
return "color: var(--icon-diff-modified-base)"
|
||||||
}
|
}
|
||||||
|
|
||||||
const kindDotColor = (kind: Kind) => {
|
const kindDotColor = (kind: Kind) => {
|
||||||
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
||||||
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
||||||
return "background-color: var(--icon-warning-active)"
|
return "background-color: var(--icon-diff-modified-base)"
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||||
@@ -260,12 +262,20 @@ export default function FileTree(props: {
|
|||||||
_marks?: Set<string>
|
_marks?: Set<string>
|
||||||
_deeps?: Map<string, number>
|
_deeps?: Map<string, number>
|
||||||
_kinds?: ReadonlyMap<string, Kind>
|
_kinds?: ReadonlyMap<string, Kind>
|
||||||
|
_chain?: readonly string[]
|
||||||
}) {
|
}) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const level = props.level ?? 0
|
const level = props.level ?? 0
|
||||||
const draggable = () => props.draggable ?? true
|
const draggable = () => props.draggable ?? true
|
||||||
const tooltip = () => props.tooltip ?? true
|
const tooltip = () => props.tooltip ?? true
|
||||||
|
|
||||||
|
const key = (p: string) =>
|
||||||
|
file
|
||||||
|
.normalize(p)
|
||||||
|
.replace(/[\\/]+$/, "")
|
||||||
|
.replaceAll("\\", "/")
|
||||||
|
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
|
||||||
|
|
||||||
const filter = createMemo(() => {
|
const filter = createMemo(() => {
|
||||||
if (props._filter) return props._filter
|
if (props._filter) return props._filter
|
||||||
|
|
||||||
@@ -307,23 +317,45 @@ export default function FileTree(props: {
|
|||||||
|
|
||||||
const out = new Map<string, number>()
|
const out = new Map<string, number>()
|
||||||
|
|
||||||
const visit = (dir: string, lvl: number): number => {
|
const root = props.path
|
||||||
const expanded = file.tree.state(dir)?.expanded ?? false
|
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
||||||
if (!expanded) return -1
|
|
||||||
|
|
||||||
const nodes = file.tree.children(dir)
|
const seen = new Set<string>()
|
||||||
const max = nodes.reduce((max, node) => {
|
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
||||||
if (node.type !== "directory") return max
|
|
||||||
const open = file.tree.state(node.path)?.expanded ?? false
|
|
||||||
if (!open) return max
|
|
||||||
return Math.max(max, visit(node.path, lvl + 1))
|
|
||||||
}, lvl)
|
|
||||||
|
|
||||||
out.set(dir, max)
|
const push = (dir: string, lvl: number) => {
|
||||||
return max
|
const id = key(dir)
|
||||||
|
if (seen.has(id)) return
|
||||||
|
seen.add(id)
|
||||||
|
|
||||||
|
const kids = file.tree
|
||||||
|
.children(dir)
|
||||||
|
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
|
||||||
|
.map((node) => node.path)
|
||||||
|
|
||||||
|
stack.push({ dir, lvl, i: 0, kids, max: lvl })
|
||||||
|
}
|
||||||
|
|
||||||
|
push(root, level - 1)
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const top = stack[stack.length - 1]!
|
||||||
|
|
||||||
|
if (top.i < top.kids.length) {
|
||||||
|
const next = top.kids[top.i]!
|
||||||
|
top.i++
|
||||||
|
push(next, top.lvl + 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out.set(top.dir, top.max)
|
||||||
|
stack.pop()
|
||||||
|
|
||||||
|
const parent = stack[stack.length - 1]
|
||||||
|
if (!parent) continue
|
||||||
|
parent.max = Math.max(parent.max, top.max)
|
||||||
}
|
}
|
||||||
|
|
||||||
visit(props.path, level - 1)
|
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -415,12 +447,13 @@ export default function FileTree(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
<div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
||||||
<For each={nodes()}>
|
<For each={nodes()}>
|
||||||
{(node) => {
|
{(node) => {
|
||||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||||
const deep = () => deeps().get(node.path) ?? -1
|
const deep = () => deeps().get(node.path) ?? -1
|
||||||
const kind = () => visibleKind(node, kinds(), marks())
|
const kind = () => visibleKind(node, kinds(), marks())
|
||||||
|
const active = () => !!kind() && !node.ignored
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -459,21 +492,27 @@ export default function FileTree(props: {
|
|||||||
}}
|
}}
|
||||||
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
|
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
|
||||||
/>
|
/>
|
||||||
<FileTree
|
<Show
|
||||||
path={node.path}
|
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
||||||
level={level + 1}
|
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
||||||
allowed={props.allowed}
|
>
|
||||||
modified={props.modified}
|
<FileTree
|
||||||
kinds={props.kinds}
|
path={node.path}
|
||||||
active={props.active}
|
level={level + 1}
|
||||||
draggable={props.draggable}
|
allowed={props.allowed}
|
||||||
tooltip={props.tooltip}
|
modified={props.modified}
|
||||||
onFileClick={props.onFileClick}
|
kinds={props.kinds}
|
||||||
_filter={filter()}
|
active={props.active}
|
||||||
_marks={marks()}
|
draggable={props.draggable}
|
||||||
_deeps={deeps()}
|
tooltip={props.tooltip}
|
||||||
_kinds={kinds()}
|
onFileClick={props.onFileClick}
|
||||||
/>
|
_filter={filter()}
|
||||||
|
_marks={marks()}
|
||||||
|
_deeps={deeps()}
|
||||||
|
_kinds={kinds()}
|
||||||
|
_chain={chain}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -492,7 +531,37 @@ export default function FileTree(props: {
|
|||||||
onClick={() => props.onFileClick?.(node)}
|
onClick={() => props.onFileClick?.(node)}
|
||||||
>
|
>
|
||||||
<div class="w-4 shrink-0" />
|
<div class="w-4 shrink-0" />
|
||||||
<FileIcon node={node} class="text-icon-weak size-4" />
|
<Switch>
|
||||||
|
<Match when={node.ignored}>
|
||||||
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--mono"
|
||||||
|
style="color: var(--icon-weak-base)"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={active()}>
|
||||||
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--mono"
|
||||||
|
style={kindTextColor(kind()!)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={!node.ignored}>
|
||||||
|
<span class="filetree-iconpair size-4">
|
||||||
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
|
||||||
|
/>
|
||||||
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
</FileTreeNode>
|
</FileTreeNode>
|
||||||
</FileTreeNodeTooltip>
|
</FileTreeNodeTooltip>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -20,19 +20,20 @@ import { useParams } from "@solidjs/router"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
|
import { RadioGroup } from "@opencode-ai/ui/radio-group"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -94,7 +95,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const files = useFile()
|
const files = useFile()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length)
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -105,11 +105,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
let fileInputRef!: HTMLInputElement
|
let fileInputRef: HTMLInputElement | undefined
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
let slashPopoverRef!: HTMLDivElement
|
let slashPopoverRef!: HTMLDivElement
|
||||||
|
|
||||||
const mirror = { input: false }
|
const mirror = { input: false }
|
||||||
|
const inset = 44
|
||||||
|
|
||||||
const scrollCursorIntoView = () => {
|
const scrollCursorIntoView = () => {
|
||||||
const container = scrollRef
|
const container = scrollRef
|
||||||
@@ -119,7 +120,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
if (!editorRef.contains(range.startContainer)) return
|
if (!editorRef.contains(range.startContainer)) return
|
||||||
|
|
||||||
const rect = range.getBoundingClientRect()
|
const cursor = getCursorPosition(editorRef)
|
||||||
|
const length = promptLength(prompt.current().filter((part) => part.type !== "image"))
|
||||||
|
if (cursor >= length) {
|
||||||
|
container.scrollTop = container.scrollHeight
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect()
|
||||||
if (!rect.height) return
|
if (!rect.height) return
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect()
|
const containerRect = container.getBoundingClientRect()
|
||||||
@@ -132,8 +140,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bottom > container.scrollTop + container.clientHeight - padding) {
|
if (bottom > container.scrollTop + container.clientHeight - inset) {
|
||||||
container.scrollTop = bottom - container.clientHeight + padding
|
container.scrollTop = bottom - container.clientHeight + inset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,16 +231,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
})
|
})
|
||||||
const placeholder = createMemo(() =>
|
|
||||||
promptPlaceholder({
|
|
||||||
mode: store.mode,
|
|
||||||
commentCount: commentCount(),
|
|
||||||
example: language.t(EXAMPLES[store.placeholder]),
|
|
||||||
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const MAX_HISTORY = 100
|
const commentCount = createMemo(() => {
|
||||||
|
if (store.mode === "shell") return 0
|
||||||
|
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const contextItems = createMemo(() => {
|
||||||
|
const items = prompt.context.items()
|
||||||
|
if (store.mode !== "shell") return items
|
||||||
|
return items.filter((item) => !item.comment?.trim())
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasUserPrompt = createMemo(() => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return false
|
||||||
|
const messages = sync.data.message[sessionID]
|
||||||
|
if (!messages) return false
|
||||||
|
return messages.some((m) => m.role === "user")
|
||||||
|
})
|
||||||
|
|
||||||
const [history, setHistory] = persisted(
|
const [history, setHistory] = persisted(
|
||||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
@@ -250,6 +268,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const suggest = createMemo(() => !hasUserPrompt())
|
||||||
|
|
||||||
|
const placeholder = createMemo(() =>
|
||||||
|
promptPlaceholder({
|
||||||
|
mode: store.mode,
|
||||||
|
commentCount: commentCount(),
|
||||||
|
example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "",
|
||||||
|
suggest: suggest(),
|
||||||
|
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
||||||
const length = position === "start" ? 0 : promptLength(p)
|
const length = position === "start" ? 0 : promptLength(p)
|
||||||
setStore("applyingHistory", true)
|
setStore("applyingHistory", true)
|
||||||
@@ -280,6 +310,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isFocused = createFocusSignal(() => editorRef)
|
const isFocused = createFocusSignal(() => editorRef)
|
||||||
|
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
||||||
|
|
||||||
|
const pick = () => fileInputRef?.click()
|
||||||
|
|
||||||
|
const setMode = (mode: "normal" | "shell") => {
|
||||||
|
setStore("mode", mode)
|
||||||
|
setStore("popover", null)
|
||||||
|
requestAnimationFrame(() => editorRef?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellModeKey = "mod+shift+x"
|
||||||
|
const normalModeKey = "mod+shift+e"
|
||||||
|
|
||||||
|
command.register("prompt-input", () => [
|
||||||
|
{
|
||||||
|
id: "file.attach",
|
||||||
|
title: language.t("prompt.action.attachFile"),
|
||||||
|
category: language.t("command.category.file"),
|
||||||
|
keybind: "mod+u",
|
||||||
|
disabled: store.mode !== "normal",
|
||||||
|
onSelect: pick,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.shell",
|
||||||
|
title: language.t("command.prompt.mode.shell"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: shellModeKey,
|
||||||
|
disabled: store.mode === "shell",
|
||||||
|
onSelect: () => setMode("shell"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.normal",
|
||||||
|
title: language.t("command.prompt.mode.normal"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: normalModeKey,
|
||||||
|
disabled: store.mode === "normal",
|
||||||
|
onSelect: () => setMode("normal"),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
const closePopover = () => setStore("popover", null)
|
const closePopover = () => setStore("popover", null)
|
||||||
|
|
||||||
@@ -325,6 +394,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
params.id
|
params.id
|
||||||
if (params.id) return
|
if (params.id) return
|
||||||
|
if (!suggest()) return
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
|
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
|
||||||
}, 6500)
|
}, 6500)
|
||||||
@@ -334,15 +404,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const [composing, setComposing] = createSignal(false)
|
const [composing, setComposing] = createSignal(false)
|
||||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||||
|
|
||||||
createEffect(() => {
|
const handleBlur = () => {
|
||||||
if (!isFocused()) closePopover()
|
closePopover()
|
||||||
})
|
setComposing(false)
|
||||||
|
}
|
||||||
// Safety: reset composing state on focus change to prevent stuck state
|
|
||||||
// This handles edge cases where compositionend event may not fire
|
|
||||||
createEffect(() => {
|
|
||||||
if (!isFocused()) setComposing(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
const agentList = createMemo(() =>
|
const agentList = createMemo(() =>
|
||||||
sync.data.agent
|
sync.data.agent
|
||||||
@@ -815,6 +880,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
||||||
|
event.preventDefault()
|
||||||
|
if (store.mode !== "normal") return
|
||||||
|
pick()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === "Backspace") {
|
if (event.key === "Backspace") {
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (selection && selection.isCollapsed) {
|
if (selection && selection.isCollapsed) {
|
||||||
@@ -842,13 +914,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (store.mode === "shell") {
|
|
||||||
const { collapsed, cursorPosition, textLength } = getCaretState()
|
if (event.key === "Escape") {
|
||||||
if (event.key === "Escape") {
|
if (store.popover) {
|
||||||
setStore("mode", "normal")
|
closePopover()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (store.mode === "shell") {
|
||||||
|
setStore("mode", "normal")
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (working()) {
|
||||||
|
abort()
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (escBlur()) {
|
||||||
|
editorRef.blur()
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.mode === "shell") {
|
||||||
|
const { collapsed, cursorPosition, textLength } = getCaretState()
|
||||||
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
|
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
|
||||||
setStore("mode", "normal")
|
setStore("mode", "normal")
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -911,31 +1009,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (!collapsed) return
|
if (!collapsed) return
|
||||||
|
|
||||||
const cursorPosition = getCursorPosition(editorRef)
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
const textLength = promptLength(prompt.current())
|
|
||||||
const textContent = prompt
|
const textContent = prompt
|
||||||
.current()
|
.current()
|
||||||
.map((part) => ("content" in part ? part.content : ""))
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
.join("")
|
.join("")
|
||||||
const direction = event.key === "ArrowUp" ? "up" : "down"
|
const direction = event.key === "ArrowUp" ? "up" : "down"
|
||||||
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
|
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return
|
||||||
const isEmpty = textContent.trim() === "" || textLength <= 1
|
if (navigateHistory(direction)) {
|
||||||
const hasNewlines = textContent.includes("\n")
|
|
||||||
const inHistory = store.historyIndex >= 0
|
|
||||||
const atStart = cursorPosition <= (isEmpty ? 1 : 0)
|
|
||||||
const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
|
|
||||||
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
|
|
||||||
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
|
|
||||||
|
|
||||||
if (direction === "up") {
|
|
||||||
if (!allowUp) return
|
|
||||||
if (navigateHistory("up")) {
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowDown) return
|
|
||||||
if (navigateHistory("down")) {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -945,17 +1025,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
handleSubmit(event)
|
handleSubmit(event)
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
|
||||||
if (store.popover) {
|
|
||||||
closePopover()
|
|
||||||
} else if (working()) {
|
|
||||||
abort()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
||||||
<PromptPopover
|
<PromptPopover
|
||||||
popover={store.popover}
|
popover={store.popover}
|
||||||
setSlashPopoverRef={(el) => (slashPopoverRef = el)}
|
setSlashPopoverRef={(el) => (slashPopoverRef = el)}
|
||||||
@@ -971,12 +1046,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
commandKeybind={command.keybind}
|
commandKeybind={command.keybind}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<form
|
<DockShellForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
classList={{
|
classList={{
|
||||||
"group/prompt-input": true,
|
"group/prompt-input": true,
|
||||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
|
"focus-within:shadow-xs-border": true,
|
||||||
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
|
|
||||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||||
[props.class ?? ""]: !!props.class,
|
[props.class ?? ""]: !!props.class,
|
||||||
}}
|
}}
|
||||||
@@ -986,7 +1060,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
|
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
|
||||||
/>
|
/>
|
||||||
<PromptContextItems
|
<PromptContextItems
|
||||||
items={prompt.context.items()}
|
items={contextItems()}
|
||||||
active={(item) => {
|
active={(item) => {
|
||||||
const active = comments.active()
|
const active = comments.active()
|
||||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||||
@@ -1006,162 +1080,60 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onRemove={removeImageAttachment}
|
onRemove={removeImageAttachment}
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
removeLabel={language.t("prompt.attachment.remove")}
|
||||||
/>
|
/>
|
||||||
<div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
|
<div
|
||||||
<div
|
class="relative"
|
||||||
data-component="prompt-input"
|
onMouseDown={(e) => {
|
||||||
ref={(el) => {
|
const target = e.target
|
||||||
editorRef = el
|
if (!(target instanceof HTMLElement)) return
|
||||||
props.ref?.(el)
|
if (
|
||||||
}}
|
target.closest(
|
||||||
role="textbox"
|
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
|
||||||
aria-multiline="true"
|
)
|
||||||
aria-label={placeholder()}
|
) {
|
||||||
contenteditable="true"
|
return
|
||||||
autocapitalize="off"
|
}
|
||||||
autocorrect="off"
|
editorRef?.focus()
|
||||||
spellcheck={false}
|
}}
|
||||||
onInput={handleInput}
|
>
|
||||||
onPaste={handlePaste}
|
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
||||||
onCompositionStart={() => setComposing(true)}
|
<div
|
||||||
onCompositionEnd={() => setComposing(false)}
|
data-component="prompt-input"
|
||||||
onKeyDown={handleKeyDown}
|
ref={(el) => {
|
||||||
classList={{
|
editorRef = el
|
||||||
"select-text": true,
|
props.ref?.(el)
|
||||||
"w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
}}
|
||||||
"[&_[data-type=file]]:text-syntax-property": true,
|
role="textbox"
|
||||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
aria-multiline="true"
|
||||||
"font-mono!": store.mode === "shell",
|
aria-label={placeholder()}
|
||||||
}}
|
contenteditable="true"
|
||||||
/>
|
autocapitalize="off"
|
||||||
<Show when={!prompt.dirty()}>
|
autocorrect="off"
|
||||||
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
spellcheck={false}
|
||||||
{placeholder()}
|
onInput={handleInput}
|
||||||
</div>
|
onPaste={handlePaste}
|
||||||
</Show>
|
onCompositionStart={() => setComposing(true)}
|
||||||
</div>
|
onCompositionEnd={() => setComposing(false)}
|
||||||
<div class="relative p-3 flex items-center justify-between gap-2">
|
onBlur={handleBlur}
|
||||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
onKeyDown={handleKeyDown}
|
||||||
<Switch>
|
classList={{
|
||||||
<Match when={store.mode === "shell"}>
|
"select-text": true,
|
||||||
<div class="flex items-center gap-2 px-2 h-6">
|
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||||
<Icon name="console" size="small" class="text-icon-primary" />
|
"[&_[data-type=file]]:text-syntax-property": true,
|
||||||
<span class="text-12-regular text-text-primary">{language.t("prompt.mode.shell")}</span>
|
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||||
<span class="text-12-regular text-text-weak">{language.t("prompt.mode.shell.exit")}</span>
|
"font-mono!": store.mode === "shell",
|
||||||
</div>
|
}}
|
||||||
</Match>
|
/>
|
||||||
<Match when={store.mode === "normal"}>
|
<Show when={!prompt.dirty()}>
|
||||||
<TooltipKeybind
|
<div
|
||||||
placement="top"
|
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||||
gutter={8}
|
classList={{ "font-mono!": store.mode === "shell" }}
|
||||||
title={language.t("command.agent.cycle")}
|
>
|
||||||
keybind={command.keybind("agent.cycle")}
|
{placeholder()}
|
||||||
>
|
</div>
|
||||||
<Select
|
</Show>
|
||||||
options={agentNames()}
|
|
||||||
current={local.agent.current()?.name ?? ""}
|
|
||||||
onSelect={local.agent.set}
|
|
||||||
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
|
|
||||||
valueClass="truncate"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
<Show
|
|
||||||
when={providers.paid().length > 0}
|
|
||||||
fallback={
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.model.choose")}
|
|
||||||
keybind={command.keybind("model.choose")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
as="div"
|
|
||||||
variant="ghost"
|
|
||||||
class="px-2 min-w-0 max-w-[240px]"
|
|
||||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
|
||||||
>
|
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
|
||||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.model.choose")}
|
|
||||||
keybind={command.keybind("model.choose")}
|
|
||||||
>
|
|
||||||
<ModelSelectorPopover
|
|
||||||
triggerAs={Button}
|
|
||||||
triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }}
|
|
||||||
>
|
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
|
||||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</ModelSelectorPopover>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</Show>
|
|
||||||
<Show when={local.model.variant.list().length > 0}>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.model.variant.cycle")}
|
|
||||||
keybind={command.keybind("model.variant.cycle")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
data-action="model-variant-cycle"
|
|
||||||
variant="ghost"
|
|
||||||
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
|
|
||||||
onClick={() => local.model.variant.cycle()}
|
|
||||||
>
|
|
||||||
{local.model.variant.current() ?? language.t("common.default")}
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</Show>
|
|
||||||
<Show when={permission.permissionsEnabled() && params.id}>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.permissions.autoaccept.enable")}
|
|
||||||
keybind={command.keybind("permissions.autoaccept")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
|
||||||
classList={{
|
|
||||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
|
||||||
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
|
||||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
|
||||||
}}
|
|
||||||
aria-label={
|
|
||||||
permission.isAutoAccepting(params.id!, sdk.directory)
|
|
||||||
? language.t("command.permissions.autoaccept.disable")
|
|
||||||
: language.t("command.permissions.autoaccept.enable")
|
|
||||||
}
|
|
||||||
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name="chevron-double-right"
|
|
||||||
size="small"
|
|
||||||
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</Show>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
|
||||||
|
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -1173,54 +1145,247 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
e.currentTarget.value = ""
|
e.currentTarget.value = ""
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-1 mr-1">
|
|
||||||
<SessionContextUsage />
|
<div
|
||||||
<Show when={store.mode === "normal"}>
|
aria-hidden={store.mode !== "normal"}
|
||||||
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
|
class="flex items-center gap-1 transition-all duration-200 ease-out"
|
||||||
|
classList={{
|
||||||
|
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
|
||||||
|
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
title={language.t("prompt.action.attachFile")}
|
||||||
|
keybind={command.keybind("file.attach")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-attach"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-8 p-0"
|
||||||
|
onClick={pick}
|
||||||
|
disabled={store.mode !== "normal"}
|
||||||
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
|
aria-label={language.t("prompt.action.attachFile")}
|
||||||
|
>
|
||||||
|
<Icon name="plus" class="size-4.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
inactive={!prompt.dirty() && !working()}
|
||||||
|
value={
|
||||||
|
<Switch>
|
||||||
|
<Match when={working()}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.stop")}</span>
|
||||||
|
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.send")}</span>
|
||||||
|
<Icon name="enter" size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
data-action="prompt-submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||||
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
|
variant="primary"
|
||||||
|
class="size-8"
|
||||||
|
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
|
||||||
|
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||||
|
<div class="pointer-events-auto">
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={8}
|
||||||
|
title={language.t("command.permissions.autoaccept.enable")}
|
||||||
|
keybind={command.keybind("permissions.autoaccept")}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
data-action="prompt-permissions"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="size-6 px-1"
|
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||||
onClick={() => fileInputRef.click()}
|
classList={{
|
||||||
aria-label={language.t("prompt.action.attachFile")}
|
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||||
|
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
||||||
|
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
||||||
|
}}
|
||||||
|
aria-label={
|
||||||
|
permission.isAutoAccepting(params.id!, sdk.directory)
|
||||||
|
? language.t("command.permissions.autoaccept.disable")
|
||||||
|
: language.t("command.permissions.autoaccept.enable")
|
||||||
|
}
|
||||||
|
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
|
||||||
>
|
>
|
||||||
<Icon name="photo" class="size-4.5" />
|
<Icon
|
||||||
|
name="chevron-double-right"
|
||||||
|
size="small"
|
||||||
|
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</DockShellForm>
|
||||||
|
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||||
|
<DockTray attach="top">
|
||||||
|
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
|
<Show when={store.mode === "shell"}>
|
||||||
|
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
|
||||||
|
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||||
|
<div class="size-4 shrink-0" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={store.mode === "normal"}>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.agent.cycle")}
|
||||||
|
keybind={command.keybind("agent.cycle")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
size="normal"
|
||||||
|
options={agentNames()}
|
||||||
|
current={local.agent.current()?.name ?? ""}
|
||||||
|
onSelect={local.agent.set}
|
||||||
|
class="capitalize max-w-[160px]"
|
||||||
|
valueClass="truncate text-13-regular"
|
||||||
|
triggerStyle={{ height: "28px" }}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
<Show
|
||||||
|
when={providers.paid().length > 0}
|
||||||
|
fallback={
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as="div"
|
||||||
|
variant="ghost"
|
||||||
|
size="normal"
|
||||||
|
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||||
|
style={{ height: "28px" }}
|
||||||
|
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()!.provider.id as IconName}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<ModelSelectorPopover
|
||||||
|
triggerAs={Button}
|
||||||
|
triggerProps={{
|
||||||
|
variant: "ghost",
|
||||||
|
size: "normal",
|
||||||
|
style: { height: "28px" },
|
||||||
|
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()!.provider.id as IconName}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</ModelSelectorPopover>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.variant.cycle")}
|
||||||
|
keybind={command.keybind("model.variant.cycle")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
size="normal"
|
||||||
|
options={variants()}
|
||||||
|
current={local.model.variant.current() ?? "default"}
|
||||||
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
|
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||||
|
class="capitalize max-w-[160px]"
|
||||||
|
valueClass="truncate text-13-regular"
|
||||||
|
triggerStyle={{ height: "28px" }}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip
|
<div class="shrink-0">
|
||||||
placement="top"
|
<RadioGroup
|
||||||
inactive={!prompt.dirty() && !working()}
|
options={["shell", "normal"] as const}
|
||||||
value={
|
current={store.mode}
|
||||||
<Switch>
|
value={(mode) => mode}
|
||||||
<Match when={working()}>
|
label={(mode) => (
|
||||||
<div class="flex items-center gap-2">
|
<TooltipKeybind
|
||||||
<span>{language.t("prompt.action.stop")}</span>
|
placement="top"
|
||||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
gutter={4}
|
||||||
</div>
|
openDelay={2000}
|
||||||
</Match>
|
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
||||||
<Match when={true}>
|
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
||||||
<div class="flex items-center gap-2">
|
class="size-full flex items-center justify-center"
|
||||||
<span>{language.t("prompt.action.send")}</span>
|
>
|
||||||
<Icon name="enter" size="small" class="text-icon-base" />
|
<Icon
|
||||||
</div>
|
name={mode === "shell" ? "console" : "prompt"}
|
||||||
</Match>
|
class="size-[18px]"
|
||||||
</Switch>
|
classList={{
|
||||||
}
|
"text-icon-strong-base": store.mode === mode,
|
||||||
>
|
"text-icon-weak": store.mode !== mode,
|
||||||
<IconButton
|
}}
|
||||||
type="submit"
|
/>
|
||||||
disabled={!prompt.dirty() && !working() && commentCount() === 0}
|
</TooltipKeybind>
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
)}
|
||||||
variant="primary"
|
onSelect={(mode) => mode && setMode(mode)}
|
||||||
class="h-6 w-4.5"
|
fill
|
||||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
pad="none"
|
||||||
|
class="w-[68px]"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DockTray>
|
||||||
</form>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,9 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
"hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
||||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
|
||||||
selected,
|
|
||||||
"bg-background-stronger": !selected,
|
"bg-background-stronger": !selected,
|
||||||
}}
|
}}
|
||||||
onClick={() => props.openComment(item)}
|
onClick={() => props.openComment(item)}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ describe("prompt-input history", () => {
|
|||||||
expect(original[1].selection?.startLine).toBe(1)
|
expect(original[1].selection?.startLine).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
|
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||||
const value = "a\nb\nc"
|
const value = "a\nb\nc"
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||||
@@ -85,7 +85,16 @@ describe("prompt-input history", () => {
|
|||||||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
|
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 3, true)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 1, true)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 1, true)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
|||||||
|
|
||||||
export const MAX_HISTORY = 100
|
export const MAX_HISTORY = 100
|
||||||
|
|
||||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
|
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||||
if (!text.includes("\n")) return true
|
|
||||||
const position = Math.max(0, Math.min(cursor, text.length))
|
const position = Math.max(0, Math.min(cursor, text.length))
|
||||||
if (direction === "up") return !text.slice(0, position).includes("\n")
|
const atStart = position === 0
|
||||||
return !text.slice(position).includes("\n")
|
const atEnd = position === text.length
|
||||||
|
if (inHistory) return atStart || atEnd
|
||||||
|
if (direction === "up") return position === 0
|
||||||
|
return position === text.length
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clonePromptParts(prompt: Prompt): Prompt {
|
export function clonePromptParts(prompt: Prompt): Prompt {
|
||||||
|
|||||||
@@ -9,27 +9,40 @@ describe("promptPlaceholder", () => {
|
|||||||
mode: "shell",
|
mode: "shell",
|
||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
example: "example",
|
example: "example",
|
||||||
|
suggest: true,
|
||||||
t,
|
t,
|
||||||
})
|
})
|
||||||
expect(value).toBe("prompt.placeholder.shell")
|
expect(value).toBe("prompt.placeholder.shell")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns summarize placeholders for comment context", () => {
|
test("returns summarize placeholders for comment context", () => {
|
||||||
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
|
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe(
|
||||||
"prompt.placeholder.summarizeComment",
|
"prompt.placeholder.summarizeComment",
|
||||||
)
|
)
|
||||||
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
|
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe(
|
||||||
"prompt.placeholder.summarizeComments",
|
"prompt.placeholder.summarizeComments",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns default placeholder with example", () => {
|
test("returns default placeholder with example when suggestions enabled", () => {
|
||||||
const value = promptPlaceholder({
|
const value = promptPlaceholder({
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
example: "translated-example",
|
example: "translated-example",
|
||||||
|
suggest: true,
|
||||||
t,
|
t,
|
||||||
})
|
})
|
||||||
expect(value).toBe("prompt.placeholder.normal:translated-example")
|
expect(value).toBe("prompt.placeholder.normal:translated-example")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("returns simple placeholder when suggestions disabled", () => {
|
||||||
|
const value = promptPlaceholder({
|
||||||
|
mode: "normal",
|
||||||
|
commentCount: 0,
|
||||||
|
example: "translated-example",
|
||||||
|
suggest: false,
|
||||||
|
t,
|
||||||
|
})
|
||||||
|
expect(value).toBe("prompt.placeholder.simple")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ type PromptPlaceholderInput = {
|
|||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
commentCount: number
|
commentCount: number
|
||||||
example: string
|
example: string
|
||||||
|
suggest: boolean
|
||||||
t: (key: string, params?: Record<string, string>) => string
|
t: (key: string, params?: Record<string, string>) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,5 +10,6 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
|
|||||||
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
|
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
|
||||||
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
|
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
|
||||||
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
|
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
|
||||||
|
if (!input.suggest) return input.t("prompt.placeholder.simple")
|
||||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
||||||
}}
|
}}
|
||||||
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
|
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
|
||||||
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|||||||
@@ -12,24 +12,27 @@ let selected = "/repo/worktree-a"
|
|||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
|
|
||||||
const clientFor = (directory: string) => ({
|
const clientFor = (directory: string) => {
|
||||||
session: {
|
createdClients.push(directory)
|
||||||
create: async () => {
|
return {
|
||||||
createdSessions.push(directory)
|
session: {
|
||||||
return { data: { id: `session-${createdSessions.length}` } }
|
create: async () => {
|
||||||
|
createdSessions.push(directory)
|
||||||
|
return { data: { id: `session-${createdSessions.length}` } }
|
||||||
|
},
|
||||||
|
shell: async () => {
|
||||||
|
sentShell.push(directory)
|
||||||
|
return { data: undefined }
|
||||||
|
},
|
||||||
|
prompt: async () => ({ data: undefined }),
|
||||||
|
command: async () => ({ data: undefined }),
|
||||||
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
shell: async () => {
|
worktree: {
|
||||||
sentShell.push(directory)
|
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||||
return { data: undefined }
|
|
||||||
},
|
},
|
||||||
prompt: async () => ({ data: undefined }),
|
}
|
||||||
command: async () => ({ data: undefined }),
|
}
|
||||||
abort: async () => ({ data: undefined }),
|
|
||||||
},
|
|
||||||
worktree: {
|
|
||||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const rootClient = clientFor("/repo/main")
|
const rootClient = clientFor("/repo/main")
|
||||||
@@ -88,11 +91,17 @@ beforeAll(async () => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sdk", () => ({
|
mock.module("@/context/sdk", () => ({
|
||||||
useSDK: () => ({
|
useSDK: () => {
|
||||||
directory: "/repo/main",
|
const sdk = {
|
||||||
client: rootClient,
|
directory: "/repo/main",
|
||||||
url: "http://localhost:4096",
|
client: rootClient,
|
||||||
}),
|
url: "http://localhost:4096",
|
||||||
|
createClient(opts: any) {
|
||||||
|
return clientFor(opts.directory)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return sdk
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { Accessor } from "solid-js"
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
|
||||||
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { useLocal } from "@/context/local"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
|
import type { Accessor } from "solid-js"
|
||||||
|
import type { FileSelection } from "@/context/file"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import type { FileSelection } from "@/context/file"
|
|
||||||
import { setCursorPosition } from "./editor-dom"
|
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
|
import { setCursorPosition } from "./editor-dom"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -56,7 +55,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const platform = usePlatform()
|
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -75,6 +73,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const abort = async () => {
|
const abort = async () => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
|
||||||
|
globalSync.todo.set(sessionID, [])
|
||||||
|
const [, setStore] = globalSync.child(sdk.directory)
|
||||||
|
setStore("todo", sessionID, [])
|
||||||
|
|
||||||
const queued = pending.get(sessionID)
|
const queued = pending.get(sessionID)
|
||||||
if (queued) {
|
if (queued) {
|
||||||
queued.abort.abort()
|
queued.abort.abort()
|
||||||
@@ -171,9 +174,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionDirectory !== projectDirectory) {
|
if (sessionDirectory !== projectDirectory) {
|
||||||
client = createOpencodeClient({
|
client = sdk.createClient({
|
||||||
baseUrl: sdk.url,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
directory: sessionDirectory,
|
directory: sessionDirectory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
@@ -368,7 +369,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const timer = { id: undefined as number | undefined }
|
const timer = { id: undefined as number | undefined }
|
||||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
timer.id = window.setTimeout(() => {
|
timer.id = window.setTimeout(() => {
|
||||||
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
|
resolve({
|
||||||
|
status: "failed",
|
||||||
|
message: language.t("workspace.error.stillPreparing"),
|
||||||
|
})
|
||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
import { For, Show, createMemo, type Component } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
|
|
||||||
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
|
|
||||||
const sdk = useSDK()
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
const questions = createMemo(() => props.request.questions)
|
|
||||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
tab: 0,
|
|
||||||
answers: [] as QuestionAnswer[],
|
|
||||||
custom: [] as string[],
|
|
||||||
editing: false,
|
|
||||||
sending: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const question = createMemo(() => questions()[store.tab])
|
|
||||||
const confirm = createMemo(() => !single() && store.tab === questions().length)
|
|
||||||
const options = createMemo(() => question()?.options ?? [])
|
|
||||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
|
||||||
const multi = createMemo(() => question()?.multiple === true)
|
|
||||||
const customPicked = createMemo(() => {
|
|
||||||
const value = input()
|
|
||||||
if (!value) return false
|
|
||||||
return store.answers[store.tab]?.includes(value) ?? false
|
|
||||||
})
|
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
|
||||||
}
|
|
||||||
|
|
||||||
const reply = async (answers: QuestionAnswer[]) => {
|
|
||||||
if (store.sending) return
|
|
||||||
|
|
||||||
setStore("sending", true)
|
|
||||||
try {
|
|
||||||
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
|
||||||
} catch (err) {
|
|
||||||
fail(err)
|
|
||||||
} finally {
|
|
||||||
setStore("sending", false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reject = async () => {
|
|
||||||
if (store.sending) return
|
|
||||||
|
|
||||||
setStore("sending", true)
|
|
||||||
try {
|
|
||||||
await sdk.client.question.reject({ requestID: props.request.id })
|
|
||||||
} catch (err) {
|
|
||||||
fail(err)
|
|
||||||
} finally {
|
|
||||||
setStore("sending", false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
void reply(questions().map((_, i) => store.answers[i] ?? []))
|
|
||||||
}
|
|
||||||
|
|
||||||
const pick = (answer: string, custom: boolean = false) => {
|
|
||||||
setStore("answers", store.tab, [answer])
|
|
||||||
|
|
||||||
if (custom) {
|
|
||||||
setStore("custom", store.tab, answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (single()) {
|
|
||||||
void reply([[answer]])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore("tab", store.tab + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = (answer: string) => {
|
|
||||||
setStore("answers", store.tab, (current = []) => {
|
|
||||||
if (current.includes(answer)) return current.filter((item) => item !== answer)
|
|
||||||
return [...current, answer]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectTab = (index: number) => {
|
|
||||||
setStore("tab", index)
|
|
||||||
setStore("editing", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectOption = (optIndex: number) => {
|
|
||||||
if (store.sending) return
|
|
||||||
|
|
||||||
if (optIndex === options().length) {
|
|
||||||
setStore("editing", true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const opt = options()[optIndex]
|
|
||||||
if (!opt) return
|
|
||||||
if (multi()) {
|
|
||||||
toggle(opt.label)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pick(opt.label)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCustomSubmit = (e: Event) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (store.sending) return
|
|
||||||
|
|
||||||
const value = input().trim()
|
|
||||||
if (!value) {
|
|
||||||
setStore("editing", false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (multi()) {
|
|
||||||
setStore("answers", store.tab, (current = []) => {
|
|
||||||
if (current.includes(value)) return current
|
|
||||||
return [...current, value]
|
|
||||||
})
|
|
||||||
setStore("editing", false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pick(value, true)
|
|
||||||
setStore("editing", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-component="question-prompt">
|
|
||||||
<Show when={!single()}>
|
|
||||||
<div data-slot="question-tabs">
|
|
||||||
<For each={questions()}>
|
|
||||||
{(q, index) => {
|
|
||||||
const active = () => index() === store.tab
|
|
||||||
const answered = () => (store.answers[index()]?.length ?? 0) > 0
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
data-slot="question-tab"
|
|
||||||
data-active={active()}
|
|
||||||
data-answered={answered()}
|
|
||||||
disabled={store.sending}
|
|
||||||
onClick={() => selectTab(index())}
|
|
||||||
>
|
|
||||||
{q.header}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<button
|
|
||||||
data-slot="question-tab"
|
|
||||||
data-active={confirm()}
|
|
||||||
disabled={store.sending}
|
|
||||||
onClick={() => selectTab(questions().length)}
|
|
||||||
>
|
|
||||||
{language.t("ui.common.confirm")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!confirm()}>
|
|
||||||
<div data-slot="question-content">
|
|
||||||
<div data-slot="question-text">
|
|
||||||
{question()?.question}
|
|
||||||
{multi() ? " " + language.t("ui.question.multiHint") : ""}
|
|
||||||
</div>
|
|
||||||
<div data-slot="question-options">
|
|
||||||
<For each={options()}>
|
|
||||||
{(opt, i) => {
|
|
||||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
data-slot="question-option"
|
|
||||||
data-picked={picked()}
|
|
||||||
disabled={store.sending}
|
|
||||||
onClick={() => selectOption(i())}
|
|
||||||
>
|
|
||||||
<span data-slot="option-label">{opt.label}</span>
|
|
||||||
<Show when={opt.description}>
|
|
||||||
<span data-slot="option-description">{opt.description}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={picked()}>
|
|
||||||
<Icon name="check-small" size="normal" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<button
|
|
||||||
data-slot="question-option"
|
|
||||||
data-picked={customPicked()}
|
|
||||||
disabled={store.sending}
|
|
||||||
onClick={() => selectOption(options().length)}
|
|
||||||
>
|
|
||||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
|
||||||
<Show when={!store.editing && input()}>
|
|
||||||
<span data-slot="option-description">{input()}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={customPicked()}>
|
|
||||||
<Icon name="check-small" size="normal" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
<Show when={store.editing}>
|
|
||||||
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
|
|
||||||
<input
|
|
||||||
ref={(el) => setTimeout(() => el.focus(), 0)}
|
|
||||||
type="text"
|
|
||||||
data-slot="custom-input"
|
|
||||||
placeholder={language.t("ui.question.custom.placeholder")}
|
|
||||||
value={input()}
|
|
||||||
disabled={store.sending}
|
|
||||||
onInput={(e) => {
|
|
||||||
setStore("custom", store.tab, e.currentTarget.value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
|
|
||||||
{multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
disabled={store.sending}
|
|
||||||
onClick={() => setStore("editing", false)}
|
|
||||||
>
|
|
||||||
{language.t("ui.common.cancel")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={confirm()}>
|
|
||||||
<div data-slot="question-review">
|
|
||||||
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
|
|
||||||
<For each={questions()}>
|
|
||||||
{(q, index) => {
|
|
||||||
const value = () => store.answers[index()]?.join(", ") ?? ""
|
|
||||||
const answered = () => Boolean(value())
|
|
||||||
return (
|
|
||||||
<div data-slot="review-item">
|
|
||||||
<span data-slot="review-label">{q.question}</span>
|
|
||||||
<span data-slot="review-value" data-answered={answered()}>
|
|
||||||
{answered() ? value() : language.t("ui.question.review.notAnswered")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div data-slot="question-actions">
|
|
||||||
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
|
|
||||||
{language.t("ui.common.dismiss")}
|
|
||||||
</Button>
|
|
||||||
<Show when={!single()}>
|
|
||||||
<Show when={confirm()}>
|
|
||||||
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
|
|
||||||
{language.t("ui.common.submit")}
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
<Show when={!confirm() && multi()}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
onClick={() => selectTab(store.tab + 1)}
|
|
||||||
disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
|
|
||||||
>
|
|
||||||
{language.t("ui.common.next")}
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
import {
|
||||||
import { serverDisplayName } from "@/context/server"
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
type JSXElement,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
type ParentProps,
|
||||||
|
Show,
|
||||||
|
} from "solid-js"
|
||||||
|
import { type ServerConnection, serverDisplayName } from "@/context/server"
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
interface ServerRowProps extends ParentProps {
|
interface ServerRowProps extends ParentProps {
|
||||||
url: string
|
conn: ServerConnection.Any
|
||||||
status?: ServerHealth
|
status?: ServerHealth
|
||||||
class?: string
|
class?: string
|
||||||
nameClass?: string
|
nameClass?: string
|
||||||
@@ -17,7 +26,7 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
const [truncated, setTruncated] = createSignal(false)
|
const [truncated, setTruncated] = createSignal(false)
|
||||||
let nameRef: HTMLSpanElement | undefined
|
let nameRef: HTMLSpanElement | undefined
|
||||||
let versionRef: HTMLSpanElement | undefined
|
let versionRef: HTMLSpanElement | undefined
|
||||||
const name = createMemo(() => serverDisplayName(props.url))
|
const name = createMemo(() => serverDisplayName(props.conn))
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||||
@@ -27,7 +36,7 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
name()
|
name()
|
||||||
props.url
|
props.conn.http.url
|
||||||
props.status?.version
|
props.status?.version
|
||||||
queueMicrotask(check)
|
queueMicrotask(check)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
@@ -11,6 +11,7 @@ import { getSessionContextMetrics } from "@/components/session/session-context-m
|
|||||||
|
|
||||||
interface SessionContextUsageProps {
|
interface SessionContextUsageProps {
|
||||||
variant?: "button" | "indicator"
|
variant?: "button" | "indicator"
|
||||||
|
placement?: TooltipProps["placement"]
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSessionContext(args: {
|
function openSessionContext(args: {
|
||||||
@@ -52,6 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
|
|
||||||
|
if (tabs().active() === "context") {
|
||||||
|
tabs().close("context")
|
||||||
|
return
|
||||||
|
}
|
||||||
openSessionContext({
|
openSessionContext({
|
||||||
view: view(),
|
view: view(),
|
||||||
layout,
|
layout,
|
||||||
@@ -90,7 +96,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={params.id}>
|
<Show when={params.id}>
|
||||||
<Tooltip value={tooltipValue()} placement="top">
|
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={variant() === "indicator"}>{circle()}</Match>
|
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { checksum } from "@opencode-ai/util/encode"
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
import { findLast } from "@opencode-ai/util/array"
|
import { findLast } from "@opencode-ai/util/array"
|
||||||
|
import { same } from "@/utils/same"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
@@ -16,13 +17,6 @@ import { getSessionContextMetrics } from "./session-context-metrics"
|
|||||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
import { createSessionContextFormatter } from "./session-context-format"
|
import { createSessionContextFormatter } from "./session-context-format"
|
||||||
|
|
||||||
interface SessionContextTabProps {
|
|
||||||
messages: () => Message[]
|
|
||||||
visibleUserMessages: () => UserMessage[]
|
|
||||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
|
||||||
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
|
||||||
}
|
|
||||||
|
|
||||||
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
|
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
|
||||||
system: "var(--syntax-info)",
|
system: "var(--syntax-info)",
|
||||||
user: "var(--syntax-success)",
|
user: "var(--syntax-success)",
|
||||||
@@ -91,11 +85,45 @@ function RawMessage(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionContextTab(props: SessionContextTabProps) {
|
const emptyMessages: Message[] = []
|
||||||
|
const emptyUserMessages: UserMessage[] = []
|
||||||
|
|
||||||
|
export function SessionContextTab() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
|
|
||||||
|
const messages = createMemo(
|
||||||
|
() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return emptyMessages
|
||||||
|
return (sync.data.message[id] ?? []) as Message[]
|
||||||
|
},
|
||||||
|
emptyMessages,
|
||||||
|
{ equals: same },
|
||||||
|
)
|
||||||
|
|
||||||
|
const userMessages = createMemo(
|
||||||
|
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||||
|
emptyUserMessages,
|
||||||
|
{ equals: same },
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleUserMessages = createMemo(
|
||||||
|
() => {
|
||||||
|
const revert = info()?.revert?.messageID
|
||||||
|
if (!revert) return userMessages()
|
||||||
|
return userMessages().filter((m) => m.id < revert)
|
||||||
|
},
|
||||||
|
emptyUserMessages,
|
||||||
|
{ equals: same },
|
||||||
|
)
|
||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
new Intl.NumberFormat(language.locale(), {
|
new Intl.NumberFormat(language.locale(), {
|
||||||
@@ -104,7 +132,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
|
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||||
const ctx = createMemo(() => metrics().context)
|
const ctx = createMemo(() => metrics().context)
|
||||||
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
|
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
|
||||||
|
|
||||||
@@ -113,7 +141,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const counts = createMemo(() => {
|
const counts = createMemo(() => {
|
||||||
const all = props.messages()
|
const all = messages()
|
||||||
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
|
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
|
||||||
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
|
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
|
||||||
return {
|
return {
|
||||||
@@ -124,7 +152,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const systemPrompt = createMemo(() => {
|
const systemPrompt = createMemo(() => {
|
||||||
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
|
const msg = findLast(visibleUserMessages(), (m) => !!m.system)
|
||||||
const system = msg?.system
|
const system = msg?.system
|
||||||
if (!system) return
|
if (!system) return
|
||||||
const trimmed = system.trim()
|
const trimmed = system.trim()
|
||||||
@@ -146,12 +174,12 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
|
|
||||||
const breakdown = createMemo(
|
const breakdown = createMemo(
|
||||||
on(
|
on(
|
||||||
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
|
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
|
||||||
() => {
|
() => {
|
||||||
const c = ctx()
|
const c = ctx()
|
||||||
if (!c?.input) return []
|
if (!c?.input) return []
|
||||||
return estimateSessionContextBreakdown({
|
return estimateSessionContextBreakdown({
|
||||||
messages: props.messages(),
|
messages: messages(),
|
||||||
parts: sync.data.part as Record<string, Part[] | undefined>,
|
parts: sync.data.part as Record<string, Part[] | undefined>,
|
||||||
input: c.input,
|
input: c.input,
|
||||||
systemPrompt: systemPrompt(),
|
systemPrompt: systemPrompt(),
|
||||||
@@ -169,7 +197,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
|
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
|
||||||
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
|
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
|
||||||
{ label: "context.stats.provider", value: providerLabel },
|
{ label: "context.stats.provider", value: providerLabel },
|
||||||
{ label: "context.stats.model", value: modelLabel },
|
{ label: "context.stats.model", value: modelLabel },
|
||||||
@@ -186,7 +214,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
|
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
|
||||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
|
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
|
||||||
{ label: "context.stats.totalCost", value: cost },
|
{ label: "context.stats.totalCost", value: cost },
|
||||||
{ label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
|
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
|
||||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||||
] satisfies { label: string; value: () => JSX.Element }[]
|
] satisfies { label: string; value: () => JSX.Element }[]
|
||||||
|
|
||||||
@@ -199,7 +227,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
const el = scroll
|
const el = scroll
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
const s = props.view()?.scroll("context")
|
const s = view().scroll("context")
|
||||||
if (!s) return
|
if (!s) return
|
||||||
|
|
||||||
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
||||||
@@ -220,13 +248,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
pending = undefined
|
pending = undefined
|
||||||
if (!next) return
|
if (!next) return
|
||||||
|
|
||||||
props.view().setScroll("context", next)
|
view().setScroll("context", next)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => props.messages().length,
|
() => messages().length,
|
||||||
() => {
|
() => {
|
||||||
requestAnimationFrame(restoreScroll)
|
requestAnimationFrame(restoreScroll)
|
||||||
},
|
},
|
||||||
@@ -300,7 +328,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||||
<Accordion multiple>
|
<Accordion multiple>
|
||||||
<For each={props.messages()}>
|
<For each={messages()}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
|
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -257,27 +257,12 @@ export function SessionHeader() {
|
|||||||
] as const
|
] as const
|
||||||
})
|
})
|
||||||
|
|
||||||
const checksReady = createMemo(() => {
|
|
||||||
if (platform.platform !== "desktop") return true
|
|
||||||
if (!platform.checkAppExists) return true
|
|
||||||
const list = apps()
|
|
||||||
return list.every((app) => exists[app.id] !== undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
||||||
const [menu, setMenu] = createStore({ open: false })
|
const [menu, setMenu] = createStore({ open: false })
|
||||||
|
|
||||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (platform.platform !== "desktop") return
|
|
||||||
if (!checksReady()) return
|
|
||||||
const value = prefs.app
|
|
||||||
if (options().some((o) => o.id === value)) return
|
|
||||||
setPrefs("app", options()[0]?.id ?? "finder")
|
|
||||||
})
|
|
||||||
|
|
||||||
const openDir = (app: OpenApp) => {
|
const openDir = (app: OpenApp) => {
|
||||||
const directory = projectDirectory()
|
const directory = projectDirectory()
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
@@ -311,25 +296,25 @@ export function SessionHeader() {
|
|||||||
platform,
|
platform,
|
||||||
})
|
})
|
||||||
|
|
||||||
const leftMount = createMemo(
|
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
||||||
() => document.getElementById("opencode-titlebar-left") ?? document.getElementById("opencode-titlebar-center"),
|
|
||||||
)
|
|
||||||
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={leftMount()}>
|
<Show when={centerMount()}>
|
||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
class="hidden md:flex w-[320px] max-w-full min-w-0 h-[24px] px-2 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-base bg-surface-panel transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||||
onClick={() => command.trigger("file.open")}
|
onClick={() => command.trigger("file.open")}
|
||||||
aria-label={language.t("session.header.searchFiles")}
|
aria-label={language.t("session.header.searchFiles")}
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-visible">
|
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
|
||||||
<Icon name="magnifying-glass" size="normal" class="icon-base shrink-0" />
|
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
|
||||||
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate h-4.5 flex items-center">
|
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||||
{language.t("session.header.search.placeholder", { project: name() })}
|
{language.t("session.header.search.placeholder", { project: name() })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,24 +324,24 @@ export function SessionHeader() {
|
|||||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
|
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</Button>
|
||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={rightMount()}>
|
<Show when={rightMount()}>
|
||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2">
|
||||||
<StatusPopover />
|
<StatusPopover />
|
||||||
<Show when={projectDirectory()}>
|
<Show when={projectDirectory()}>
|
||||||
<div class="hidden xl:flex items-center">
|
<div class="hidden xl:flex items-center">
|
||||||
<Show
|
<Show
|
||||||
when={canOpen()}
|
when={canOpen()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
|
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="rounded-none h-full py-0 pr-3 pl-2 gap-2 border-none shadow-none"
|
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
|
||||||
onClick={copyPath}
|
onClick={copyPath}
|
||||||
aria-label={language.t("session.header.open.copyPath")}
|
aria-label={language.t("session.header.open.copyPath")}
|
||||||
>
|
>
|
||||||
@@ -369,10 +354,10 @@ export function SessionHeader() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
|
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
|
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
|
||||||
onClick={() => openDir(current().id)}
|
onClick={() => openDir(current().id)}
|
||||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||||
>
|
>
|
||||||
@@ -381,9 +366,9 @@ export function SessionHeader() {
|
|||||||
</div>
|
</div>
|
||||||
<span class="text-12-regular text-text-strong">Open</span>
|
<span class="text-12-regular text-text-strong">Open</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div class="self-stretch w-px bg-border-base/70" />
|
<div class="self-stretch w-px bg-border-weak-base" />
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
gutter={6}
|
gutter={4}
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
open={menu.open}
|
open={menu.open}
|
||||||
onOpenChange={(open) => setMenu("open", open)}
|
onOpenChange={(open) => setMenu("open", open)}
|
||||||
@@ -392,7 +377,7 @@ export function SessionHeader() {
|
|||||||
as={IconButton}
|
as={IconButton}
|
||||||
icon="chevron-down"
|
icon="chevron-down"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
|
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-hover"
|
||||||
aria-label={language.t("session.header.open.menu")}
|
aria-label={language.t("session.header.open.menu")}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
@@ -400,7 +385,7 @@ export function SessionHeader() {
|
|||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
|
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
|
||||||
<DropdownMenu.RadioGroup
|
<DropdownMenu.RadioGroup
|
||||||
value={prefs.app}
|
value={current().id}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||||
setPrefs("app", value as OpenApp)
|
setPrefs("app", value as OpenApp)
|
||||||
@@ -458,7 +443,7 @@ export function SessionHeader() {
|
|||||||
? language.t("session.share.popover.description.shared")
|
? language.t("session.share.popover.description.shared")
|
||||||
: language.t("session.share.popover.description.unshared")
|
: language.t("session.share.popover.description.unshared")
|
||||||
}
|
}
|
||||||
gutter={6}
|
gutter={4}
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
shift={-64}
|
shift={-64}
|
||||||
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
|
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
|
||||||
@@ -466,11 +451,11 @@ export function SessionHeader() {
|
|||||||
triggerProps={{
|
triggerProps={{
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
class:
|
class:
|
||||||
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
|
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
|
||||||
classList: { "rounded-r-none": share.shareUrl() !== undefined },
|
classList: { "rounded-r-none": share.shareUrl() !== undefined },
|
||||||
style: { scale: 1 },
|
style: { scale: 1 },
|
||||||
}}
|
}}
|
||||||
trigger={language.t("session.share.action.share")}
|
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Show
|
<Show
|
||||||
@@ -539,7 +524,7 @@ export function SessionHeader() {
|
|||||||
<IconButton
|
<IconButton
|
||||||
icon={share.state.copied ? "check" : "link"}
|
icon={share.state.copied ? "check" : "link"}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
|
class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
|
||||||
onClick={() => share.copyLink((error) => showRequestError(language, error))}
|
onClick={() => share.copyLink((error) => showRequestError(language, error))}
|
||||||
disabled={share.state.unshare}
|
disabled={share.state.unshare}
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -552,94 +537,97 @@ export function SessionHeader() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-3 ml-2 shrink-0">
|
<div class="flex items-center gap-1">
|
||||||
<TooltipKeybind
|
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||||
title={language.t("command.terminal.toggle")}
|
<TooltipKeybind
|
||||||
keybind={command.keybind("terminal.toggle")}
|
title={language.t("command.terminal.toggle")}
|
||||||
>
|
keybind={command.keybind("terminal.toggle")}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="group/terminal-toggle size-6 p-0"
|
|
||||||
onClick={() => view().terminal.toggle()}
|
|
||||||
aria-label={language.t("command.terminal.toggle")}
|
|
||||||
aria-expanded={view().terminal.opened()}
|
|
||||||
aria-controls="terminal-panel"
|
|
||||||
>
|
>
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
<Button
|
||||||
<Icon
|
variant="ghost"
|
||||||
size="small"
|
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
onClick={() => view().terminal.toggle()}
|
||||||
class="group-hover/terminal-toggle:hidden"
|
aria-label={language.t("command.terminal.toggle")}
|
||||||
/>
|
aria-expanded={view().terminal.opened()}
|
||||||
<Icon
|
aria-controls="terminal-panel"
|
||||||
size="small"
|
>
|
||||||
name="layout-bottom-partial"
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
class="hidden group-hover/terminal-toggle:inline-block"
|
<Icon
|
||||||
/>
|
size="small"
|
||||||
<Icon
|
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
||||||
size="small"
|
class="group-hover/terminal-toggle:hidden"
|
||||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
/>
|
||||||
class="hidden group-active/terminal-toggle:inline-block"
|
<Icon
|
||||||
/>
|
size="small"
|
||||||
</div>
|
name="layout-bottom-partial"
|
||||||
</Button>
|
class="hidden group-hover/terminal-toggle:inline-block"
|
||||||
</TooltipKeybind>
|
/>
|
||||||
</div>
|
<Icon
|
||||||
<div class="hidden lg:block shrink-0">
|
size="small"
|
||||||
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
||||||
<Button
|
class="hidden group-active/terminal-toggle:inline-block"
|
||||||
variant="ghost"
|
/>
|
||||||
class="group/review-toggle size-6 p-0"
|
</div>
|
||||||
onClick={() => view().reviewPanel.toggle()}
|
</Button>
|
||||||
aria-label={language.t("command.review.toggle")}
|
</TooltipKeybind>
|
||||||
aria-expanded={view().reviewPanel.opened()}
|
|
||||||
aria-controls="review-panel"
|
<TooltipKeybind
|
||||||
|
title={language.t("command.review.toggle")}
|
||||||
|
keybind={command.keybind("review.toggle")}
|
||||||
>
|
>
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
<Button
|
||||||
<Icon
|
variant="ghost"
|
||||||
size="small"
|
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
|
onClick={() => view().reviewPanel.toggle()}
|
||||||
class="group-hover/review-toggle:hidden"
|
aria-label={language.t("command.review.toggle")}
|
||||||
/>
|
aria-expanded={view().reviewPanel.opened()}
|
||||||
<Icon
|
aria-controls="review-panel"
|
||||||
size="small"
|
>
|
||||||
name="layout-right-partial"
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
class="hidden group-hover/review-toggle:inline-block"
|
<Icon
|
||||||
/>
|
size="small"
|
||||||
<Icon
|
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
|
||||||
size="small"
|
class="group-hover/review-toggle:hidden"
|
||||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
|
/>
|
||||||
class="hidden group-active/review-toggle:inline-block"
|
<Icon
|
||||||
/>
|
size="small"
|
||||||
</div>
|
name="layout-right-partial"
|
||||||
</Button>
|
class="hidden group-hover/review-toggle:inline-block"
|
||||||
</TooltipKeybind>
|
/>
|
||||||
</div>
|
<Icon
|
||||||
<div class="hidden lg:block shrink-0">
|
size="small"
|
||||||
<TooltipKeybind
|
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
|
||||||
title={language.t("command.fileTree.toggle")}
|
class="hidden group-active/review-toggle:inline-block"
|
||||||
keybind={command.keybind("fileTree.toggle")}
|
/>
|
||||||
>
|
</div>
|
||||||
<Button
|
</Button>
|
||||||
variant="ghost"
|
</TooltipKeybind>
|
||||||
class="group/file-tree-toggle size-6 p-0"
|
|
||||||
onClick={() => layout.fileTree.toggle()}
|
<TooltipKeybind
|
||||||
aria-label={language.t("command.fileTree.toggle")}
|
title={language.t("command.fileTree.toggle")}
|
||||||
aria-expanded={layout.fileTree.opened()}
|
keybind={command.keybind("fileTree.toggle")}
|
||||||
aria-controls="file-tree-panel"
|
|
||||||
>
|
>
|
||||||
<div class="relative flex items-center justify-center size-4">
|
<Button
|
||||||
<Icon
|
variant="ghost"
|
||||||
size="small"
|
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
name="bullet-list"
|
onClick={() => layout.fileTree.toggle()}
|
||||||
classList={{
|
aria-label={language.t("command.fileTree.toggle")}
|
||||||
"text-icon-strong": layout.fileTree.opened(),
|
aria-expanded={layout.fileTree.opened()}
|
||||||
"text-icon-weak": !layout.fileTree.opened(),
|
aria-controls="file-tree-panel"
|
||||||
}}
|
>
|
||||||
/>
|
<div class="relative flex items-center justify-center size-4">
|
||||||
</div>
|
<Icon
|
||||||
</Button>
|
size="small"
|
||||||
</TooltipKeybind>
|
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||||
|
classList={{
|
||||||
|
"text-icon-strong": layout.fileTree.opened(),
|
||||||
|
"text-icon-weak": !layout.fileTree.opened(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|||||||
const MAIN_WORKTREE = "main"
|
const MAIN_WORKTREE = "main"
|
||||||
const CREATE_WORKTREE = "create"
|
const CREATE_WORKTREE = "create"
|
||||||
const ROOT_CLASS =
|
const ROOT_CLASS =
|
||||||
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"
|
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
|
||||||
|
|
||||||
interface NewSessionViewProps {
|
interface NewSessionViewProps {
|
||||||
worktree: string
|
worktree: string
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
{ value: "roboto-mono", label: "font.option.robotoMono" },
|
{ value: "roboto-mono", label: "font.option.robotoMono" },
|
||||||
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
|
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
|
||||||
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
|
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
|
||||||
|
{ value: "geist-mono", label: "font.option.geistMono" },
|
||||||
] as const
|
] as const
|
||||||
const fontOptionsList = [...fontOptions]
|
const fontOptionsList = [...fontOptions]
|
||||||
|
|
||||||
@@ -430,7 +431,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
<SoundsSection />
|
<SoundsSection />
|
||||||
|
|
||||||
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
|
{/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
|
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
|
||||||
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
|
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
|
||||||
@@ -456,7 +457,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>*/}
|
||||||
|
|
||||||
<UpdatesSection />
|
<UpdatesSection />
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
|
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
|
||||||
import { useNavigate } from "@solidjs/router"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Popover } from "@opencode-ai/ui/popover"
|
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useSync } from "@/context/sync"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
import { normalizeServerUrl, useServer } from "@/context/server"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { DialogSelectServer } from "./dialog-select-server"
|
|
||||||
import { ServerRow } from "@/components/server/server-row"
|
import { ServerRow } from "@/components/server/server-row"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
|
import { DialogSelectServer } from "./dialog-select-server"
|
||||||
|
|
||||||
const pollMs = 10_000
|
const pollMs = 10_000
|
||||||
|
|
||||||
@@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listServersByHealth = (
|
const listServersByHealth = (
|
||||||
list: string[],
|
list: ServerConnection.Any[],
|
||||||
active: string | undefined,
|
active: ServerConnection.Key | undefined,
|
||||||
status: Record<string, ServerHealth | undefined>,
|
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||||
) => {
|
) => {
|
||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
@@ -45,16 +45,16 @@ const listServersByHealth = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return list.slice().sort((a, b) => {
|
return list.slice().sort((a, b) => {
|
||||||
if (a === active) return -1
|
if (ServerConnection.key(a) === active) return -1
|
||||||
if (b === active) return 1
|
if (ServerConnection.key(b) === active) return 1
|
||||||
const diff = rank(status[a]) - rank(status[b])
|
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
||||||
if (diff !== 0) return diff
|
if (diff !== 0) return diff
|
||||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
|
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
|
||||||
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
|
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const list = servers()
|
const list = servers()
|
||||||
@@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
|
|||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const results: Record<string, ServerHealth> = {}
|
const results: Record<string, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
list.map(async (url) => {
|
list.map(async (conn) => {
|
||||||
results[url] = await checkServerHealth(url, fetcher)
|
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (dead) return
|
if (dead) return
|
||||||
@@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
|
|||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
const useDefaultServerUrl = (
|
const useDefaultServerKey = (
|
||||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||||
) => {
|
) => {
|
||||||
const [url, setUrl] = createSignal<string | undefined>()
|
const [url, setUrl] = createSignal<string | undefined>()
|
||||||
@@ -117,7 +117,14 @@ const useDefaultServerUrl = (
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return { url, refresh: () => setTick((value) => value + 1) }
|
return {
|
||||||
|
key: () => {
|
||||||
|
const u = url()
|
||||||
|
if (!u) return
|
||||||
|
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||||
|
},
|
||||||
|
refresh: () => setTick((value) => value + 1),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const useMcpToggle = (input: {
|
const useMcpToggle = (input: {
|
||||||
@@ -163,16 +170,16 @@ export function StatusPopover() {
|
|||||||
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
const servers = createMemo(() => {
|
const servers = createMemo(() => {
|
||||||
const current = server.url
|
const current = server.current
|
||||||
const list = server.list
|
const list = server.list
|
||||||
if (!current) return list
|
if (!current) return list
|
||||||
if (!list.includes(current)) return [current, ...list]
|
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||||
return [current, ...list.filter((item) => item !== current)]
|
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||||
})
|
})
|
||||||
const health = useServerHealth(servers, fetcher)
|
const health = useServerHealth(servers, fetcher)
|
||||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
|
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||||
const mcp = useMcpToggle({ sync, sdk, language })
|
const mcp = useMcpToggle({ sync, sdk, language })
|
||||||
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
@@ -196,24 +203,26 @@ export function StatusPopover() {
|
|||||||
triggerProps={{
|
triggerProps={{
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
class:
|
class:
|
||||||
"rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
|
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
|
||||||
style: { scale: 1 },
|
style: { scale: 1 },
|
||||||
}}
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-0.5">
|
||||||
<div
|
<div class="size-4 flex items-center justify-center">
|
||||||
classList={{
|
<div
|
||||||
"size-1.5 rounded-full": true,
|
classList={{
|
||||||
"bg-icon-success-base": overallHealthy(),
|
"size-1.5 rounded-full": true,
|
||||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
"bg-icon-success-base": overallHealthy(),
|
||||||
"bg-border-weak-base": server.healthy() === undefined,
|
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||||
}}
|
"bg-border-weak-base": server.healthy() === undefined,
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
|
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||||
gutter={6}
|
gutter={4}
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
shift={-136}
|
shift={-136}
|
||||||
>
|
>
|
||||||
@@ -249,8 +258,9 @@ export function StatusPopover() {
|
|||||||
<div class="flex flex-col px-2 pb-2">
|
<div class="flex flex-col px-2 pb-2">
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
<For each={sortedServers()}>
|
<For each={sortedServers()}>
|
||||||
{(url) => {
|
{(s) => {
|
||||||
const isBlocked = () => health[url]?.healthy === false
|
const key = ServerConnection.key(s)
|
||||||
|
const isBlocked = () => health[key]?.healthy === false
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -262,19 +272,19 @@ export function StatusPopover() {
|
|||||||
aria-disabled={isBlocked()}
|
aria-disabled={isBlocked()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isBlocked()) return
|
if (isBlocked()) return
|
||||||
server.setActive(url)
|
server.setActive(key)
|
||||||
navigate("/")
|
navigate("/")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
url={url}
|
conn={s}
|
||||||
status={health[url]}
|
status={health[key]}
|
||||||
dimmed={isBlocked()}
|
dimmed={isBlocked()}
|
||||||
class="flex items-center gap-2 w-full min-w-0"
|
class="flex items-center gap-2 w-full min-w-0"
|
||||||
nameClass="text-14-regular text-text-base truncate"
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
versionClass="text-12-regular text-text-weak truncate"
|
versionClass="text-12-regular text-text-weak truncate"
|
||||||
badge={
|
badge={
|
||||||
<Show when={url === defaultServer.url()}>
|
<Show when={key === defaultServer.key()}>
|
||||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||||
{language.t("common.default")}
|
{language.t("common.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -282,7 +292,7 @@ export function StatusPopover() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<Show when={url === server.url}>
|
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
||||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||||
</Show>
|
</Show>
|
||||||
</ServerRow>
|
</ServerRow>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
||||||
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||||
|
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
|
import { SerializeAddon } from "@/addons/serialize"
|
||||||
|
import { matchKeybind, parseKeybind } from "@/context/command"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||||
import { parseKeybind, matchKeybind } from "@/context/command"
|
import type { LocalPTY } from "@/context/terminal"
|
||||||
import { SerializeAddon } from "@/addons/serialize"
|
|
||||||
import { LocalPTY } from "@/context/terminal"
|
|
||||||
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||||
import { terminalWriter } from "@/utils/terminal-writer"
|
import { terminalWriter } from "@/utils/terminal-writer"
|
||||||
|
|
||||||
@@ -106,8 +107,14 @@ const useTerminalUiBindings = (input: {
|
|||||||
input.container.addEventListener("pointerdown", input.handlePointerDown)
|
input.container.addEventListener("pointerdown", input.handlePointerDown)
|
||||||
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
|
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
|
||||||
|
|
||||||
input.container.addEventListener("click", input.handleLinkClick, { capture: true })
|
input.container.addEventListener("click", input.handleLinkClick, {
|
||||||
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
|
capture: true,
|
||||||
|
})
|
||||||
|
input.cleanups.push(() =>
|
||||||
|
input.container.removeEventListener("click", input.handleLinkClick, {
|
||||||
|
capture: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
input.term.textarea?.addEventListener("focus", handleTextareaFocus)
|
input.term.textarea?.addEventListener("focus", handleTextareaFocus)
|
||||||
input.term.textarea?.addEventListener("blur", handleTextareaBlur)
|
input.term.textarea?.addEventListener("blur", handleTextareaBlur)
|
||||||
@@ -148,6 +155,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
|
||||||
let ws: WebSocket | undefined
|
let ws: WebSocket | undefined
|
||||||
@@ -312,8 +320,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const mod = loaded.mod
|
const mod = loaded.mod
|
||||||
const g = loaded.ghostty
|
const g = loaded.ghostty
|
||||||
|
|
||||||
const once = { value: false }
|
|
||||||
|
|
||||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||||
const restoreSize =
|
const restoreSize =
|
||||||
restore &&
|
restore &&
|
||||||
@@ -346,7 +352,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
ghostty = g
|
ghostty = g
|
||||||
term = t
|
term = t
|
||||||
output = terminalWriter((data) => t.write(data))
|
output = terminalWriter((data, done) => t.write(data, done))
|
||||||
|
|
||||||
t.attachCustomKeyEventHandler((event) => {
|
t.attachCustomKeyEventHandler((event) => {
|
||||||
const key = event.key.toLowerCase()
|
const key = event.key.toLowerCase()
|
||||||
@@ -372,7 +378,13 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
serializeAddon = serializer
|
serializeAddon = serializer
|
||||||
|
|
||||||
t.open(container)
|
t.open(container)
|
||||||
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
|
useTerminalUiBindings({
|
||||||
|
container,
|
||||||
|
term: t,
|
||||||
|
cleanups,
|
||||||
|
handlePointerDown,
|
||||||
|
handleLinkClick,
|
||||||
|
})
|
||||||
|
|
||||||
focusTerminal()
|
focusTerminal()
|
||||||
|
|
||||||
@@ -402,20 +414,28 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (restore && restoreSize) {
|
const write = (data: string) =>
|
||||||
t.write(restore, () => {
|
new Promise<void>((resolve) => {
|
||||||
fit.fit()
|
if (!output) {
|
||||||
scheduleSize(t.cols, t.rows)
|
resolve()
|
||||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
return
|
||||||
startResize()
|
}
|
||||||
|
output.push(data)
|
||||||
|
output.flush(resolve)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (restore && restoreSize) {
|
||||||
|
await write(restore)
|
||||||
|
fit.fit()
|
||||||
|
scheduleSize(t.cols, t.rows)
|
||||||
|
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||||
|
startResize()
|
||||||
} else {
|
} else {
|
||||||
fit.fit()
|
fit.fit()
|
||||||
scheduleSize(t.cols, t.rows)
|
scheduleSize(t.cols, t.rows)
|
||||||
if (restore) {
|
if (restore) {
|
||||||
t.write(restore, () => {
|
await write(restore)
|
||||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
startResize()
|
startResize()
|
||||||
}
|
}
|
||||||
@@ -424,40 +444,32 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
// console.log("Scroll position:", ydisp)
|
// console.log("Scroll position:", ydisp)
|
||||||
// })
|
// })
|
||||||
|
|
||||||
|
const once = { value: false }
|
||||||
|
let closing = false
|
||||||
|
|
||||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
|
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
|
||||||
url.searchParams.set("directory", sdk.directory)
|
url.searchParams.set("directory", sdk.directory)
|
||||||
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
||||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||||
if (window.__OPENCODE__?.serverPassword) {
|
url.username = server.current?.http.username ?? ""
|
||||||
url.username = "opencode"
|
url.password = server.current?.http.password ?? ""
|
||||||
url.password = window.__OPENCODE__?.serverPassword
|
|
||||||
}
|
|
||||||
const socket = new WebSocket(url)
|
const socket = new WebSocket(url)
|
||||||
socket.binaryType = "arraybuffer"
|
socket.binaryType = "arraybuffer"
|
||||||
ws = socket
|
ws = socket
|
||||||
cleanups.push(() => {
|
|
||||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
|
|
||||||
})
|
|
||||||
if (disposed) {
|
|
||||||
cleanup()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
local.onConnect?.()
|
local.onConnect?.()
|
||||||
scheduleSize(t.cols, t.rows)
|
scheduleSize(t.cols, t.rows)
|
||||||
}
|
}
|
||||||
socket.addEventListener("open", handleOpen)
|
socket.addEventListener("open", handleOpen)
|
||||||
cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
|
||||||
|
|
||||||
if (socket.readyState === WebSocket.OPEN) handleOpen()
|
if (socket.readyState === WebSocket.OPEN) handleOpen()
|
||||||
|
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = (event: MessageEvent) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
if (closing) return
|
||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
|
|
||||||
const bytes = new Uint8Array(event.data)
|
const bytes = new Uint8Array(event.data)
|
||||||
if (bytes[0] !== 0) return
|
if (bytes[0] !== 0) return
|
||||||
const json = decoder.decode(bytes.subarray(1))
|
const json = decoder.decode(bytes.subarray(1))
|
||||||
@@ -479,20 +491,20 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
cursor += data.length
|
cursor += data.length
|
||||||
}
|
}
|
||||||
socket.addEventListener("message", handleMessage)
|
socket.addEventListener("message", handleMessage)
|
||||||
cleanups.push(() => socket.removeEventListener("message", handleMessage))
|
|
||||||
|
|
||||||
const handleError = (error: Event) => {
|
const handleError = (error: Event) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
if (closing) return
|
||||||
if (once.value) return
|
if (once.value) return
|
||||||
once.value = true
|
once.value = true
|
||||||
console.error("WebSocket error:", error)
|
console.error("WebSocket error:", error)
|
||||||
local.onConnectError?.(error)
|
local.onConnectError?.(error)
|
||||||
}
|
}
|
||||||
socket.addEventListener("error", handleError)
|
socket.addEventListener("error", handleError)
|
||||||
cleanups.push(() => socket.removeEventListener("error", handleError))
|
|
||||||
|
|
||||||
const handleClose = (event: CloseEvent) => {
|
const handleClose = (event: CloseEvent) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
if (closing) return
|
||||||
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
|
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
|
||||||
// For other codes (network issues, server restart), trigger error handler
|
// For other codes (network issues, server restart), trigger error handler
|
||||||
if (event.code !== 1000) {
|
if (event.code !== 1000) {
|
||||||
@@ -502,7 +514,15 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
socket.addEventListener("close", handleClose)
|
socket.addEventListener("close", handleClose)
|
||||||
cleanups.push(() => socket.removeEventListener("close", handleClose))
|
|
||||||
|
cleanups.push(() => {
|
||||||
|
closing = true
|
||||||
|
socket.removeEventListener("open", handleOpen)
|
||||||
|
socket.removeEventListener("message", handleMessage)
|
||||||
|
socket.removeEventListener("error", handleError)
|
||||||
|
socket.removeEventListener("close", handleClose)
|
||||||
|
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
void run().catch((err) => {
|
void run().catch((err) => {
|
||||||
@@ -520,9 +540,19 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
disposed = true
|
disposed = true
|
||||||
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
||||||
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||||
output?.flush()
|
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
|
||||||
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
|
||||||
cleanup()
|
const finalize = () => {
|
||||||
|
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
finalize()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
output.flush(finalize)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocation, useNavigate } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -43,6 +43,7 @@ export function Titlebar() {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const params = useParams()
|
||||||
|
|
||||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||||
@@ -171,9 +172,10 @@ export function Titlebar() {
|
|||||||
<IconButton
|
<IconButton
|
||||||
icon="menu"
|
icon="menu"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="size-8 rounded-md"
|
class="titlebar-icon rounded-md"
|
||||||
onClick={layout.mobileSidebar.toggle}
|
onClick={layout.mobileSidebar.toggle}
|
||||||
aria-label={language.t("sidebar.menu.toggle")}
|
aria-label={language.t("sidebar.menu.toggle")}
|
||||||
|
aria-expanded={layout.mobileSidebar.opened()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -182,13 +184,14 @@ export function Titlebar() {
|
|||||||
<IconButton
|
<IconButton
|
||||||
icon="menu"
|
icon="menu"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="size-8 rounded-md"
|
class="titlebar-icon rounded-md"
|
||||||
onClick={layout.mobileSidebar.toggle}
|
onClick={layout.mobileSidebar.toggle}
|
||||||
aria-label={language.t("sidebar.menu.toggle")}
|
aria-label={language.t("sidebar.menu.toggle")}
|
||||||
|
aria-expanded={layout.mobileSidebar.opened()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
|
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
@@ -197,7 +200,7 @@ export function Titlebar() {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="group/sidebar-toggle size-6 p-0"
|
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
onClick={layout.sidebar.toggle}
|
onClick={layout.sidebar.toggle}
|
||||||
aria-label={language.t("command.sidebar.toggle")}
|
aria-label={language.t("command.sidebar.toggle")}
|
||||||
aria-expanded={layout.sidebar.opened()}
|
aria-expanded={layout.sidebar.opened()}
|
||||||
@@ -205,56 +208,77 @@ export function Titlebar() {
|
|||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
<Icon
|
<Icon
|
||||||
size="small"
|
size="small"
|
||||||
name={layout.sidebar.opened() ? "layout-left-full" : "layout-left"}
|
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
|
||||||
class="group-hover/sidebar-toggle:hidden"
|
class="group-hover/sidebar-toggle:hidden"
|
||||||
/>
|
/>
|
||||||
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
|
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
|
||||||
<Icon
|
<Icon
|
||||||
size="small"
|
size="small"
|
||||||
name={layout.sidebar.opened() ? "layout-left" : "layout-left-full"}
|
name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
|
||||||
class="hidden group-active/sidebar-toggle:inline-block"
|
class="hidden group-active/sidebar-toggle:inline-block"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
<div class="hidden xl:flex items-center shrink-0">
|
||||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
<Show when={params.dir}>
|
||||||
<Button
|
<TooltipKeybind
|
||||||
variant="ghost"
|
placement="bottom"
|
||||||
icon="arrow-left"
|
title={language.t("command.session.new")}
|
||||||
class="size-6 p-0"
|
keybind={command.keybind("session.new")}
|
||||||
disabled={!canBack()}
|
openDelay={2000}
|
||||||
onClick={back}
|
>
|
||||||
aria-label={language.t("common.goBack")}
|
<Button
|
||||||
/>
|
variant="ghost"
|
||||||
</Tooltip>
|
icon="new-session"
|
||||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
<Button
|
onClick={() => {
|
||||||
variant="ghost"
|
if (!params.dir) return
|
||||||
icon="arrow-right"
|
navigate(`/${params.dir}/session`)
|
||||||
class="size-6 p-0"
|
}}
|
||||||
disabled={!canForward()}
|
aria-label={language.t("command.session.new")}
|
||||||
onClick={forward}
|
/>
|
||||||
aria-label={language.t("common.goForward")}
|
</TooltipKeybind>
|
||||||
/>
|
</Show>
|
||||||
</Tooltip>
|
<div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
|
||||||
|
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="chevron-left"
|
||||||
|
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||||
|
disabled={!canBack()}
|
||||||
|
onClick={back}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="chevron-right"
|
||||||
|
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||||
|
disabled={!canForward()}
|
||||||
|
onClick={forward}
|
||||||
|
aria-label={language.t("common.goForward")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center">
|
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||||
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex items-center min-w-0 justify-end": true,
|
"flex items-center min-w-0 justify-end": true,
|
||||||
"pr-6": !windows(),
|
"pr-2": !windows(),
|
||||||
}}
|
}}
|
||||||
onMouseDown={drag}
|
onMouseDown={drag}
|
||||||
>
|
>
|
||||||
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" />
|
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||||
<Show when={windows()}>
|
<Show when={windows()}>
|
||||||
<div class="w-6 shrink-0" />
|
<div class="w-6 shrink-0" />
|
||||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
|
|||||||
const PALETTE_ID = "command.palette"
|
const PALETTE_ID = "command.palette"
|
||||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||||
const SUGGESTED_PREFIX = "suggested."
|
const SUGGESTED_PREFIX = "suggested."
|
||||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
|
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
||||||
|
|
||||||
function actionId(id: string) {
|
function actionId(id: string) {
|
||||||
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
||||||
@@ -316,8 +316,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
const isPalette = palette().has(sig)
|
const isPalette = palette().has(sig)
|
||||||
const option = keymap().get(sig)
|
const option = keymap().get(sig)
|
||||||
const modified = event.ctrlKey || event.metaKey || event.altKey
|
const modified = event.ctrlKey || event.metaKey || event.altKey
|
||||||
|
const isTab = event.key === "Tab"
|
||||||
|
|
||||||
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified) return
|
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab)
|
||||||
|
return
|
||||||
|
|
||||||
if (isPalette) {
|
if (isPalette) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { batch, onCleanup } from "solid-js"
|
import { batch, onCleanup } from "solid-js"
|
||||||
|
import z from "zod"
|
||||||
|
import { createSdkForServer } from "@/utils/server"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
|
|
||||||
|
const abortError = z.object({
|
||||||
|
name: z.literal("AbortError"),
|
||||||
|
})
|
||||||
|
|
||||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||||
name: "GlobalSDK",
|
name: "GlobalSDK",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -12,20 +18,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
|
|
||||||
const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
|
|
||||||
|
|
||||||
const auth = (() => {
|
|
||||||
if (!password) return
|
|
||||||
if (!server.isLocal()) return
|
|
||||||
return {
|
|
||||||
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const eventFetch = (() => {
|
const eventFetch = (() => {
|
||||||
if (!platform.fetch) return
|
if (!platform.fetch || !server.current) return
|
||||||
try {
|
try {
|
||||||
const url = new URL(server.url)
|
const url = new URL(server.current.http.url)
|
||||||
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
||||||
if (url.protocol === "http:" && !loopback) return platform.fetch
|
if (url.protocol === "http:" && !loopback) return platform.fetch
|
||||||
} catch {
|
} catch {
|
||||||
@@ -33,11 +29,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const eventSdk = createOpencodeClient({
|
const currentServer = server.current
|
||||||
baseUrl: server.url,
|
if (!currentServer) throw new Error("No server available")
|
||||||
|
|
||||||
|
const eventSdk = createSdkForServer({
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
fetch: eventFetch,
|
fetch: eventFetch,
|
||||||
headers: eventFetch ? undefined : auth,
|
server: currentServer.http,
|
||||||
})
|
})
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key: string]: Event
|
[key: string]: Event
|
||||||
@@ -93,23 +91,51 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
|
|
||||||
let streamErrorLogged = false
|
let streamErrorLogged = false
|
||||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
|
const aborted = (error: unknown) => abortError.safeParse(error).success
|
||||||
|
|
||||||
|
let attempt: AbortController | undefined
|
||||||
|
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||||
|
let lastEventAt = Date.now()
|
||||||
|
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const resetHeartbeat = () => {
|
||||||
|
lastEventAt = Date.now()
|
||||||
|
if (heartbeat) clearTimeout(heartbeat)
|
||||||
|
heartbeat = setTimeout(() => {
|
||||||
|
attempt?.abort()
|
||||||
|
}, HEARTBEAT_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
const clearHeartbeat = () => {
|
||||||
|
if (!heartbeat) return
|
||||||
|
clearTimeout(heartbeat)
|
||||||
|
heartbeat = undefined
|
||||||
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
while (!abort.signal.aborted) {
|
while (!abort.signal.aborted) {
|
||||||
|
attempt = new AbortController()
|
||||||
|
lastEventAt = Date.now()
|
||||||
|
const onAbort = () => {
|
||||||
|
attempt?.abort()
|
||||||
|
}
|
||||||
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
try {
|
try {
|
||||||
const events = await eventSdk.global.event({
|
const events = await eventSdk.global.event({
|
||||||
|
signal: attempt.signal,
|
||||||
onSseError: (error) => {
|
onSseError: (error) => {
|
||||||
|
if (aborted(error)) return
|
||||||
if (streamErrorLogged) return
|
if (streamErrorLogged) return
|
||||||
streamErrorLogged = true
|
streamErrorLogged = true
|
||||||
console.error("[global-sdk] event stream error", {
|
console.error("[global-sdk] event stream error", {
|
||||||
url: server.url,
|
url: currentServer.http.url,
|
||||||
fetch: eventFetch ? "platform" : "webview",
|
fetch: eventFetch ? "platform" : "webview",
|
||||||
error,
|
error,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
let yielded = Date.now()
|
let yielded = Date.now()
|
||||||
|
resetHeartbeat()
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
|
resetHeartbeat()
|
||||||
streamErrorLogged = false
|
streamErrorLogged = false
|
||||||
const directory = event.directory ?? "global"
|
const directory = event.directory ?? "global"
|
||||||
const payload = event.payload
|
const payload = event.payload
|
||||||
@@ -130,14 +156,18 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
await wait(0)
|
await wait(0)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!streamErrorLogged) {
|
if (!aborted(error) && !streamErrorLogged) {
|
||||||
streamErrorLogged = true
|
streamErrorLogged = true
|
||||||
console.error("[global-sdk] event stream failed", {
|
console.error("[global-sdk] event stream failed", {
|
||||||
url: server.url,
|
url: currentServer.http.url,
|
||||||
fetch: eventFetch ? "platform" : "webview",
|
fetch: eventFetch ? "platform" : "webview",
|
||||||
error,
|
error,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
abort.signal.removeEventListener("abort", onAbort)
|
||||||
|
attempt = undefined
|
||||||
|
clearHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (abort.signal.aborted) return
|
if (abort.signal.aborted) return
|
||||||
@@ -145,17 +175,43 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
}
|
}
|
||||||
})().finally(flush)
|
})().finally(flush)
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
if (document.visibilityState !== "visible") return
|
||||||
|
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||||
|
attempt?.abort()
|
||||||
|
}
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.addEventListener("visibilitychange", onVisibility)
|
||||||
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility)
|
||||||
|
}
|
||||||
abort.abort()
|
abort.abort()
|
||||||
flush()
|
flush()
|
||||||
})
|
})
|
||||||
|
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createSdkForServer({
|
||||||
baseUrl: server.url,
|
server: server.current.http,
|
||||||
fetch: platform.fetch,
|
fetch: platform.fetch,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { url: server.url, client: sdk, event: emitter }
|
return {
|
||||||
|
url: currentServer.http.url,
|
||||||
|
client: sdk,
|
||||||
|
event: emitter,
|
||||||
|
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||||
|
const s = server.current
|
||||||
|
if (!s) throw new Error("Server not available")
|
||||||
|
return createSdkForServer({
|
||||||
|
server: s.http,
|
||||||
|
fetch: platform.fetch,
|
||||||
|
...opts,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ describe("pickDirectoriesToEvict", () => {
|
|||||||
describe("loadRootSessionsWithFallback", () => {
|
describe("loadRootSessionsWithFallback", () => {
|
||||||
test("uses limited roots query when supported", async () => {
|
test("uses limited roots query when supported", async () => {
|
||||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||||
let fallback = 0
|
|
||||||
|
|
||||||
const result = await loadRootSessionsWithFallback({
|
const result = await loadRootSessionsWithFallback({
|
||||||
directory: "dir",
|
directory: "dir",
|
||||||
@@ -39,20 +38,15 @@ describe("loadRootSessionsWithFallback", () => {
|
|||||||
calls.push(query)
|
calls.push(query)
|
||||||
return { data: [] }
|
return { data: [] }
|
||||||
},
|
},
|
||||||
onFallback: () => {
|
|
||||||
fallback += 1
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.data).toEqual([])
|
expect(result.data).toEqual([])
|
||||||
expect(result.limited).toBe(true)
|
expect(result.limited).toBe(true)
|
||||||
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
|
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
|
||||||
expect(fallback).toBe(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("falls back to full roots query on limited-query failure", async () => {
|
test("falls back to full roots query on limited-query failure", async () => {
|
||||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||||
let fallback = 0
|
|
||||||
|
|
||||||
const result = await loadRootSessionsWithFallback({
|
const result = await loadRootSessionsWithFallback({
|
||||||
directory: "dir",
|
directory: "dir",
|
||||||
@@ -62,9 +56,6 @@ describe("loadRootSessionsWithFallback", () => {
|
|||||||
if (query.limit) throw new Error("unsupported")
|
if (query.limit) throw new Error("unsupported")
|
||||||
return { data: [] }
|
return { data: [] }
|
||||||
},
|
},
|
||||||
onFallback: () => {
|
|
||||||
fallback += 1
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.data).toEqual([])
|
expect(result.data).toEqual([])
|
||||||
@@ -73,7 +64,6 @@ describe("loadRootSessionsWithFallback", () => {
|
|||||||
{ directory: "dir", roots: true, limit: 25 },
|
{ directory: "dir", roots: true, limit: 25 },
|
||||||
{ directory: "dir", roots: true },
|
{ directory: "dir", roots: true },
|
||||||
])
|
])
|
||||||
expect(fallback).toBe(1)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,50 @@
|
|||||||
import {
|
import type {
|
||||||
type Config,
|
Config,
|
||||||
type Path,
|
OpencodeClient,
|
||||||
type Project,
|
Path,
|
||||||
type ProviderAuthResponse,
|
Project,
|
||||||
type ProviderListResponse,
|
ProviderAuthResponse,
|
||||||
createOpencodeClient,
|
ProviderListResponse,
|
||||||
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import type { InitError } from "../pages/error"
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
createEffect,
|
createEffect,
|
||||||
untrack,
|
|
||||||
getOwner,
|
getOwner,
|
||||||
useContext,
|
Match,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
Switch,
|
Switch,
|
||||||
Match,
|
untrack,
|
||||||
|
useContext,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
|
||||||
import { usePlatform } from "./platform"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { createRefreshQueue } from "./global-sync/queue"
|
import type { InitError } from "../pages/error"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { trimSessions } from "./global-sync/session-trim"
|
|
||||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
|
||||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
|
||||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||||
import { sanitizeProject } from "./global-sync/utils"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
|
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||||
|
import { createRefreshQueue } from "./global-sync/queue"
|
||||||
|
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||||
|
import { trimSessions } from "./global-sync/session-trim"
|
||||||
import type { ProjectMeta } from "./global-sync/types"
|
import type { ProjectMeta } from "./global-sync/types"
|
||||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||||
|
import { sanitizeProject } from "./global-sync/utils"
|
||||||
|
import { usePlatform } from "./platform"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
error?: InitError
|
error?: InitError
|
||||||
path: Path
|
path: Path
|
||||||
project: Project[]
|
project: Project[]
|
||||||
|
session_todo: {
|
||||||
|
[sessionID: string]: Todo[]
|
||||||
|
}
|
||||||
provider: ProviderListResponse
|
provider: ProviderListResponse
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: ProviderAuthResponse
|
||||||
config: Config
|
config: Config
|
||||||
@@ -53,14 +57,6 @@ function errorMessage(error: unknown) {
|
|||||||
return "Unknown error"
|
return "Unknown error"
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDevStats(value: {
|
|
||||||
activeDirectoryStores: number
|
|
||||||
evictions: number
|
|
||||||
loadSessionsFullFetchFallback: number
|
|
||||||
}) {
|
|
||||||
;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
|
|
||||||
}
|
|
||||||
|
|
||||||
function createGlobalSync() {
|
function createGlobalSync() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
@@ -68,12 +64,7 @@ function createGlobalSync() {
|
|||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
if (!owner) throw new Error("GlobalSync must be created within owner")
|
if (!owner) throw new Error("GlobalSync must be created within owner")
|
||||||
|
|
||||||
const stats = {
|
const sdkCache = new Map<string, OpencodeClient>()
|
||||||
evictions: 0,
|
|
||||||
loadSessionsFallback: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
|
|
||||||
const booting = new Map<string, Promise<void>>()
|
const booting = new Map<string, Promise<void>>()
|
||||||
const sessionLoads = new Map<string, Promise<void>>()
|
const sessionLoads = new Map<string, Promise<void>>()
|
||||||
const sessionMeta = new Map<string, { limit: number }>()
|
const sessionMeta = new Map<string, { limit: number }>()
|
||||||
@@ -87,19 +78,25 @@ function createGlobalSync() {
|
|||||||
ready: false,
|
ready: false,
|
||||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
project: projectCache.value,
|
project: projectCache.value,
|
||||||
|
session_todo: {},
|
||||||
provider: { all: [], connected: [], default: {} },
|
provider: { all: [], connected: [], default: {} },
|
||||||
provider_auth: {},
|
provider_auth: {},
|
||||||
config: {},
|
config: {},
|
||||||
reload: undefined,
|
reload: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateStats = (activeDirectoryStores: number) => {
|
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
||||||
if (!import.meta.env.DEV) return
|
if (!sessionID) return
|
||||||
setDevStats({
|
if (!todos) {
|
||||||
activeDirectoryStores,
|
setGlobalStore(
|
||||||
evictions: stats.evictions,
|
"session_todo",
|
||||||
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
|
produce((draft) => {
|
||||||
})
|
delete draft[sessionID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const paused = () => untrack(() => globalStore.reload) !== undefined
|
const paused = () => untrack(() => globalStore.reload) !== undefined
|
||||||
@@ -112,11 +109,6 @@ function createGlobalSync() {
|
|||||||
|
|
||||||
const children = createChildStoreManager({
|
const children = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
markStats: updateStats,
|
|
||||||
incrementEvictions: () => {
|
|
||||||
stats.evictions += 1
|
|
||||||
updateStats(Object.keys(children.children).length)
|
|
||||||
},
|
|
||||||
isBooting: (directory) => booting.has(directory),
|
isBooting: (directory) => booting.has(directory),
|
||||||
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
||||||
onBootstrap: (directory) => {
|
onBootstrap: (directory) => {
|
||||||
@@ -132,9 +124,7 @@ function createGlobalSync() {
|
|||||||
const sdkFor = (directory: string) => {
|
const sdkFor = (directory: string) => {
|
||||||
const cached = sdkCache.get(directory)
|
const cached = sdkCache.get(directory)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
const sdk = createOpencodeClient({
|
const sdk = globalSDK.createClient({
|
||||||
baseUrl: globalSDK.url,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
directory,
|
directory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
@@ -174,7 +164,10 @@ function createGlobalSync() {
|
|||||||
const [store, setStore] = children.child(directory, { bootstrap: false })
|
const [store, setStore] = children.child(directory, { bootstrap: false })
|
||||||
const meta = sessionMeta.get(directory)
|
const meta = sessionMeta.get(directory)
|
||||||
if (meta && meta.limit >= store.limit) {
|
if (meta && meta.limit >= store.limit) {
|
||||||
const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
|
const next = trimSessions(store.session, {
|
||||||
|
limit: store.limit,
|
||||||
|
permission: store.permission,
|
||||||
|
})
|
||||||
if (next.length !== store.session.length) {
|
if (next.length !== store.session.length) {
|
||||||
setStore("session", reconcile(next, { key: "id" }))
|
setStore("session", reconcile(next, { key: "id" }))
|
||||||
}
|
}
|
||||||
@@ -187,10 +180,6 @@ function createGlobalSync() {
|
|||||||
directory,
|
directory,
|
||||||
limit,
|
limit,
|
||||||
list: (query) => globalSDK.client.session.list(query),
|
list: (query) => globalSDK.client.session.list(query),
|
||||||
onFallback: () => {
|
|
||||||
stats.loadSessionsFallback += 1
|
|
||||||
updateStats(Object.keys(children.children).length)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
@@ -199,10 +188,17 @@ function createGlobalSync() {
|
|||||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||||
const limit = store.limit
|
const limit = store.limit
|
||||||
const childSessions = store.session.filter((s) => !!s.parentID)
|
const childSessions = store.session.filter((s) => !!s.parentID)
|
||||||
const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
|
const sessions = trimSessions([...nonArchived, ...childSessions], {
|
||||||
|
limit,
|
||||||
|
permission: store.permission,
|
||||||
|
})
|
||||||
setStore(
|
setStore(
|
||||||
"sessionTotal",
|
"sessionTotal",
|
||||||
estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
|
estimateRootSessionTotal({
|
||||||
|
count: nonArchived.length,
|
||||||
|
limit: x.limit,
|
||||||
|
limited: x.limited,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
setStore("session", reconcile(sessions, { key: "id" }))
|
setStore("session", reconcile(sessions, { key: "id" }))
|
||||||
sessionMeta.set(directory, { limit })
|
sessionMeta.set(directory, { limit })
|
||||||
@@ -270,6 +266,11 @@ function createGlobalSync() {
|
|||||||
setGlobalStore("project", next)
|
setGlobalStore("project", next)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||||
|
for (const directory of Object.keys(children.children)) {
|
||||||
|
queue.push(directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +284,7 @@ function createGlobalSync() {
|
|||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
push: queue.push,
|
push: queue.push,
|
||||||
|
setSessionTodo,
|
||||||
vcsCache: children.vcsCache.get(directory),
|
vcsCache: children.vcsCache.get(directory),
|
||||||
loadLsp: () => {
|
loadLsp: () => {
|
||||||
sdkFor(directory)
|
sdkFor(directory)
|
||||||
@@ -306,7 +308,9 @@ function createGlobalSync() {
|
|||||||
await bootstrapGlobal({
|
await bootstrapGlobal({
|
||||||
globalSDK: globalSDK.client,
|
globalSDK: globalSDK.client,
|
||||||
connectErrorTitle: language.t("dialog.server.add.error"),
|
connectErrorTitle: language.t("dialog.server.add.error"),
|
||||||
connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
|
connectErrorDescription: language.t("error.globalSync.connectFailed", {
|
||||||
|
url: globalSDK.url,
|
||||||
|
}),
|
||||||
requestFailedTitle: language.t("common.requestFailed"),
|
requestFailedTitle: language.t("common.requestFailed"),
|
||||||
setGlobalStore,
|
setGlobalStore,
|
||||||
})
|
})
|
||||||
@@ -353,6 +357,9 @@ function createGlobalSync() {
|
|||||||
bootstrap,
|
bootstrap,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
|
todo: {
|
||||||
|
set: setSessionTodo,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import {
|
import type {
|
||||||
type Config,
|
Config,
|
||||||
type Path,
|
OpencodeClient,
|
||||||
type PermissionRequest,
|
Path,
|
||||||
type Project,
|
PermissionRequest,
|
||||||
type ProviderAuthResponse,
|
Project,
|
||||||
type ProviderListResponse,
|
ProviderAuthResponse,
|
||||||
type QuestionRequest,
|
ProviderListResponse,
|
||||||
createOpencodeClient,
|
QuestionRequest,
|
||||||
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { cmp, normalizeProviderList } from "./utils"
|
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
|
import { cmp, normalizeProviderList } from "./utils"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
path: Path
|
path: Path
|
||||||
project: Project[]
|
project: Project[]
|
||||||
|
session_todo: {
|
||||||
|
[sessionID: string]: Todo[]
|
||||||
|
}
|
||||||
provider: ProviderListResponse
|
provider: ProviderListResponse
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: ProviderAuthResponse
|
||||||
config: Config
|
config: Config
|
||||||
@@ -27,7 +31,7 @@ type GlobalStore = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
globalSDK: ReturnType<typeof createOpencodeClient>
|
globalSDK: OpencodeClient
|
||||||
connectErrorTitle: string
|
connectErrorTitle: string
|
||||||
connectErrorDescription: string
|
connectErrorDescription: string
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
@@ -106,13 +110,13 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
|||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
sdk: ReturnType<typeof createOpencodeClient>
|
sdk: OpencodeClient
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
vcsCache: VcsCache
|
vcsCache: VcsCache
|
||||||
loadSessions: (directory: string) => Promise<void> | void
|
loadSessions: (directory: string) => Promise<void> | void
|
||||||
}) {
|
}) {
|
||||||
input.setStore("status", "loading")
|
if (input.store.status !== "complete") input.setStore("status", "loading")
|
||||||
|
|
||||||
const blockingRequests = {
|
const blockingRequests = {
|
||||||
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
|
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ describe("createChildStoreManager", () => {
|
|||||||
|
|
||||||
const manager = createChildStoreManager({
|
const manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
markStats() {},
|
|
||||||
incrementEvictions() {},
|
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
isLoadingSessions: () => false,
|
isLoadingSessions: () => false,
|
||||||
onBootstrap() {},
|
onBootstrap() {},
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
|||||||
|
|
||||||
export function createChildStoreManager(input: {
|
export function createChildStoreManager(input: {
|
||||||
owner: Owner
|
owner: Owner
|
||||||
markStats: (activeDirectoryStores: number) => void
|
|
||||||
incrementEvictions: () => void
|
|
||||||
isBooting: (directory: string) => boolean
|
isBooting: (directory: string) => boolean
|
||||||
isLoadingSessions: (directory: string) => boolean
|
isLoadingSessions: (directory: string) => boolean
|
||||||
onBootstrap: (directory: string) => void
|
onBootstrap: (directory: string) => void
|
||||||
@@ -102,7 +100,6 @@ export function createChildStoreManager(input: {
|
|||||||
}
|
}
|
||||||
delete children[directory]
|
delete children[directory]
|
||||||
input.onDispose(directory)
|
input.onDispose(directory)
|
||||||
input.markStats(Object.keys(children).length)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +117,6 @@ export function createChildStoreManager(input: {
|
|||||||
if (list.length === 0) return
|
if (list.length === 0) return
|
||||||
for (const directory of list) {
|
for (const directory of list) {
|
||||||
if (!disposeDirectory(directory)) continue
|
if (!disposeDirectory(directory)) continue
|
||||||
input.incrementEvictions()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +196,6 @@ export function createChildStoreManager(input: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
runWithOwner(input.owner, init)
|
runWithOwner(input.owner, init)
|
||||||
input.markStats(Object.keys(children).length)
|
|
||||||
}
|
}
|
||||||
mark(directory)
|
mark(directory)
|
||||||
const childStore = children[directory]
|
const childStore = children[directory]
|
||||||
|
|||||||
@@ -116,6 +116,20 @@ describe("applyGlobalEvent", () => {
|
|||||||
|
|
||||||
expect(refreshCount).toBe(1)
|
expect(refreshCount).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("handles server.connected by triggering refresh", () => {
|
||||||
|
let refreshCount = 0
|
||||||
|
applyGlobalEvent({
|
||||||
|
event: { type: "server.connected" },
|
||||||
|
project: [],
|
||||||
|
refresh: () => {
|
||||||
|
refreshCount += 1
|
||||||
|
},
|
||||||
|
setGlobalProject() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(refreshCount).toBe(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("applyDirectoryEvent", () => {
|
describe("applyDirectoryEvent", () => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function applyGlobalEvent(input: {
|
|||||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
|
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
|
||||||
refresh: () => void
|
refresh: () => void
|
||||||
}) {
|
}) {
|
||||||
if (input.event.type === "global.disposed") {
|
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
|
||||||
input.refresh()
|
input.refresh()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,12 @@ export function applyGlobalEvent(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
|
function cleanupSessionCaches(
|
||||||
|
store: Store<State>,
|
||||||
|
setStore: SetStoreFunction<State>,
|
||||||
|
sessionID: string,
|
||||||
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||||
|
) {
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
const hasAny =
|
const hasAny =
|
||||||
store.message[sessionID] !== undefined ||
|
store.message[sessionID] !== undefined ||
|
||||||
@@ -48,6 +53,7 @@ function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<St
|
|||||||
store.permission[sessionID] !== undefined ||
|
store.permission[sessionID] !== undefined ||
|
||||||
store.question[sessionID] !== undefined ||
|
store.question[sessionID] !== undefined ||
|
||||||
store.session_status[sessionID] !== undefined
|
store.session_status[sessionID] !== undefined
|
||||||
|
setSessionTodo?.(sessionID, undefined)
|
||||||
if (!hasAny) return
|
if (!hasAny) return
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -77,6 +83,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
directory: string
|
directory: string
|
||||||
loadLsp: () => void
|
loadLsp: () => void
|
||||||
vcsCache?: VcsCache
|
vcsCache?: VcsCache
|
||||||
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||||
}) {
|
}) {
|
||||||
const event = input.event
|
const event = input.event
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -110,7 +117,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cleanupSessionCaches(input.store, input.setStore, info.id)
|
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
@@ -136,7 +143,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cleanupSessionCaches(input.store, input.setStore, info.id)
|
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
@@ -149,6 +156,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
case "todo.updated": {
|
case "todo.updated": {
|
||||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||||
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||||
|
input.setSessionTodo?.(props.sessionID, props.todos)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.status": {
|
case "session.status": {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
|||||||
limited: true,
|
limited: true,
|
||||||
} as const
|
} as const
|
||||||
} catch {
|
} catch {
|
||||||
input.onFallback()
|
|
||||||
const result = await input.list({ directory: input.directory, roots: true })
|
const result = await input.list({ directory: input.directory, roots: true })
|
||||||
return {
|
return {
|
||||||
data: result.data,
|
data: result.data,
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ export type RootLoadArgs = {
|
|||||||
directory: string
|
directory: string
|
||||||
limit: number
|
limit: number
|
||||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
||||||
onFallback: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RootLoadResult = {
|
export type RootLoadResult = {
|
||||||
|
|||||||
@@ -174,6 +174,10 @@ function detectLocale(): Locale {
|
|||||||
return "en"
|
return "en"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLocale(value: string): Locale {
|
||||||
|
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||||
name: "Language",
|
name: "Language",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -184,15 +188,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const locale = createMemo<Locale>(() =>
|
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||||
LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en",
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const current = locale()
|
|
||||||
if (store.locale === current) return
|
|
||||||
setStore("locale", current)
|
|
||||||
})
|
|
||||||
|
|
||||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||||
|
|
||||||
@@ -213,7 +209,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
label,
|
label,
|
||||||
t,
|
t,
|
||||||
setLocale(next: Locale) {
|
setLocale(next: Locale) {
|
||||||
setStore("locale", next)
|
setStore("locale", normalizeLocale(next))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
|
|
||||||
type PickerPaths = string | string[] | null
|
type PickerPaths = string | string[] | null
|
||||||
@@ -58,7 +58,7 @@ export type Platform = {
|
|||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
|
|
||||||
/** Get the configured default server URL (platform-specific) */
|
/** Get the configured default server URL (platform-specific) */
|
||||||
getDefaultServerUrl?(): Promise<string | null> | string | null
|
getDefaultServerUrl?(): Promise<string | null>
|
||||||
|
|
||||||
/** Set the default server URL to use on app startup (platform-specific) */
|
/** Set the default server URL to use on app startup (platform-specific) */
|
||||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { usePlatform } from "./platform"
|
|
||||||
|
|
||||||
type SDKEventMap = {
|
type SDKEventMap = {
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
@@ -12,14 +11,11 @@ type SDKEventMap = {
|
|||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
init: (props: { directory: Accessor<string> }) => {
|
init: (props: { directory: Accessor<string> }) => {
|
||||||
const platform = usePlatform()
|
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
|
|
||||||
const directory = createMemo(props.directory)
|
const directory = createMemo(props.directory)
|
||||||
const client = createMemo(() =>
|
const client = createMemo(() =>
|
||||||
createOpencodeClient({
|
globalSDK.createClient({
|
||||||
baseUrl: globalSDK.url,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
directory: directory(),
|
directory: directory(),
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
}),
|
}),
|
||||||
@@ -45,6 +41,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
get url() {
|
get url() {
|
||||||
return globalSDK.url
|
return globalSDK.url
|
||||||
},
|
},
|
||||||
|
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
|
||||||
|
return globalSDK.createClient(opts)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -15,92 +15,126 @@ export function normalizeServerUrl(input: string) {
|
|||||||
return withProtocol.replace(/\/+$/, "")
|
return withProtocol.replace(/\/+$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serverDisplayName(url: string) {
|
export function serverDisplayName(conn?: ServerConnection.Any) {
|
||||||
if (!url) return ""
|
if (!conn) return ""
|
||||||
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
if (conn.displayName) return conn.displayName
|
||||||
|
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectsKey(url: string) {
|
function projectsKey(key: ServerConnection.Key) {
|
||||||
if (!url) return ""
|
if (!key) return ""
|
||||||
|
if (key === "sidecar") return "local"
|
||||||
|
if (isLocalHost(key)) return "local"
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalHost(url: string) {
|
||||||
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
||||||
if (host === "localhost" || host === "127.0.0.1") return "local"
|
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||||
return url
|
}
|
||||||
|
|
||||||
|
export namespace ServerConnection {
|
||||||
|
type Base = { displayName?: string }
|
||||||
|
|
||||||
|
export type HttpBase = {
|
||||||
|
url: string
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular web connections
|
||||||
|
export type Http = {
|
||||||
|
type: "http"
|
||||||
|
http: HttpBase
|
||||||
|
} & Base
|
||||||
|
|
||||||
|
export type Sidecar = {
|
||||||
|
type: "sidecar"
|
||||||
|
http: HttpBase
|
||||||
|
} & (
|
||||||
|
| // Regular desktop server
|
||||||
|
{ variant: "base" }
|
||||||
|
// WSL server (windows only)
|
||||||
|
| {
|
||||||
|
variant: "wsl"
|
||||||
|
distro: string
|
||||||
|
}
|
||||||
|
) &
|
||||||
|
Base
|
||||||
|
|
||||||
|
// Remote server desktop can SSH into
|
||||||
|
export type Ssh = {
|
||||||
|
type: "ssh"
|
||||||
|
host: string
|
||||||
|
// SSH client exposes an HTTP server for the app to use as a proxy
|
||||||
|
http: HttpBase
|
||||||
|
} & Base
|
||||||
|
|
||||||
|
export type Any =
|
||||||
|
| Http
|
||||||
|
// All these are desktop-only
|
||||||
|
| (Sidecar | Ssh)
|
||||||
|
|
||||||
|
export const key = (conn: Any): Key => {
|
||||||
|
switch (conn.type) {
|
||||||
|
case "http":
|
||||||
|
return Key.make(conn.http.url)
|
||||||
|
case "sidecar": {
|
||||||
|
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
|
||||||
|
return Key.make("sidecar")
|
||||||
|
}
|
||||||
|
case "ssh":
|
||||||
|
return Key.make(`ssh:${conn.host}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Key = string & { _brand: "Key" }
|
||||||
|
export const Key = { make: (v: string) => v as Key }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||||
name: "Server",
|
name: "Server",
|
||||||
init: (props: { defaultUrl: string; isSidecar?: boolean }) => {
|
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("server", ["server.v3"]),
|
Persist.global("server", ["server.v3"]),
|
||||||
createStore({
|
createStore({
|
||||||
list: [] as string[],
|
list: [] as string[],
|
||||||
currentSidecarUrl: "",
|
|
||||||
projects: {} as Record<string, StoredProject[]>,
|
projects: {} as Record<string, StoredProject[]>,
|
||||||
lastProject: {} as Record<string, string>,
|
lastProject: {} as Record<string, string>,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||||
|
const servers = [
|
||||||
|
...(props.servers ?? []),
|
||||||
|
...store.list.map((value) => ({
|
||||||
|
type: "http" as const,
|
||||||
|
http: typeof value === "string" ? { url: value } : value,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
const deduped = new Map(servers.map((conn) => [ServerConnection.key(conn), conn]))
|
||||||
|
|
||||||
|
return [...deduped.values()]
|
||||||
|
})
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
active: "",
|
active: props.defaultServer,
|
||||||
healthy: undefined as boolean | undefined,
|
healthy: undefined as boolean | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const healthy = () => state.healthy
|
const healthy = () => state.healthy
|
||||||
|
|
||||||
const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
|
function startHealthPolling(conn: ServerConnection.Any) {
|
||||||
|
|
||||||
function reconcileStartup() {
|
|
||||||
const fallback = defaultUrl()
|
|
||||||
if (!fallback) return
|
|
||||||
|
|
||||||
const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
|
|
||||||
const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
|
|
||||||
if (!props.isSidecar) {
|
|
||||||
batch(() => {
|
|
||||||
setStore("list", list)
|
|
||||||
if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
|
|
||||||
setState("active", fallback)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextList = list.includes(fallback) ? list : [...list, fallback]
|
|
||||||
batch(() => {
|
|
||||||
setStore("list", nextList)
|
|
||||||
setStore("currentSidecarUrl", fallback)
|
|
||||||
setState("active", fallback)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateServerList(url: string, remove = false) {
|
|
||||||
if (remove) {
|
|
||||||
const list = store.list.filter((x) => x !== url)
|
|
||||||
const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
|
|
||||||
batch(() => {
|
|
||||||
setStore("list", list)
|
|
||||||
setState("active", next)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
batch(() => {
|
|
||||||
if (!store.list.includes(url)) {
|
|
||||||
setStore("list", store.list.length, url)
|
|
||||||
}
|
|
||||||
setState("active", url)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function startHealthPolling(url: string) {
|
|
||||||
let alive = true
|
let alive = true
|
||||||
let busy = false
|
let busy = false
|
||||||
|
|
||||||
const run = () => {
|
const run = () => {
|
||||||
if (busy) return
|
if (busy) return
|
||||||
busy = true
|
busy = true
|
||||||
void check(url)
|
void check(conn)
|
||||||
.then((next) => {
|
.then((next) => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
setState("healthy", next)
|
setState("healthy", next)
|
||||||
@@ -118,59 +152,73 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActive(input: string) {
|
function setActive(input: ServerConnection.Key) {
|
||||||
const url = normalizeServerUrl(input)
|
if (state.active !== input) setState("active", input)
|
||||||
if (!url) return
|
|
||||||
setState("active", url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function add(input: string) {
|
function add(input: string) {
|
||||||
const url = normalizeServerUrl(input)
|
const url = normalizeServerUrl(input)
|
||||||
if (!url) return
|
if (!url) return
|
||||||
updateServerList(url)
|
return batch(() => {
|
||||||
|
const http: ServerConnection.HttpBase = { url }
|
||||||
|
if (!store.list.includes(url)) {
|
||||||
|
setStore("list", store.list.length, url)
|
||||||
|
}
|
||||||
|
const conn: ServerConnection.Http = { type: "http", http }
|
||||||
|
setState("active", ServerConnection.key(conn))
|
||||||
|
return conn
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(input: string) {
|
function remove(key: ServerConnection.Key) {
|
||||||
const url = normalizeServerUrl(input)
|
const list = store.list.filter((x) => x !== key)
|
||||||
if (!url) return
|
batch(() => {
|
||||||
updateServerList(url, true)
|
setStore("list", list)
|
||||||
|
if (state.active === key) {
|
||||||
|
const next = list[0]
|
||||||
|
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready()) return
|
|
||||||
if (state.active) return
|
|
||||||
reconcileStartup()
|
|
||||||
})
|
|
||||||
|
|
||||||
const isReady = createMemo(() => ready() && !!state.active)
|
const isReady = createMemo(() => ready() && !!state.active)
|
||||||
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
|
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const url = state.active
|
const current_ = current()
|
||||||
if (!url) return
|
if (!current_) return
|
||||||
|
|
||||||
setState("healthy", undefined)
|
setState("healthy", undefined)
|
||||||
onCleanup(startHealthPolling(url))
|
onCleanup(startHealthPolling(current_))
|
||||||
})
|
})
|
||||||
|
|
||||||
const origin = createMemo(() => projectsKey(state.active))
|
const origin = createMemo(() => projectsKey(state.active))
|
||||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||||
const isLocal = createMemo(() => origin() === "local")
|
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
|
||||||
|
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
|
||||||
|
)
|
||||||
|
const isLocal = createMemo(() => {
|
||||||
|
const c = current()
|
||||||
|
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: isReady,
|
ready: isReady,
|
||||||
healthy,
|
healthy,
|
||||||
isLocal,
|
isLocal,
|
||||||
get url() {
|
get key() {
|
||||||
return state.active
|
return state.active
|
||||||
},
|
},
|
||||||
get name() {
|
get name() {
|
||||||
return serverDisplayName(state.active)
|
return serverDisplayName(current())
|
||||||
},
|
},
|
||||||
get list() {
|
get list() {
|
||||||
return store.list
|
return allServers()
|
||||||
|
},
|
||||||
|
get current() {
|
||||||
|
return current()
|
||||||
},
|
},
|
||||||
setActive,
|
setActive,
|
||||||
add,
|
add,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const monoFonts: Record<string, string> = {
|
|||||||
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
|
"geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function monoFontFamily(font: string | undefined) {
|
export function monoFontFamily(font: string | undefined) {
|
||||||
|
|||||||
@@ -289,12 +289,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
if (store.todo[sessionID] !== undefined) return
|
const existing = store.todo[sessionID]
|
||||||
|
if (existing !== undefined) {
|
||||||
|
if (globalSync.data.session_todo[sessionID] === undefined) {
|
||||||
|
globalSync.todo.set(sessionID, existing)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = globalSync.data.session_todo[sessionID]
|
||||||
|
if (cached !== undefined) {
|
||||||
|
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
|
||||||
|
}
|
||||||
|
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
return runInflight(inflightTodo, key, () =>
|
return runInflight(inflightTodo, key, () =>
|
||||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
const list = todo.data ?? []
|
||||||
|
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||||
|
globalSync.todo.set(sessionID, list)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
// @refresh reload
|
// @refresh reload
|
||||||
|
|
||||||
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
import { render } from "solid-js/web"
|
import { render } from "solid-js/web"
|
||||||
import { AppBaseProviders, AppInterface } from "@/app"
|
import { AppBaseProviders, AppInterface } from "@/app"
|
||||||
import { Platform, PlatformProvider } from "@/context/platform"
|
import { type Platform, PlatformProvider } from "@/context/platform"
|
||||||
import { dict as en } from "@/i18n/en"
|
import { dict as en } from "@/i18n/en"
|
||||||
import { dict as zh } from "@/i18n/zh"
|
import { dict as zh } from "@/i18n/zh"
|
||||||
import { handleNotificationClick } from "@/utils/notification-click"
|
import { handleNotificationClick } from "@/utils/notification-click"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
|
import { ServerConnection } from "./context/server"
|
||||||
|
|
||||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||||
|
|
||||||
@@ -103,16 +106,26 @@ const platform: Platform = {
|
|||||||
forward,
|
forward,
|
||||||
restart,
|
restart,
|
||||||
notify,
|
notify,
|
||||||
getDefaultServerUrl: readDefaultServerUrl,
|
getDefaultServerUrl: async () => readDefaultServerUrl(),
|
||||||
setDefaultServerUrl: writeDefaultServerUrl,
|
setDefaultServerUrl: writeDefaultServerUrl,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultUrl = iife(() => {
|
||||||
|
const lsDefault = readDefaultServerUrl()
|
||||||
|
if (lsDefault) return lsDefault
|
||||||
|
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
|
if (import.meta.env.DEV)
|
||||||
|
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||||
|
return location.origin
|
||||||
|
})
|
||||||
|
|
||||||
if (root instanceof HTMLElement) {
|
if (root instanceof HTMLElement) {
|
||||||
|
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
|
||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
<AppBaseProviders>
|
<AppBaseProviders>
|
||||||
<AppInterface />
|
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
|
||||||
</AppBaseProviders>
|
</AppBaseProviders>
|
||||||
</PlatformProvider>
|
</PlatformProvider>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
|
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
|
||||||
"command.model.variant.cycle": "تغيير جهد التفكير",
|
"command.model.variant.cycle": "تغيير جهد التفكير",
|
||||||
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
|
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
|
||||||
|
"command.prompt.mode.shell": "Shell",
|
||||||
|
"command.prompt.mode.normal": "Prompt",
|
||||||
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
|
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
|
||||||
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
|
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
|
||||||
"command.workspace.toggle": "تبديل مساحات العمل",
|
"command.workspace.toggle": "تبديل مساحات العمل",
|
||||||
@@ -206,9 +208,11 @@ export const dict = {
|
|||||||
"common.attachment": "مرفق",
|
"common.attachment": "مرفق",
|
||||||
"prompt.placeholder.shell": "أدخل أمر shell...",
|
"prompt.placeholder.shell": "أدخل أمر shell...",
|
||||||
"prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"',
|
"prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"',
|
||||||
|
"prompt.placeholder.simple": "اسأل أي شيء...",
|
||||||
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
|
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
|
||||||
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
|
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc للخروج",
|
"prompt.mode.shell.exit": "esc للخروج",
|
||||||
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
|
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
|
||||||
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",
|
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",
|
||||||
@@ -447,6 +451,9 @@ export const dict = {
|
|||||||
"session.messages.loading": "جارٍ تحميل الرسائل...",
|
"session.messages.loading": "جارٍ تحميل الرسائل...",
|
||||||
"session.messages.jumpToLatest": "الانتقال إلى الأحدث",
|
"session.messages.jumpToLatest": "الانتقال إلى الأحدث",
|
||||||
"session.context.addToContext": "إضافة {{selection}} إلى السياق",
|
"session.context.addToContext": "إضافة {{selection}} إلى السياق",
|
||||||
|
"session.todo.title": "المهام",
|
||||||
|
"session.todo.collapse": "طي",
|
||||||
|
"session.todo.expand": "توسيع",
|
||||||
"session.new.worktree.main": "الفرع الرئيسي",
|
"session.new.worktree.main": "الفرع الرئيسي",
|
||||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||||
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
||||||
@@ -509,6 +516,7 @@ export const dict = {
|
|||||||
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
|
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
|
||||||
"sidebar.project.recentSessions": "الجلسات الحديثة",
|
"sidebar.project.recentSessions": "الجلسات الحديثة",
|
||||||
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
|
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
|
||||||
|
"sidebar.project.clearNotifications": "مسح الإشعارات",
|
||||||
"app.name.desktop": "OpenCode Desktop",
|
"app.name.desktop": "OpenCode Desktop",
|
||||||
"settings.section.desktop": "سطح المكتب",
|
"settings.section.desktop": "سطح المكتب",
|
||||||
"settings.section.server": "الخادم",
|
"settings.section.server": "الخادم",
|
||||||
@@ -556,6 +564,7 @@ export const dict = {
|
|||||||
"font.option.robotoMono": "Roboto Mono",
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
"font.option.sourceCodePro": "Source Code Pro",
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.alert01": "تنبيه 01",
|
"sound.option.alert01": "تنبيه 01",
|
||||||
"sound.option.alert02": "تنبيه 02",
|
"sound.option.alert02": "تنبيه 02",
|
||||||
"sound.option.alert03": "تنبيه 03",
|
"sound.option.alert03": "تنبيه 03",
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Mudar para o agente anterior",
|
"command.agent.cycle.reverse.description": "Mudar para o agente anterior",
|
||||||
"command.model.variant.cycle": "Alternar nível de raciocínio",
|
"command.model.variant.cycle": "Alternar nível de raciocínio",
|
||||||
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
|
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
|
||||||
|
"command.prompt.mode.shell": "Shell",
|
||||||
|
"command.prompt.mode.normal": "Prompt",
|
||||||
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
|
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
|
||||||
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
|
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
|
||||||
"command.workspace.toggle": "Alternar espaços de trabalho",
|
"command.workspace.toggle": "Alternar espaços de trabalho",
|
||||||
@@ -206,9 +208,11 @@ export const dict = {
|
|||||||
"common.attachment": "anexo",
|
"common.attachment": "anexo",
|
||||||
"prompt.placeholder.shell": "Digite comando do shell...",
|
"prompt.placeholder.shell": "Digite comando do shell...",
|
||||||
"prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"',
|
"prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"',
|
||||||
|
"prompt.placeholder.simple": "Pergunte qualquer coisa...",
|
||||||
"prompt.placeholder.summarizeComments": "Resumir comentários…",
|
"prompt.placeholder.summarizeComments": "Resumir comentários…",
|
||||||
"prompt.placeholder.summarizeComment": "Resumir comentário…",
|
"prompt.placeholder.summarizeComment": "Resumir comentário…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc para sair",
|
"prompt.mode.shell.exit": "esc para sair",
|
||||||
"prompt.example.1": "Corrigir um TODO no código",
|
"prompt.example.1": "Corrigir um TODO no código",
|
||||||
"prompt.example.2": "Qual é a stack tecnológica deste projeto?",
|
"prompt.example.2": "Qual é a stack tecnológica deste projeto?",
|
||||||
@@ -450,6 +454,9 @@ export const dict = {
|
|||||||
"session.messages.loading": "Carregando mensagens...",
|
"session.messages.loading": "Carregando mensagens...",
|
||||||
"session.messages.jumpToLatest": "Ir para a mais recente",
|
"session.messages.jumpToLatest": "Ir para a mais recente",
|
||||||
"session.context.addToContext": "Adicionar {{selection}} ao contexto",
|
"session.context.addToContext": "Adicionar {{selection}} ao contexto",
|
||||||
|
"session.todo.title": "Tarefas",
|
||||||
|
"session.todo.collapse": "Recolher",
|
||||||
|
"session.todo.expand": "Expandir",
|
||||||
"session.new.worktree.main": "Branch principal",
|
"session.new.worktree.main": "Branch principal",
|
||||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||||
"session.new.worktree.create": "Criar novo worktree",
|
"session.new.worktree.create": "Criar novo worktree",
|
||||||
@@ -515,6 +522,7 @@ export const dict = {
|
|||||||
"sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.",
|
"sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.",
|
||||||
"sidebar.project.recentSessions": "Sessões recentes",
|
"sidebar.project.recentSessions": "Sessões recentes",
|
||||||
"sidebar.project.viewAllSessions": "Ver todas as sessões",
|
"sidebar.project.viewAllSessions": "Ver todas as sessões",
|
||||||
|
"sidebar.project.clearNotifications": "Limpar notificações",
|
||||||
"app.name.desktop": "OpenCode Desktop",
|
"app.name.desktop": "OpenCode Desktop",
|
||||||
"settings.section.desktop": "Desktop",
|
"settings.section.desktop": "Desktop",
|
||||||
"settings.section.server": "Servidor",
|
"settings.section.server": "Servidor",
|
||||||
@@ -562,6 +570,7 @@ export const dict = {
|
|||||||
"font.option.robotoMono": "Roboto Mono",
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
"font.option.sourceCodePro": "Source Code Pro",
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.alert01": "Alerta 01",
|
"sound.option.alert01": "Alerta 01",
|
||||||
"sound.option.alert02": "Alerta 02",
|
"sound.option.alert02": "Alerta 02",
|
||||||
"sound.option.alert03": "Alerta 03",
|
"sound.option.alert03": "Alerta 03",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Prebaci na prethodnog agenta",
|
"command.agent.cycle.reverse.description": "Prebaci na prethodnog agenta",
|
||||||
"command.model.variant.cycle": "Promijeni nivo razmišljanja",
|
"command.model.variant.cycle": "Promijeni nivo razmišljanja",
|
||||||
"command.model.variant.cycle.description": "Prebaci na sljedeći nivo",
|
"command.model.variant.cycle.description": "Prebaci na sljedeći nivo",
|
||||||
|
"command.prompt.mode.shell": "Shell",
|
||||||
|
"command.prompt.mode.normal": "Prompt",
|
||||||
"command.permissions.autoaccept.enable": "Automatski prihvataj izmjene",
|
"command.permissions.autoaccept.enable": "Automatski prihvataj izmjene",
|
||||||
"command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena",
|
"command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena",
|
||||||
"command.workspace.toggle": "Prikaži/sakrij radne prostore",
|
"command.workspace.toggle": "Prikaži/sakrij radne prostore",
|
||||||
@@ -224,9 +226,11 @@ export const dict = {
|
|||||||
|
|
||||||
"prompt.placeholder.shell": "Unesi shell naredbu...",
|
"prompt.placeholder.shell": "Unesi shell naredbu...",
|
||||||
"prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"',
|
"prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"',
|
||||||
|
"prompt.placeholder.simple": "Pitaj bilo šta...",
|
||||||
"prompt.placeholder.summarizeComments": "Sažmi komentare…",
|
"prompt.placeholder.summarizeComments": "Sažmi komentare…",
|
||||||
"prompt.placeholder.summarizeComment": "Sažmi komentar…",
|
"prompt.placeholder.summarizeComment": "Sažmi komentar…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc za izlaz",
|
"prompt.mode.shell.exit": "esc za izlaz",
|
||||||
|
|
||||||
"prompt.example.1": "Popravi TODO u bazi koda",
|
"prompt.example.1": "Popravi TODO u bazi koda",
|
||||||
@@ -505,6 +509,9 @@ export const dict = {
|
|||||||
"session.messages.jumpToLatest": "Idi na najnovije",
|
"session.messages.jumpToLatest": "Idi na najnovije",
|
||||||
|
|
||||||
"session.context.addToContext": "Dodaj {{selection}} u kontekst",
|
"session.context.addToContext": "Dodaj {{selection}} u kontekst",
|
||||||
|
"session.todo.title": "Zadaci",
|
||||||
|
"session.todo.collapse": "Sažmi",
|
||||||
|
"session.todo.expand": "Proširi",
|
||||||
|
|
||||||
"session.new.worktree.main": "Glavna grana",
|
"session.new.worktree.main": "Glavna grana",
|
||||||
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
|
||||||
@@ -576,6 +583,7 @@ export const dict = {
|
|||||||
"sidebar.gettingStarted.line2": "Poveži bilo kojeg provajdera da koristiš modele, npr. Claude, GPT, Gemini itd.",
|
"sidebar.gettingStarted.line2": "Poveži bilo kojeg provajdera da koristiš modele, npr. Claude, GPT, Gemini itd.",
|
||||||
"sidebar.project.recentSessions": "Nedavne sesije",
|
"sidebar.project.recentSessions": "Nedavne sesije",
|
||||||
"sidebar.project.viewAllSessions": "Prikaži sve sesije",
|
"sidebar.project.viewAllSessions": "Prikaži sve sesije",
|
||||||
|
"sidebar.project.clearNotifications": "Očisti obavijesti",
|
||||||
|
|
||||||
"app.name.desktop": "OpenCode Desktop",
|
"app.name.desktop": "OpenCode Desktop",
|
||||||
|
|
||||||
@@ -630,6 +638,7 @@ export const dict = {
|
|||||||
"font.option.robotoMono": "Roboto Mono",
|
"font.option.robotoMono": "Roboto Mono",
|
||||||
"font.option.sourceCodePro": "Source Code Pro",
|
"font.option.sourceCodePro": "Source Code Pro",
|
||||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||||
|
"font.option.geistMono": "Geist Mono",
|
||||||
"sound.option.alert01": "Upozorenje 01",
|
"sound.option.alert01": "Upozorenje 01",
|
||||||
"sound.option.alert02": "Upozorenje 02",
|
"sound.option.alert02": "Upozorenje 02",
|
||||||
"sound.option.alert03": "Upozorenje 03",
|
"sound.option.alert03": "Upozorenje 03",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user