mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
Compare commits
467 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e92b4932 | ||
|
|
83397ebde2 | ||
|
|
fde74a72bb | ||
|
|
10ee8e5b3d | ||
|
|
96d3f1fe7c | ||
|
|
1a2b656c4d | ||
|
|
161e9287a8 | ||
|
|
968543af39 | ||
|
|
5af35117db | ||
|
|
eab177f5e7 | ||
|
|
279dc04b3c | ||
|
|
cbc5903aa1 | ||
|
|
81c3c63895 | ||
|
|
b76bd4141d | ||
|
|
794fe8f381 | ||
|
|
a4eebf9f08 | ||
|
|
680a63e3de | ||
|
|
3a54ab68d1 | ||
|
|
44fd0eee64 | ||
|
|
ac371d2987 | ||
|
|
a7baa5ce18 | ||
|
|
b129f809b9 | ||
|
|
92c0ab51e2 | ||
|
|
b25418e68b | ||
|
|
046e351140 | ||
|
|
b9029afa22 | ||
|
|
b229aeec0b | ||
|
|
c9140c6bab | ||
|
|
38551bda38 | ||
|
|
cd16d31510 | ||
|
|
54ba1af5d6 | ||
| fe3144ce5b | |||
|
|
a1c0bae3af | ||
|
|
85f8655dfd | ||
|
|
9b6c9f64f7 | ||
|
|
1aae1c795d | ||
|
|
526c723e62 | ||
|
|
6011200128 | ||
|
|
740fcd243c | ||
|
|
e4d8a117c4 | ||
|
|
8c4a816cf6 | ||
|
|
5605fc3f38 | ||
|
|
009b096004 | ||
|
|
64f898601b | ||
|
|
224e5466c1 | ||
|
|
87b5b34280 | ||
|
|
855fd07d22 | ||
|
|
f9be2bab3a | ||
|
|
25f1643e8e | ||
|
|
e015bea462 | ||
|
|
7dc55ac3ca | ||
|
|
cd8ecf9722 | ||
|
|
eb021a5f92 | ||
|
|
7f5e30834f | ||
|
|
750a936ae1 | ||
|
|
8dfef670b3 | ||
|
|
1b1b73b5b3 | ||
|
|
6baee0791f | ||
|
|
291b65977c | ||
|
|
90f232d7f1 | ||
|
|
af214d35cb | ||
|
|
3f0afd7cf6 | ||
|
|
0545c5da2d | ||
|
|
4a32fa6f02 | ||
|
|
29c99ed4ab | ||
|
|
753abbe164 | ||
|
|
8e01f6cc13 | ||
|
|
33c0b125cb | ||
|
|
dab2e54df8 | ||
|
|
60db171b44 | ||
|
|
c6e9a5c800 | ||
|
|
2c16b9fa61 | ||
|
|
240ad31edd | ||
|
|
a97631f769 | ||
|
|
dbaac79039 | ||
|
|
a05915ddc8 | ||
|
|
eebbd73346 | ||
|
|
d4c981495a | ||
|
|
653c206688 | ||
|
|
580f46b589 | ||
|
|
986d12fd20 | ||
|
|
d04a72a4ad | ||
|
|
5fd873a35a | ||
|
|
a9fbd786b3 | ||
|
|
abde984b3e | ||
|
|
a95aa037a3 | ||
|
|
11a92b24c2 | ||
|
|
f9c10c62d8 | ||
|
|
6339f39871 | ||
|
|
68b09b30a1 | ||
|
|
92ade2a320 | ||
|
|
cb1a1fb26c | ||
|
|
af5ebabd03 | ||
|
|
fe2626a4ea | ||
|
|
45447e3336 | ||
|
|
7a3e82ec5d | ||
|
|
345f4801e8 | ||
|
|
ac4b8d62e3 | ||
|
|
236ce7a8c0 | ||
|
|
8bdc0c8f79 | ||
|
|
04650f01fe | ||
|
|
02d4594abf | ||
|
|
c1894b4e3d | ||
|
|
2062247e72 | ||
|
|
8785bec29c | ||
|
|
d4b7f75ce3 | ||
|
|
4f73d58031 | ||
|
|
b906f2de88 | ||
|
|
4035afe5c8 | ||
|
|
8fe0715928 | ||
|
|
cb8af962cd | ||
|
|
c333ffa38b | ||
|
|
3456f4ed80 | ||
|
|
2536e9f45b | ||
|
|
9188bc542c | ||
|
|
cbaba10994 | ||
|
|
85d3604309 | ||
|
|
507ba644cf | ||
|
|
3d6f62746a | ||
|
|
2f48c8c05f | ||
|
|
4828fd1eac | ||
|
|
10375263ef | ||
|
|
ae00001aa0 | ||
|
|
f53ebafbab | ||
|
|
23ebc50da9 | ||
|
|
673c6f97b7 | ||
|
|
ec46f71258 | ||
|
|
8865e524cb | ||
|
|
36bb02ae45 | ||
|
|
5072331f04 | ||
|
|
9d48fd4bbd | ||
|
|
bf66390557 | ||
|
|
184643f0db | ||
|
|
1bce898ca7 | ||
|
|
8c895570c6 | ||
|
|
6dc4e5ac93 | ||
|
|
d3922f0965 | ||
|
|
cfaac9f2e1 | ||
|
|
0b046d6cf0 | ||
|
|
3d822e5f79 | ||
|
|
f9cef22a53 | ||
|
|
b5d7d3dec1 | ||
|
|
182630e0d7 | ||
|
|
c81506b28d | ||
|
|
6c40bfe043 | ||
|
|
9caaae6a18 | ||
|
|
ad6a5e6157 | ||
|
|
7dd8ea58c2 | ||
|
|
3b261e0125 | ||
|
|
426791f68a | ||
|
|
c7cade2494 | ||
|
|
8f6c8844d7 | ||
|
|
da6e0e60c0 | ||
|
|
d89b567b47 | ||
|
|
34eb03f5b8 | ||
|
|
2f6d15a51e | ||
|
|
8ffea80980 | ||
|
|
c87d61b561 | ||
|
|
35c12e2053 | ||
|
|
33d8bfc937 | ||
|
|
f2343a6794 | ||
|
|
bab000eeb5 | ||
|
|
8e674ae053 | ||
|
|
6a4f4009d5 | ||
|
|
5e79b95927 | ||
|
|
a7a2bbb497 | ||
|
|
6e93d14bdb | ||
|
|
f29f284b3e | ||
|
|
b1b8f6cf71 | ||
|
|
4c3336bbe7 | ||
|
|
354ac0b493 | ||
|
|
1d159c6858 | ||
|
|
d70639b256 | ||
|
|
e4a92f0084 | ||
|
|
fdf5a70a27 | ||
|
|
f71da42520 | ||
|
|
f6bdeb9e3a | ||
|
|
2400354bab | ||
|
|
db348c46cc | ||
|
|
49567fe61a | ||
|
|
e5b3f796e4 | ||
|
|
a9700c8773 | ||
|
|
26cf5e003e | ||
|
|
742cf10dee | ||
|
|
7664453f94 | ||
|
|
460672aa93 | ||
|
|
b4e4fd9807 | ||
|
|
34bdfd0937 | ||
|
|
84591ca8ad | ||
|
|
fd4d0c5c0b | ||
|
|
9f5db46911 | ||
|
|
755ddbb223 | ||
|
|
701d470d01 | ||
|
|
1d9058d26b | ||
|
|
39e2a5f595 | ||
|
|
f862ab6722 | ||
|
|
129d4f0b1b | ||
|
|
3a1e50d1f8 | ||
|
|
e2fb690d8e | ||
|
|
0a7f58a811 | ||
|
|
dae0168ed8 | ||
|
|
edfe2e4f1c | ||
|
|
1bc1ea8b47 | ||
|
|
dacbbe3184 | ||
|
|
89285d8f5f | ||
|
|
2e853911c3 | ||
|
|
695fdecf23 | ||
|
|
054d22791d | ||
|
|
4a57cc69d8 | ||
|
|
7e0c8db029 | ||
|
|
ba4cc3bf86 | ||
|
|
b19a424c85 | ||
|
|
1689281c35 | ||
|
|
cdbb59fae8 | ||
|
|
4eb311e98f | ||
|
|
80eac96258 | ||
|
|
4bad6f9f1b | ||
|
|
d7db57e8e1 | ||
|
|
943fbf39a3 | ||
|
|
d8a34c2fcc | ||
|
|
5720ed1f44 | ||
|
|
bb20a359e4 | ||
|
|
0d472a49a0 | ||
|
|
203581e82f | ||
|
|
677631916c | ||
|
|
1aa1e8c904 | ||
|
|
55d62fbd9f | ||
|
|
e1ad2a355c | ||
|
|
4f318f913e | ||
|
|
2d814b6db2 | ||
|
|
e561f1ad68 | ||
|
|
ebfb985215 | ||
|
|
2646da50df | ||
|
|
50a5f6e53b | ||
|
|
d03fac52e7 | ||
|
|
6a802c01cd | ||
|
|
14146428dd | ||
|
|
26d0280f70 | ||
|
|
3274a5813e | ||
|
|
382905602c | ||
|
|
8b5cea7899 | ||
|
|
100c31cbb1 | ||
|
|
0b286f1b84 | ||
|
|
2f6ca958fe | ||
|
|
5218e7a546 | ||
|
|
7f8e799392 | ||
|
|
289f4abaaa | ||
|
|
7ce898ce43 | ||
|
|
0dd716a75e | ||
|
|
87171467fa | ||
|
|
b99afdad91 | ||
|
|
4fd576f3af | ||
|
|
2f41d0bedd | ||
|
|
5f03290534 | ||
|
|
427157c683 | ||
|
|
a0ab3d98b7 | ||
|
|
c8de766913 | ||
|
|
d57b963141 | ||
|
|
0ebcaff927 | ||
|
|
15931fa170 | ||
|
|
af4087d7b5 | ||
|
|
323ea1040c | ||
|
|
1fe87b0233 | ||
|
|
8d11df1b3b | ||
|
|
ecc5050838 | ||
|
|
606cf3b6f2 | ||
|
|
67cfd7f06b | ||
|
|
ab9ac7c87a | ||
|
|
ee9f979613 | ||
|
|
228b6444f8 | ||
|
|
9998efdae2 | ||
|
|
9427f56e1a | ||
|
|
a6dd35d73d | ||
|
|
faeaafa5f5 | ||
|
|
8b298a233e | ||
|
|
6f43d03043 | ||
|
|
c868a4088d | ||
|
|
83d8a88c90 | ||
|
|
268f37f8c9 | ||
|
|
b0aaf04957 | ||
|
|
b7875256f3 | ||
|
|
7bc47fb904 | ||
|
|
5cf8e54372 | ||
|
|
7437ccd6f4 | ||
|
|
4bf882ba81 | ||
|
|
d5dcc55a47 | ||
|
|
e1925f4fe8 | ||
|
|
ee3d034e16 | ||
|
|
257a4d5b86 | ||
|
|
1fc5836f64 | ||
|
|
2fb89161c8 | ||
|
|
251fbc0a99 | ||
|
|
0da901a188 | ||
|
|
17221e6ffe | ||
|
|
cc9f88ac8f | ||
|
|
fe65ed6a61 | ||
|
|
e37a75a411 | ||
|
|
194ff4919c | ||
|
|
83843a794f | ||
|
|
235a60d3c2 | ||
|
|
b70d186bd1 | ||
|
|
647331de28 | ||
|
|
57ef115375 | ||
|
|
942498211f | ||
|
|
e789fcf5e5 | ||
|
|
b9fb180bc6 | ||
|
|
7427b887f9 | ||
|
|
289b2b6a51 | ||
|
|
49b4b5907e | ||
|
|
f82442c123 | ||
|
|
e682cc9daf | ||
|
|
d359e086a4 | ||
|
|
f949755367 | ||
|
|
a168d854f4 | ||
|
|
31645f5578 | ||
|
|
a1b68daa9a | ||
|
|
ca65da2d9e | ||
|
|
e48d804d84 | ||
|
|
b4209582fb | ||
|
|
dbdea2f659 | ||
|
|
a50ab4b5b5 | ||
|
|
4d7c3f56fa | ||
|
|
16b41d2bea | ||
|
|
a8c499ae8f | ||
|
|
24430287c5 | ||
|
|
1f52731255 | ||
|
|
4a3ba58f65 | ||
|
|
2a3a8a1ec2 | ||
|
|
69e562125d | ||
|
|
b5e97eb338 | ||
|
|
16e6941495 | ||
|
|
f033e0317e | ||
|
|
ddd88f92cc | ||
|
|
99101edc13 | ||
|
|
6e85a07977 | ||
|
|
be1a3536ae | ||
|
|
1e4bfbcf6f | ||
|
|
204e3bf382 | ||
|
|
8fb014a48d | ||
|
|
57c3cf1f8b | ||
|
|
f9d0850c5e | ||
|
|
8864da7a77 | ||
|
|
1b39199083 | ||
|
|
b8204c0bb7 | ||
|
|
fe8c5c143e | ||
|
|
d6f86e9bb7 | ||
|
|
bf00b2bfc9 | ||
|
|
382ec8fb2c | ||
|
|
6454adcd69 | ||
|
|
99548554d7 | ||
|
|
751899eeec | ||
|
|
f8df1d3185 | ||
|
|
b07a47fc89 | ||
|
|
c6f84f32d7 | ||
|
|
ebe25c3e9a | ||
|
|
65d7fc3ccd | ||
|
|
4f3037d803 | ||
|
|
5c490c51ed | ||
|
|
5da1c0087b | ||
|
|
4375149e63 | ||
|
|
b695d3b6bb | ||
|
|
d7e133732c | ||
|
|
494e6fff01 | ||
|
|
0c7a297b1d | ||
|
|
9b1f9007c3 | ||
|
|
34ef5f4ece | ||
|
|
73ad20b90c | ||
|
|
340e80257a | ||
|
|
c23ea2a211 | ||
|
|
a5f964aec6 | ||
|
|
b8a8fb0de6 | ||
|
|
a6a8f41fd3 | ||
|
|
c137babea3 | ||
|
|
db2abc1b2c | ||
|
|
a0f9f8dabb | ||
|
|
8a185aa678 | ||
|
|
29aaf4f000 | ||
|
|
fc940dfcfb | ||
|
|
2f2ea98937 | ||
|
|
ef0fa2007b | ||
|
|
f07d4b933c | ||
|
|
5f57cee8e4 | ||
|
|
1755a3fe07 | ||
|
|
99680baf83 | ||
|
|
9aa5460a0e | ||
|
|
b4014e5baa | ||
|
|
96e4dcb521 | ||
|
|
7e682a95c4 | ||
|
|
5eeba76bc5 | ||
|
|
a2c91ebc32 | ||
|
|
1aee8b49e1 | ||
|
|
984f17ddd7 | ||
|
|
d556143e3b | ||
|
|
7e3ad770ac | ||
|
|
87524de265 | ||
|
|
ee10d9b898 | ||
|
|
bbd36e8441 | ||
|
|
4e2d1acf7d | ||
|
|
40d63cd1e3 | ||
|
|
77b2331428 | ||
|
|
2b7e2edee5 | ||
|
|
28aba35ff9 | ||
|
|
89219a77f7 | ||
|
|
20e3a74bad | ||
|
|
ff690350b1 | ||
|
|
ebefb26e8f | ||
|
|
0b1ee9ddd9 | ||
|
|
79599f351e | ||
|
|
8c9f6b1d3e | ||
|
|
83bcb9e95b | ||
|
|
96b9ff8d0e | ||
|
|
0af2254856 | ||
|
|
c2944024a8 | ||
|
|
5be4bda90f | ||
|
|
b78e2db013 | ||
|
|
3f4d1121a4 | ||
|
|
def910021d | ||
|
|
3ac42e9632 | ||
|
|
9c26bb7c6c | ||
|
|
53f20f7612 | ||
|
|
11b3927dc2 | ||
|
|
a190eda2c8 | ||
|
|
1f18f389c0 | ||
|
|
84e56ee614 | ||
|
|
59329a414d | ||
|
|
452c991f58 | ||
|
|
be8116e2ea | ||
|
|
f0ed1e38c9 | ||
|
|
ac0f1dbbdd | ||
|
|
275a352e81 | ||
|
|
9f3bc0e352 | ||
|
|
6c1a1a77b7 | ||
|
|
2e21c62320 | ||
|
|
19c6fec4d1 | ||
|
|
4779d99a13 | ||
|
|
05e0759878 | ||
|
|
2330ec6dc3 | ||
|
|
75e5130cf8 | ||
|
|
87efd27459 | ||
|
|
62f080b0e4 | ||
|
|
ae3990a557 | ||
|
|
d7b5b431d6 | ||
|
|
e2fbd098d2 | ||
|
|
ef78fd8bae | ||
|
|
72ebaeb8f7 | ||
|
|
0dc62d5dad | ||
|
|
d118782a10 | ||
|
|
ff05647350 | ||
|
|
0e1c711c4e | ||
|
|
bfb254dac6 | ||
|
|
92fe927785 | ||
|
|
2e25fe9d5d | ||
|
|
38c5f23f4a | ||
|
|
112c58abf5 | ||
|
|
0dce5173cc | ||
|
|
2c70c0b00f | ||
|
|
34024c2504 | ||
|
|
27e826eba6 | ||
|
|
89a4f1c1ae | ||
|
|
c0c61b25ff | ||
|
|
0d1c6e0ca9 | ||
|
|
002db3abf4 | ||
|
|
416a919c6d | ||
|
|
dbbcf0b8d0 | ||
|
|
efac8cebb3 | ||
|
|
4f2baf1a72 | ||
|
|
48b2bde6e5 |
522 changed files with 16297 additions and 5270 deletions
63
.github/workflows/auto-label-tui.yml
vendored
63
.github/workflows/auto-label-tui.yml
vendored
|
|
@ -1,63 +0,0 @@
|
||||||
name: Auto-label TUI Issues
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [opened]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
auto-label:
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- name: Auto-label and assign issues
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
script: |
|
|
||||||
const issue = context.payload.issue;
|
|
||||||
const title = issue.title;
|
|
||||||
const description = issue.body || '';
|
|
||||||
|
|
||||||
// Check for "opencode web" keyword
|
|
||||||
const webPattern = /(opencode web)/i;
|
|
||||||
const isWebRelated = webPattern.test(title) || webPattern.test(description);
|
|
||||||
|
|
||||||
// Check for version patterns like v1.0.x or 1.0.x
|
|
||||||
const versionPattern = /[v]?1\.0\./i;
|
|
||||||
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
|
|
||||||
|
|
||||||
// Check for "nix" keyword
|
|
||||||
const nixPattern = /\bnix\b/i;
|
|
||||||
const isNixRelated = nixPattern.test(title) || nixPattern.test(description);
|
|
||||||
|
|
||||||
const labels = [];
|
|
||||||
|
|
||||||
if (isWebRelated) {
|
|
||||||
labels.push('web');
|
|
||||||
|
|
||||||
// Assign to adamdotdevin
|
|
||||||
await github.rest.issues.addAssignees({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: issue.number,
|
|
||||||
assignees: ['adamdotdevin']
|
|
||||||
});
|
|
||||||
} else if (isVersionRelated) {
|
|
||||||
// Only add opentui if NOT web-related
|
|
||||||
labels.push('opentui');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNixRelated) {
|
|
||||||
labels.push('nix');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (labels.length > 0) {
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: issue.number,
|
|
||||||
labels: labels
|
|
||||||
});
|
|
||||||
}
|
|
||||||
69
.github/workflows/docs-update.yml
vendored
Normal file
69
.github/workflows/docs-update.yml
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
name: Docs Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 */12 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-docs:
|
||||||
|
if: github.repository == 'sst/opencode'
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Fetch full history to access commits
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Get recent commits
|
||||||
|
id: commits
|
||||||
|
run: |
|
||||||
|
COMMITS=$(git log --since="4 hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$COMMITS" ]; then
|
||||||
|
echo "No commits in the last 4 hours"
|
||||||
|
echo "has_commits=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "has_commits=true" >> $GITHUB_OUTPUT
|
||||||
|
{
|
||||||
|
echo "list<<EOF"
|
||||||
|
echo "$COMMITS"
|
||||||
|
echo "EOF"
|
||||||
|
} >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run opencode
|
||||||
|
if: steps.commits.outputs.has_commits == 'true'
|
||||||
|
uses: sst/opencode/github@latest
|
||||||
|
env:
|
||||||
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
|
with:
|
||||||
|
model: opencode/gpt-5.2
|
||||||
|
agent: docs
|
||||||
|
prompt: |
|
||||||
|
Review the following commits from the last 4 hours and identify any new features that may need documentation.
|
||||||
|
|
||||||
|
<recent_commits>
|
||||||
|
${{ steps.commits.outputs.list }}
|
||||||
|
</recent_commits>
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. For each commit that looks like a new feature or significant change:
|
||||||
|
- Read the changed files to understand what was added
|
||||||
|
- Check if the feature is already documented in packages/web/src/content/docs/*
|
||||||
|
2. If you find undocumented features:
|
||||||
|
- Update the relevant documentation files in packages/web/src/content/docs/*
|
||||||
|
- Follow the existing documentation style and structure
|
||||||
|
- Make sure to document the feature clearly with examples where appropriate
|
||||||
|
3. If all new features are already documented, report that no updates are needed
|
||||||
|
4. If you are creating a new documentation file be sure to update packages/web/astro.config.mjs too.
|
||||||
|
|
||||||
|
Focus on user-facing features and API changes. Skip internal refactors, bug fixes, and test updates unless they affect user-facing behavior.
|
||||||
|
Don't feel the need to document every little thing. It is perfectly okay to make 0 changes at all.
|
||||||
|
Try to keep documentation only for large features or changes that already have a good spot to be documented.
|
||||||
2
.github/workflows/duplicate-issues.yml
vendored
2
.github/workflows/duplicate-issues.yml
vendored
|
|
@ -16,6 +16,8 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Install opencode
|
- name: Install opencode
|
||||||
run: curl -fsSL https://opencode.ai/install | bash
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
|
|
||||||
43
.github/workflows/generate.yml
vendored
43
.github/workflows/generate.yml
vendored
|
|
@ -2,11 +2,8 @@ name: generate
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches:
|
||||||
- production
|
- dev
|
||||||
pull_request:
|
|
||||||
branches-ignore:
|
|
||||||
- production
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -14,6 +11,7 @@ jobs:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
@ -25,14 +23,29 @@ jobs:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Generate SDK
|
- name: Generate
|
||||||
run: |
|
run: ./script/generate.ts
|
||||||
bun ./packages/sdk/js/script/build.ts
|
|
||||||
(cd packages/opencode && bun dev generate > ../sdk/openapi.json)
|
|
||||||
bun x prettier --write packages/sdk/openapi.json
|
|
||||||
|
|
||||||
- name: Format
|
- name: Commit and push
|
||||||
run: ./script/format.ts
|
run: |
|
||||||
env:
|
if [ -z "$(git status --porcelain)" ]; then
|
||||||
CI: true
|
echo "No changes to commit"
|
||||||
PUSH_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }}
|
exit 0
|
||||||
|
fi
|
||||||
|
git config --local user.email "action@github.com"
|
||||||
|
git config --local user.name "GitHub Action"
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: generate"
|
||||||
|
git push origin HEAD:${{ github.ref_name }} --no-verify
|
||||||
|
# if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then
|
||||||
|
# echo ""
|
||||||
|
# echo "============================================"
|
||||||
|
# echo "Failed to push generated code."
|
||||||
|
# echo "Please run locally and push:"
|
||||||
|
# echo ""
|
||||||
|
# echo " ./script/generate.ts"
|
||||||
|
# echo " git add -A && git commit -m \"chore: generate\" && git push"
|
||||||
|
# echo ""
|
||||||
|
# echo "============================================"
|
||||||
|
# exit 1
|
||||||
|
# fi
|
||||||
|
|
|
||||||
2
.github/workflows/notify-discord.yml
vendored
2
.github/workflows/notify-discord.yml
vendored
|
|
@ -2,7 +2,7 @@ name: discord
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published] # fires only when a release is published
|
types: [released] # fires when a draft release is published
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
notify:
|
notify:
|
||||||
|
|
|
||||||
86
.github/workflows/publish.yml
vendored
86
.github/workflows/publish.yml
vendored
|
|
@ -41,21 +41,9 @@ jobs:
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Setup SSH for AUR
|
|
||||||
if: inputs.bump || inputs.version
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y pacman-package-manager
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
|
|
||||||
chmod 600 ~/.ssh/id_rsa
|
|
||||||
git config --global user.email "opencode@sst.dev"
|
|
||||||
git config --global user.name "opencode"
|
|
||||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
|
|
||||||
|
|
||||||
- name: Install OpenCode
|
- name: Install OpenCode
|
||||||
if: inputs.bump || inputs.version
|
if: inputs.bump || inputs.version
|
||||||
run: bun i -g opencode-ai@1.0.143
|
run: bun i -g opencode-ai@1.0.169
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|
@ -75,9 +63,15 @@ jobs:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
|
- name: Setup Git Identity
|
||||||
|
run: |
|
||||||
|
git config --global user.email "opencode@sst.dev"
|
||||||
|
git config --global user.name "opencode"
|
||||||
|
git remote set-url origin https://x-access-token:${{ secrets.SST_GITHUB_TOKEN }}@github.com/${{ github.repository }}
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
id: publish
|
id: publish
|
||||||
run: ./script/publish.ts
|
run: ./script/publish-start.ts
|
||||||
env:
|
env:
|
||||||
OPENCODE_BUMP: ${{ inputs.bump }}
|
OPENCODE_BUMP: ${{ inputs.bump }}
|
||||||
OPENCODE_VERSION: ${{ inputs.version }}
|
OPENCODE_VERSION: ${{ inputs.version }}
|
||||||
|
|
@ -85,9 +79,16 @@ jobs:
|
||||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||||
NPM_CONFIG_PROVENANCE: false
|
NPM_CONFIG_PROVENANCE: false
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-cli
|
||||||
|
path: packages/opencode/dist
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
releaseId: ${{ steps.publish.outputs.releaseId }}
|
release: ${{ steps.publish.outputs.release }}
|
||||||
tagName: ${{ steps.publish.outputs.tagName }}
|
tag: ${{ steps.publish.outputs.tag }}
|
||||||
|
version: ${{ steps.publish.outputs.version }}
|
||||||
|
|
||||||
publish-tauri:
|
publish-tauri:
|
||||||
needs: publish
|
needs: publish
|
||||||
|
|
@ -104,12 +105,14 @@ jobs:
|
||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
- host: blacksmith-4vcpu-ubuntu-2404
|
- host: blacksmith-4vcpu-ubuntu-2404
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
|
- host: blacksmith-4vcpu-ubuntu-2404-arm
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ needs.publish.outputs.tagName }}
|
ref: ${{ needs.publish.outputs.tag }}
|
||||||
|
|
||||||
- uses: apple-actions/import-codesign-certs@v2
|
- uses: apple-actions/import-codesign-certs@v2
|
||||||
if: ${{ runner.os == 'macOS' }}
|
if: ${{ runner.os == 'macOS' }}
|
||||||
|
|
@ -148,23 +151,22 @@ jobs:
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: packages/tauri/src-tauri
|
workspaces: packages/desktop/src-tauri
|
||||||
shared-key: ${{ matrix.settings.target }}
|
shared-key: ${{ matrix.settings.target }}
|
||||||
|
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
cd packages/tauri
|
cd packages/desktop
|
||||||
bun ./scripts/prepare.ts
|
bun ./scripts/prepare.ts
|
||||||
env:
|
env:
|
||||||
OPENCODE_BUMP: ${{ inputs.bump }}
|
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
|
||||||
OPENCODE_VERSION: ${{ inputs.version }}
|
|
||||||
OPENCODE_CHANNEL: latest
|
|
||||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
RUST_TARGET: ${{ matrix.settings.target }}
|
RUST_TARGET: ${{ matrix.settings.target }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -189,11 +191,43 @@ jobs:
|
||||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||||
with:
|
with:
|
||||||
projectPath: packages/tauri
|
projectPath: packages/desktop
|
||||||
uploadWorkflowArtifacts: true
|
uploadWorkflowArtifacts: true
|
||||||
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
||||||
args: --target ${{ matrix.settings.target }}
|
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
|
||||||
updaterJsonPreferNsis: true
|
updaterJsonPreferNsis: true
|
||||||
releaseId: ${{ needs.publish.outputs.releaseId }}
|
releaseId: ${{ needs.publish.outputs.release }}
|
||||||
tagName: ${{ needs.publish.outputs.tagName }}
|
tagName: ${{ needs.publish.outputs.tag }}
|
||||||
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
||||||
|
releaseDraft: true
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
needs:
|
||||||
|
- publish
|
||||||
|
- publish-tauri
|
||||||
|
if: needs.publish.outputs.tag
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ needs.publish.outputs.tag }}
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Setup SSH for AUR
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y pacman-package-manager
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
git config --global user.email "opencode@sst.dev"
|
||||||
|
git config --global user.name "opencode"
|
||||||
|
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
|
||||||
|
|
||||||
|
- run: ./script/publish-complete.ts
|
||||||
|
env:
|
||||||
|
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
|
||||||
|
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||||
|
|
|
||||||
29
.github/workflows/release-github-action.yml
vendored
Normal file
29
.github/workflows/release-github-action.yml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
name: release-github-action
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- "github/**"
|
||||||
|
|
||||||
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- run: git fetch --force --tags
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
run: |
|
||||||
|
git config --global user.email "opencode@sst.dev"
|
||||||
|
git config --global user.name "opencode"
|
||||||
|
./github/script/release
|
||||||
4
.github/workflows/review.yml
vendored
4
.github/workflows/review.yml
vendored
|
|
@ -29,6 +29,8 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Install opencode
|
- name: Install opencode
|
||||||
run: curl -fsSL https://opencode.ai/install | bash
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
|
@ -65,6 +67,8 @@ jobs:
|
||||||
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
|
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
|
||||||
|
|
||||||
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
|
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
|
||||||
|
If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors.
|
||||||
|
Generally, write a comment instead of writing suggested change if you can help it.
|
||||||
|
|
||||||
Command MUST be like this.
|
Command MUST be like this.
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
|
||||||
3
.github/workflows/stats.yml
vendored
3
.github/workflows/stats.yml
vendored
|
|
@ -5,8 +5,11 @@ on:
|
||||||
- cron: "0 12 * * *" # Run daily at 12:00 UTC
|
- cron: "0 12 * * *" # Run daily at 12:00 UTC
|
||||||
workflow_dispatch: # Allow manual trigger
|
workflow_dispatch: # Allow manual trigger
|
||||||
|
|
||||||
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stats:
|
stats:
|
||||||
|
if: github.repository == 'sst/opencode'
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
|
||||||
37
.github/workflows/triage.yml
vendored
Normal file
37
.github/workflows/triage.yml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
name: Issue Triage
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
triage:
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Install opencode
|
||||||
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
- name: Triage issue
|
||||||
|
env:
|
||||||
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||||
|
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||||
|
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||||
|
run: |
|
||||||
|
opencode run --agent triage "The following issue was just opened, triage it:
|
||||||
|
|
||||||
|
Title: $ISSUE_TITLE
|
||||||
|
|
||||||
|
$ISSUE_BODY"
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,3 +19,4 @@ Session.vim
|
||||||
opencode.json
|
opencode.json
|
||||||
a.out
|
a.out
|
||||||
target
|
target
|
||||||
|
.scripts
|
||||||
|
|
|
||||||
77
.opencode/agent/triage.md
Normal file
77
.opencode/agent/triage.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
---
|
||||||
|
mode: primary
|
||||||
|
hidden: true
|
||||||
|
model: opencode/claude-haiku-4-5
|
||||||
|
tools:
|
||||||
|
"*": false
|
||||||
|
"github-triage": true
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a triage agent responsible for triaging github issues.
|
||||||
|
|
||||||
|
Use your github-triage tool to triage issues.
|
||||||
|
|
||||||
|
## Labels
|
||||||
|
|
||||||
|
### windows
|
||||||
|
|
||||||
|
Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows.
|
||||||
|
|
||||||
|
- Use if they mention WSL too
|
||||||
|
|
||||||
|
#### perf
|
||||||
|
|
||||||
|
Performance-related issues:
|
||||||
|
|
||||||
|
- Slow performance
|
||||||
|
- High RAM usage
|
||||||
|
- High CPU usage
|
||||||
|
|
||||||
|
**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness.
|
||||||
|
|
||||||
|
#### desktop
|
||||||
|
|
||||||
|
Desktop app issues:
|
||||||
|
|
||||||
|
- `opencode web` command
|
||||||
|
- The desktop app itself
|
||||||
|
|
||||||
|
**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues.
|
||||||
|
|
||||||
|
#### nix
|
||||||
|
|
||||||
|
**Only** add if the issue explicitly mentions nix.
|
||||||
|
|
||||||
|
#### zen
|
||||||
|
|
||||||
|
**Only** add if the issue mentions "zen" or "opencode zen". Zen is our gateway for coding models. **Do not** add for other gateways or inference providers.
|
||||||
|
|
||||||
|
If the issue doesn't have "zen" in it then don't add zen label
|
||||||
|
|
||||||
|
#### docs
|
||||||
|
|
||||||
|
Add if the issue requests better documentation or docs updates.
|
||||||
|
|
||||||
|
#### opentui
|
||||||
|
|
||||||
|
TUI issues potentially caused by our underlying TUI library:
|
||||||
|
|
||||||
|
- Keybindings not working
|
||||||
|
- Scroll speed issues (too fast/slow/laggy)
|
||||||
|
- Screen flickering
|
||||||
|
- Crashes with opentui in the log
|
||||||
|
|
||||||
|
**Do not** add for general TUI bugs.
|
||||||
|
|
||||||
|
When assigning to people here are the following rules:
|
||||||
|
|
||||||
|
adamdotdev:
|
||||||
|
ONLY assign adam if the issue will have the "desktop" label.
|
||||||
|
|
||||||
|
fwang:
|
||||||
|
ONLY assign fwang if the issue will have the "zen" label.
|
||||||
|
|
||||||
|
jayair:
|
||||||
|
ONLY assign jayair if the issue will have the "docs" label.
|
||||||
|
|
||||||
|
In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node.
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
description: git commit and push
|
description: git commit and push
|
||||||
model: opencode/glm-4.6
|
model: opencode/glm-4.6
|
||||||
|
subtask: true
|
||||||
---
|
---
|
||||||
|
|
||||||
commit and push
|
commit and push
|
||||||
|
|
|
||||||
4
.opencode/env.d.ts
vendored
Normal file
4
.opencode/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
declare module "*.txt" {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
|
|
@ -11,4 +11,7 @@
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"mcp": {},
|
"mcp": {},
|
||||||
|
"tools": {
|
||||||
|
"github-triage": false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
.opencode/skill/test-skill/SKILL.md
Normal file
6
.opencode/skill/test-skill/SKILL.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
name: test-skill
|
||||||
|
description: use this when asked to test skill
|
||||||
|
---
|
||||||
|
|
||||||
|
woah this is a test skill
|
||||||
90
.opencode/tool/github-triage.ts
Normal file
90
.opencode/tool/github-triage.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/// <reference path="../env.d.ts" />
|
||||||
|
// import { Octokit } from "@octokit/rest"
|
||||||
|
import { tool } from "@opencode-ai/plugin"
|
||||||
|
import DESCRIPTION from "./github-triage.txt"
|
||||||
|
|
||||||
|
function getIssueNumber(): number {
|
||||||
|
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
|
||||||
|
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
|
||||||
|
return issue
|
||||||
|
}
|
||||||
|
|
||||||
|
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||||
|
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default tool({
|
||||||
|
description: DESCRIPTION,
|
||||||
|
args: {
|
||||||
|
assignee: tool.schema
|
||||||
|
.enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"])
|
||||||
|
.describe("The username of the assignee")
|
||||||
|
.default("rekram1-node"),
|
||||||
|
labels: tool.schema
|
||||||
|
.array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"]))
|
||||||
|
.describe("The labels(s) to add to the issue")
|
||||||
|
.default([]),
|
||||||
|
},
|
||||||
|
async execute(args) {
|
||||||
|
const issue = getIssueNumber()
|
||||||
|
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
|
||||||
|
const owner = "sst"
|
||||||
|
const repo = "opencode"
|
||||||
|
|
||||||
|
const results: string[] = []
|
||||||
|
|
||||||
|
if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) {
|
||||||
|
throw new Error("Only desktop issues should be assigned to adamdotdevin")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.assignee === "fwang" && !args.labels.includes("zen")) {
|
||||||
|
throw new Error("Only zen issues should be assigned to fwang")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.assignee === "kommander" && !args.labels.includes("opentui")) {
|
||||||
|
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`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ assignees: [args.assignee] }),
|
||||||
|
})
|
||||||
|
results.push(`Assigned @${args.assignee} to issue #${issue}`)
|
||||||
|
|
||||||
|
const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label))
|
||||||
|
|
||||||
|
if (labels.length > 0) {
|
||||||
|
// await octokit.rest.issues.addLabels({
|
||||||
|
// owner,
|
||||||
|
// repo,
|
||||||
|
// issue_number: issue,
|
||||||
|
// labels,
|
||||||
|
// })
|
||||||
|
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ labels }),
|
||||||
|
})
|
||||||
|
results.push(`Added labels: ${args.labels.join(", ")}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.join("\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
88
.opencode/tool/github-triage.txt
Normal file
88
.opencode/tool/github-triage.txt
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
Use this tool to assign and/or label a Github issue.
|
||||||
|
|
||||||
|
You can assign the following users:
|
||||||
|
- thdxr
|
||||||
|
- adamdotdevin
|
||||||
|
- fwang
|
||||||
|
- jayair
|
||||||
|
- kommander
|
||||||
|
- rekram1-node
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
29
AGENTS.md
29
AGENTS.md
|
|
@ -4,31 +4,4 @@
|
||||||
|
|
||||||
## Tool Calling
|
## Tool Calling
|
||||||
|
|
||||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environment:
|
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||||
|
|
||||||
json
|
|
||||||
{
|
|
||||||
"recipient_name": "multi_tool_use.parallel",
|
|
||||||
"parameters": {
|
|
||||||
"tool_uses": [
|
|
||||||
{
|
|
||||||
"recipient_name": "functions.read",
|
|
||||||
"parameters": {
|
|
||||||
"filePath": "path/to/file.tsx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient_name": "functions.read",
|
|
||||||
"parameters": {
|
|
||||||
"filePath": "path/to/file.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"recipient_name": "functions.read",
|
|
||||||
"parameters": {
|
|
||||||
"filePath": "path/to/file.md"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
|
||||||
- `packages/plugin`: Source for `@opencode-ai/plugin`
|
- `packages/plugin`: Source for `@opencode-ai/plugin`
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> After touching `packages/opencode/src/server/server.ts`, run "./packages/sdk/js/script/build.ts" to regenerate the JS sdk.
|
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
|
||||||
|
|
||||||
Please try to follow the [style guide](./STYLE_GUIDE.md)
|
Please try to follow the [style guide](./STYLE_GUIDE.md)
|
||||||
|
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -30,13 +30,29 @@ scoop bucket add extras; scoop install extras/opencode # Windows
|
||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install opencode # macOS and Linux
|
brew install opencode # macOS and Linux
|
||||||
paru -S opencode-bin # Arch Linux
|
paru -S opencode-bin # Arch Linux
|
||||||
mise use -g ubi:sst/opencode # Any OS
|
mise use -g github:sst/opencode # Any OS
|
||||||
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
|
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Remove versions older than 0.1.x before installing.
|
> Remove versions older than 0.1.x before installing.
|
||||||
|
|
||||||
|
### Desktop App (BETA)
|
||||||
|
|
||||||
|
OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/sst/opencode/releases) or [opencode.ai/download](https://opencode.ai/download).
|
||||||
|
|
||||||
|
| Platform | 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`, or AppImage |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew)
|
||||||
|
brew install --cask opencode-desktop
|
||||||
|
```
|
||||||
|
|
||||||
#### Installation Directory
|
#### Installation Directory
|
||||||
|
|
||||||
The install script respects the following priority order for the installation path:
|
The install script respects the following priority order for the installation path:
|
||||||
|
|
@ -78,7 +94,7 @@ If you're interested in contributing to OpenCode, please read our [contributing
|
||||||
|
|
||||||
### Building on OpenCode
|
### Building on OpenCode
|
||||||
|
|
||||||
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in anyway.
|
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
|
||||||
|
|
||||||
### FAQ
|
### FAQ
|
||||||
|
|
||||||
|
|
|
||||||
115
README.zh-TW.md
Normal file
115
README.zh-TW.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<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 Coding Agent。</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/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://opencode.ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 安裝
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 直接安裝 (YOLO)
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
# 套件管理員
|
||||||
|
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
|
||||||
|
scoop bucket add extras; scoop install extras/opencode # Windows
|
||||||
|
choco install opencode # Windows
|
||||||
|
brew install opencode # macOS 與 Linux
|
||||||
|
paru -S opencode-bin # Arch Linux
|
||||||
|
mise use -g github:sst/opencode # 任何作業系統
|
||||||
|
nix run nixpkgs#opencode # 或使用 github:sst/opencode 以取得最新開發分支
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 安裝前請先移除 0.1.x 以前的舊版本。
|
||||||
|
|
||||||
|
### 桌面應用程式 (BETA)
|
||||||
|
|
||||||
|
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/sst/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 Cask)
|
||||||
|
brew install --cask opencode-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 安裝目錄
|
||||||
|
|
||||||
|
安裝腳本會依據以下優先順序決定安裝路徑:
|
||||||
|
|
||||||
|
1. `$OPENCODE_INSTALL_DIR` - 自定義安裝目錄
|
||||||
|
2. `$XDG_BIN_DIR` - 符合 XDG 基礎目錄規範的路徑
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agents
|
||||||
|
|
||||||
|
OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||||
|
|
||||||
|
- **build** - 預設模式,具備完整權限的 Agent,適用於開發工作。
|
||||||
|
- **plan** - 唯讀模式,適用於程式碼分析與探索。
|
||||||
|
- 預設禁止修改檔案。
|
||||||
|
- 執行 bash 指令前會詢問權限。
|
||||||
|
- 非常適合用來探索陌生的程式碼庫或規劃變更。
|
||||||
|
|
||||||
|
此外,OpenCode 還包含一個 **general** 子 Agent,用於處理複雜搜尋與多步驟任務。此 Agent 供系統內部使用,亦可透過在訊息中輸入 `@general` 來呼叫。
|
||||||
|
|
||||||
|
了解更多關於 [Agents](https://opencode.ai/docs/agents) 的資訊。
|
||||||
|
|
||||||
|
### 線上文件
|
||||||
|
|
||||||
|
關於如何設定 OpenCode 的詳細資訊,請參閱我們的 [**官方文件**](https://opencode.ai/docs)。
|
||||||
|
|
||||||
|
### 參與貢獻
|
||||||
|
|
||||||
|
如果您有興趣參與 OpenCode 的開發,請在提交 Pull Request 前先閱讀我們的 [貢獻指南 (Contributing Docs)](./CONTRIBUTING.md)。
|
||||||
|
|
||||||
|
### 基於 OpenCode 進行開發
|
||||||
|
|
||||||
|
如果您正在開發與 OpenCode 相關的專案,並在名稱中使用了 "opencode"(例如 "opencode-dashboard" 或 "opencode-mobile"),請在您的 README 中加入聲明,說明該專案並非由 OpenCode 團隊開發,且與我們沒有任何隸屬關係。
|
||||||
|
|
||||||
|
### 常見問題 (FAQ)
|
||||||
|
|
||||||
|
#### 這跟 Claude Code 有什麼不同?
|
||||||
|
|
||||||
|
在功能面上與 Claude Code 非常相似。以下是關鍵差異:
|
||||||
|
|
||||||
|
- 100% 開源。
|
||||||
|
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
|
||||||
|
- 內建 LSP (語言伺服器協定) 支援。
|
||||||
|
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造;我們將不斷挑戰終端機介面的極限。
|
||||||
|
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
|
||||||
|
|
||||||
|
#### 另一個同名的 Repo 是什麼?
|
||||||
|
|
||||||
|
另一個名稱相近的儲存庫與本專案無關。您可以點此[閱讀背後的故事](https://x.com/thdxr/status/1933561254481666466)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||||
7
STATS.md
7
STATS.md
|
|
@ -171,3 +171,10 @@
|
||||||
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
|
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
|
||||||
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
|
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
|
||||||
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
|
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
|
||||||
|
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
|
||||||
|
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
|
||||||
|
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
|
||||||
|
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
|
||||||
|
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
|
||||||
|
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
|
||||||
|
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
|
||||||
|
|
|
||||||
6
flake.lock
generated
6
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1765772535,
|
"lastModified": 1766314097,
|
||||||
"narHash": "sha256-aq+dQoaPONOSjtFIBnAXseDm9TUhIbe215TPmkfMYww=",
|
"narHash": "sha256-laJftWbghBehazn/zxVJ8NdENVgjccsWAdAqKXhErrM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "09b8fda8959d761445f12b55f380d90375a1d6bb",
|
"rev": "306ea70f9eb0fb4e040f8540e2deab32ed7e2055",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Mention `/opencode` in your comment, and opencode will execute tasks within your
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
#### Explain an issues
|
#### Explain an issue
|
||||||
|
|
||||||
Leave the following comment on a GitHub issue. `opencode` will read the entire thread, including all comments, and reply with a clear explanation.
|
Leave the following comment on a GitHub issue. `opencode` will read the entire thread, including all comments, and reply with a clear explanation.
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ Leave the following comment on a GitHub issue. `opencode` will read the entire t
|
||||||
/opencode explain this issue
|
/opencode explain this issue
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Fix an issues
|
#### Fix an issue
|
||||||
|
|
||||||
Leave the following comment on a GitHub issue. opencode will create a new branch, implement the changes, and open a PR with the changes.
|
Leave the following comment on a GitHub issue. opencode will create a new branch, implement the changes, and open a PR with the changes.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ inputs:
|
||||||
description: "Model to use"
|
description: "Model to use"
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
agent:
|
||||||
|
description: "Agent to use. Must be a primary agent. Falls back to default_agent from config or 'build' if not found."
|
||||||
|
required: false
|
||||||
|
|
||||||
share:
|
share:
|
||||||
description: "Share the opencode session (defaults to true for public repos)"
|
description: "Share the opencode session (defaults to true for public repos)"
|
||||||
required: false
|
required: false
|
||||||
|
|
@ -22,6 +26,14 @@ inputs:
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
|
|
||||||
|
mentions:
|
||||||
|
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
oidc_base_url:
|
||||||
|
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai"
|
||||||
|
required: false
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -54,6 +66,9 @@ runs:
|
||||||
run: opencode github run
|
run: opencode github run
|
||||||
env:
|
env:
|
||||||
MODEL: ${{ inputs.model }}
|
MODEL: ${{ inputs.model }}
|
||||||
|
AGENT: ${{ inputs.agent }}
|
||||||
SHARE: ${{ inputs.share }}
|
SHARE: ${{ inputs.share }}
|
||||||
PROMPT: ${{ inputs.prompt }}
|
PROMPT: ${{ inputs.prompt }}
|
||||||
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
|
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
|
||||||
|
MENTIONS: ${{ inputs.mentions }}
|
||||||
|
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,10 @@ function useEnvRunUrl() {
|
||||||
return `/${repo.owner}/${repo.repo}/actions/runs/${runId}`
|
return `/${repo.owner}/${repo.repo}/actions/runs/${runId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useEnvAgent() {
|
||||||
|
return process.env["AGENT"] || undefined
|
||||||
|
}
|
||||||
|
|
||||||
function useEnvShare() {
|
function useEnvShare() {
|
||||||
const value = process.env["SHARE"]
|
const value = process.env["SHARE"]
|
||||||
if (!value) return undefined
|
if (!value) return undefined
|
||||||
|
|
@ -570,24 +574,49 @@ async function subscribeSessionEvents() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function summarize(response: string) {
|
async function summarize(response: string) {
|
||||||
const payload = useContext().payload as IssueCommentEvent
|
|
||||||
try {
|
try {
|
||||||
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (isScheduleEvent()) {
|
||||||
|
return "Scheduled task changes"
|
||||||
|
}
|
||||||
|
const payload = useContext().payload as IssueCommentEvent
|
||||||
return `Fix issue: ${payload.issue.title}`
|
return `Fix issue: ${payload.issue.title}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveAgent(): Promise<string | undefined> {
|
||||||
|
const envAgent = useEnvAgent()
|
||||||
|
if (!envAgent) return undefined
|
||||||
|
|
||||||
|
// Validate the agent exists and is a primary agent
|
||||||
|
const agents = await client.agent.list<true>()
|
||||||
|
const agent = agents.data?.find((a) => a.name === envAgent)
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
console.warn(`agent "${envAgent}" not found. Falling back to default agent`)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.mode === "subagent") {
|
||||||
|
console.warn(`agent "${envAgent}" is a subagent, not a primary agent. Falling back to default agent`)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return envAgent
|
||||||
|
}
|
||||||
|
|
||||||
async function chat(text: string, files: PromptFiles = []) {
|
async function chat(text: string, files: PromptFiles = []) {
|
||||||
console.log("Sending message to opencode...")
|
console.log("Sending message to opencode...")
|
||||||
const { providerID, modelID } = useEnvModel()
|
const { providerID, modelID } = useEnvModel()
|
||||||
|
const agent = await resolveAgent()
|
||||||
|
|
||||||
const chat = await client.session.chat<true>({
|
const chat = await client.session.chat<true>({
|
||||||
path: session,
|
path: session,
|
||||||
body: {
|
body: {
|
||||||
providerID,
|
providerID,
|
||||||
modelID,
|
modelID,
|
||||||
agent: "build",
|
agent,
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@octokit/graphql": "9.0.1",
|
"@octokit/graphql": "9.0.1",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*"
|
"@opencode-ai/sdk": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,3 +44,12 @@ new sst.cloudflare.x.Astro("Web", {
|
||||||
VITE_API_URL: api.url.apply((url) => url!),
|
VITE_API_URL: api.url.apply((url) => url!),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
new sst.cloudflare.StaticSite("App", {
|
||||||
|
domain: "app." + domain,
|
||||||
|
path: "packages/app",
|
||||||
|
build: {
|
||||||
|
command: "bun turbo build",
|
||||||
|
output: "./dist",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
|
||||||
////////////////
|
////////////////
|
||||||
|
|
||||||
const bucket = new sst.cloudflare.Bucket("ZenData")
|
const bucket = new sst.cloudflare.Bucket("ZenData")
|
||||||
|
const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||||
|
|
||||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||||
|
|
@ -136,6 +137,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||||
path: "packages/console/app",
|
path: "packages/console/app",
|
||||||
link: [
|
link: [
|
||||||
bucket,
|
bucket,
|
||||||
|
bucketNew,
|
||||||
database,
|
database,
|
||||||
AUTH_API_URL,
|
AUTH_API_URL,
|
||||||
STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { domain } from "./stage"
|
||||||
|
|
||||||
new sst.cloudflare.StaticSite("Desktop", {
|
new sst.cloudflare.StaticSite("Desktop", {
|
||||||
domain: "desktop." + domain,
|
domain: "desktop." + domain,
|
||||||
path: "packages/desktop",
|
path: "packages/app",
|
||||||
build: {
|
build: {
|
||||||
command: "bun turbo build",
|
command: "bun turbo build",
|
||||||
output: "./dist",
|
output: "./dist",
|
||||||
|
|
|
||||||
17
install
17
install
|
|
@ -240,22 +240,23 @@ download_with_progress() {
|
||||||
|
|
||||||
download_and_install() {
|
download_and_install() {
|
||||||
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
|
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
|
||||||
mkdir -p opencodetmp && cd opencodetmp
|
local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
|
||||||
|
mkdir -p "$tmp_dir"
|
||||||
|
|
||||||
if [[ "$os" == "windows" ]] || ! download_with_progress "$url" "$filename"; then
|
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
|
||||||
# Fallback to standard curl on Windows or if custom progress fails
|
# Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
|
||||||
curl -# -L -o "$filename" "$url"
|
curl -# -L -o "$tmp_dir/$filename" "$url"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$os" = "linux" ]; then
|
if [ "$os" = "linux" ]; then
|
||||||
tar -xzf "$filename"
|
tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
|
||||||
else
|
else
|
||||||
unzip -q "$filename"
|
unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mv opencode "$INSTALL_DIR"
|
mv "$tmp_dir/opencode" "$INSTALL_DIR"
|
||||||
chmod 755 "${INSTALL_DIR}/opencode"
|
chmod 755 "${INSTALL_DIR}/opencode"
|
||||||
cd .. && rm -rf opencodetmp
|
rm -rf "$tmp_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
check_version
|
check_version
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"nodeModules": "sha256-PyoVOza+3WnwZbtpPF6uSN1zkyLsSG2VsgBfIMvIFAs="
|
"nodeModules": "sha256-QlQblkUq49DOdvNNMNAzHHAfHxR6cZNmJtyzc4rD168="
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
package.json
17
package.json
|
|
@ -4,7 +4,7 @@
|
||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.4",
|
"packageManager": "bun@1.3.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@types/bun": "1.3.4",
|
"@types/bun": "1.3.4",
|
||||||
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
|
|
@ -30,7 +31,8 @@
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.0.0-beta.3",
|
"@pierre/diffs": "1.0.2",
|
||||||
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"ai": "5.0.97",
|
"ai": "5.0.97",
|
||||||
|
|
@ -42,6 +44,7 @@
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
|
"shiki": "3.20.0",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"virtua": "0.42.3",
|
"virtua": "0.42.3",
|
||||||
|
|
@ -54,6 +57,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@actions/artifact": "5.0.1",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
|
|
@ -61,7 +65,15 @@
|
||||||
"turbo": "2.5.6"
|
"turbo": "2.5.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/cerebras": "1.0.33",
|
||||||
|
"@ai-sdk/cohere": "2.0.21",
|
||||||
|
"@ai-sdk/deepinfra": "1.0.30",
|
||||||
|
"@ai-sdk/gateway": "2.0.23",
|
||||||
|
"@ai-sdk/groq": "2.0.33",
|
||||||
|
"@ai-sdk/perplexity": "2.0.22",
|
||||||
|
"@ai-sdk/togetherai": "1.0.30",
|
||||||
"@aws-sdk/client-s3": "3.933.0",
|
"@aws-sdk/client-s3": "3.933.0",
|
||||||
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
|
|
@ -78,7 +90,6 @@
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"sharp",
|
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
"web-tree-sitter"
|
"web-tree-sitter"
|
||||||
|
|
|
||||||
1
packages/app/.gitignore
vendored
Normal file
1
packages/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
src/assets/theme.css
|
||||||
34
packages/app/README.md
Normal file
34
packages/app/README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
|
||||||
|
|
||||||
|
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install # or pnpm install or yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm run dev` or `npm start`
|
||||||
|
|
||||||
|
Runs the app in the development mode.<br>
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
|
|
||||||
|
The page will reload if you make edits.<br>
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `dist` folder.<br>
|
||||||
|
It correctly bundles Solid in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.<br>
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
|
||||||
|
|
@ -23,6 +23,6 @@
|
||||||
</script>
|
</script>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root" class="flex flex-col h-screen"></div>
|
<div id="root" class="flex flex-col h-screen"></div>
|
||||||
<script src="/src/index.tsx" type="module"></script>
|
<script src="/src/entry.tsx" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
62
packages/app/package.json
Normal file
62
packages/app/package.json
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
{
|
||||||
|
"name": "@opencode-ai/app",
|
||||||
|
"version": "1.0.191",
|
||||||
|
"description": "",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./vite": "./vite.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsgo -b",
|
||||||
|
"start": "vite",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"serve": "vite preview"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@happy-dom/global-registrator": "20.0.11",
|
||||||
|
"@tailwindcss/vite": "catalog:",
|
||||||
|
"@tsconfig/bun": "1.0.9",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@types/luxon": "catalog:",
|
||||||
|
"@types/node": "catalog:",
|
||||||
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"vite": "catalog:",
|
||||||
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
|
"vite-plugin-solid": "catalog:"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
"@opencode-ai/util": "workspace:*",
|
||||||
|
"@shikijs/transformers": "3.9.2",
|
||||||
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
|
"@solid-primitives/audio": "1.4.2",
|
||||||
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
"@solid-primitives/media": "2.3.3",
|
||||||
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
|
"@solid-primitives/storage": "catalog:",
|
||||||
|
"@solid-primitives/websocket": "1.3.1",
|
||||||
|
"@solidjs/meta": "catalog:",
|
||||||
|
"@solidjs/router": "catalog:",
|
||||||
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
|
"diff": "catalog:",
|
||||||
|
"fuzzysort": "catalog:",
|
||||||
|
"ghostty-web": "0.3.0",
|
||||||
|
"luxon": "catalog:",
|
||||||
|
"marked": "16.2.0",
|
||||||
|
"marked-shiki": "1.2.1",
|
||||||
|
"remeda": "catalog:",
|
||||||
|
"shiki": "catalog:",
|
||||||
|
"solid-js": "catalog:",
|
||||||
|
"solid-list": "catalog:",
|
||||||
|
"tailwindcss": "catalog:",
|
||||||
|
"virtua": "catalog:",
|
||||||
|
"zod": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
packages/app/src/app.tsx
Normal file
92
packages/app/src/app.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import "@/index.css"
|
||||||
|
import { ErrorBoundary, Show } 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 { Diff } from "@opencode-ai/ui/diff"
|
||||||
|
import { Code } from "@opencode-ai/ui/code"
|
||||||
|
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||||
|
import { LayoutProvider } from "@/context/layout"
|
||||||
|
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||||
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
|
import { PromptProvider } from "@/context/prompt"
|
||||||
|
import { NotificationProvider } from "@/context/notification"
|
||||||
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { CommandProvider } from "@/context/command"
|
||||||
|
import Layout from "@/pages/layout"
|
||||||
|
import Home from "@/pages/home"
|
||||||
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
|
import Session from "@/pages/session"
|
||||||
|
import { ErrorPage } from "./pages/error"
|
||||||
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__OPENCODE__?: { updaterEnabled?: boolean; port?: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = iife(() => {
|
||||||
|
const param = new URLSearchParams(document.location.search).get("url")
|
||||||
|
if (param) return param
|
||||||
|
|
||||||
|
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
|
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
|
||||||
|
if (import.meta.env.DEV)
|
||||||
|
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
return "http://localhost:4096"
|
||||||
|
})
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<MetaProvider>
|
||||||
|
<Font />
|
||||||
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
|
<DialogProvider>
|
||||||
|
<MarkedProvider>
|
||||||
|
<DiffComponentProvider component={Diff}>
|
||||||
|
<CodeComponentProvider component={Code}>
|
||||||
|
<GlobalSDKProvider url={url}>
|
||||||
|
<GlobalSyncProvider>
|
||||||
|
<LayoutProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<Router
|
||||||
|
root={(props) => (
|
||||||
|
<CommandProvider>
|
||||||
|
<Layout>{props.children}</Layout>
|
||||||
|
</CommandProvider>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Route path="/" component={Home} />
|
||||||
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
|
<Route
|
||||||
|
path="/session/:id?"
|
||||||
|
component={(p) => (
|
||||||
|
<Show when={p.params.id || true} keyed>
|
||||||
|
<TerminalProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<Session />
|
||||||
|
</PromptProvider>
|
||||||
|
</TerminalProvider>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Router>
|
||||||
|
</NotificationProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</GlobalSyncProvider>
|
||||||
|
</GlobalSDKProvider>
|
||||||
|
</CodeComponentProvider>
|
||||||
|
</DiffComponentProvider>
|
||||||
|
</MarkedProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</MetaProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
|
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
|
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||||
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { List, ListRef } 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 { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|
||||||
import { IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { DialogSelectModel } from "./dialog-select-model"
|
import { DialogSelectModel } from "./dialog-select-model"
|
||||||
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
@ -108,20 +108,18 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
await globalSDK.client.global.dispose()
|
await globalSDK.client.global.dispose()
|
||||||
setTimeout(() => {
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
icon: "circle-check",
|
icon: "circle-check",
|
||||||
title: `${provider().name} connected`,
|
title: `${provider().name} connected`,
|
||||||
description: `${provider().name} models are now available to use.`,
|
description: `${provider().name} models are now available to use.`,
|
||||||
})
|
})
|
||||||
dialog.replace(() => <DialogSelectModel provider={props.provider} />)
|
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (methods().length === 1) {
|
if (methods().length === 1) {
|
||||||
dialog.replace(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.authorization) {
|
if (store.authorization) {
|
||||||
|
|
@ -133,7 +131,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
setStore("methodIndex", undefined)
|
setStore("methodIndex", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.replace(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -156,7 +154,9 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
|
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<List
|
<List
|
||||||
ref={(ref) => (listRef = ref)}
|
ref={(ref) => {
|
||||||
|
listRef = ref
|
||||||
|
}}
|
||||||
items={methods}
|
items={methods}
|
||||||
key={(m) => m?.label}
|
key={(m) => m?.label}
|
||||||
onSelect={async (method, index) => {
|
onSelect={async (method, index) => {
|
||||||
|
|
@ -165,7 +165,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-4">
|
<div class="w-full flex items-center gap-x-2">
|
||||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||||
<div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
<div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -177,7 +177,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "pending"}>
|
<Match when={store.state === "pending"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
<div class="flex items-center gap-x-4">
|
<div class="flex items-center gap-x-2">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span>Authorization in progress...</span>
|
<span>Authorization in progress...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -185,7 +185,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "error"}>
|
<Match when={store.state === "error"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
<div class="flex items-center gap-x-4">
|
<div class="flex items-center gap-x-2">
|
||||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||||
<span>Authorization failed: {store.error}</span>
|
<span>Authorization failed: {store.error}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -352,7 +352,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
})
|
})
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
// TODO: show error
|
// TODO: show error
|
||||||
dialog.clear()
|
dialog.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await complete()
|
await complete()
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
import { Component } from "solid-js"
|
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import type { Component } from "solid-js"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
|
|
||||||
export const DialogManageModels: Component = () => {
|
export const DialogManageModels: Component = () => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
return (
|
return (
|
||||||
<Dialog title="Manage models" description="Customize which models appear in the model selector.">
|
<Dialog title="Manage models" description="Customize which models appear in the model selector.">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search models", autofocus: true }}
|
search={{ placeholder: "Search models", autofocus: true }}
|
||||||
emptyMessage="No model results"
|
emptyMessage="No model results"
|
||||||
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
||||||
|
|
@ -27,16 +26,24 @@ export const DialogManageModels: Component = () => {
|
||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
const visible = local.model.visible({ modelID: x.id, providerID: x.provider.id })
|
const visible = local.model.visible({
|
||||||
|
modelID: x.id,
|
||||||
|
providerID: x.provider.id,
|
||||||
|
})
|
||||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center justify-between gap-x-2.5">
|
<div class="w-full flex items-center justify-between gap-x-3">
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
|
checked={
|
||||||
|
!!local.model.visible({
|
||||||
|
modelID: i.id,
|
||||||
|
providerID: i.provider.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
||||||
}}
|
}}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { List } from "@opencode-ai/ui/list"
|
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
|
||||||
export function DialogSelectFile() {
|
export function DialogSelectFile() {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
|
@ -18,7 +18,6 @@ export function DialogSelectFile() {
|
||||||
return (
|
return (
|
||||||
<Dialog title="Select file">
|
<Dialog title="Select file">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search files", autofocus: true }}
|
search={{ placeholder: "Search files", autofocus: true }}
|
||||||
emptyMessage="No files found"
|
emptyMessage="No files found"
|
||||||
items={local.file.searchFiles}
|
items={local.file.searchFiles}
|
||||||
|
|
@ -27,12 +26,12 @@ export function DialogSelectFile() {
|
||||||
if (path) {
|
if (path) {
|
||||||
tabs().open("file://" + path)
|
tabs().open("file://" + path)
|
||||||
}
|
}
|
||||||
dialog.clear()
|
dialog.close()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular">
|
<div class="flex items-center text-14-regular">
|
||||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { Component, onCleanup, onMount, Show } from "solid-js"
|
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List, ListRef } from "@opencode-ai/ui/list"
|
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
|
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { IconName } from "@opencode-ai/ui/icons/provider"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { type Component, onCleanup, onMount, Show } from "solid-js"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
export const DialogSelectModelUnpaid: Component = () => {
|
export const DialogSelectModelUnpaid: Component = () => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
|
@ -42,7 +42,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||||
recent: true,
|
recent: true,
|
||||||
})
|
})
|
||||||
dialog.clear()
|
dialog.close()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
|
|
@ -64,7 +64,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||||
<div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
|
<div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<List
|
<List
|
||||||
class="w-full"
|
class="w-full px-0"
|
||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={providers.popular}
|
items={providers.popular}
|
||||||
activeIcon="plus-small"
|
activeIcon="plus-small"
|
||||||
|
|
@ -75,21 +75,12 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
dialog.replace(() => <DialogConnectProvider provider={x.id} />)
|
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-4">
|
<div class="w-full flex items-center gap-x-3">
|
||||||
<ProviderIcon
|
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
||||||
data-slot="list-item-extra-icon"
|
|
||||||
id={i.id as IconName}
|
|
||||||
// TODO: clean this up after we update icon in models.dev
|
|
||||||
classList={{
|
|
||||||
"text-icon-weak-base": true,
|
|
||||||
"size-4 mx-0.5": i.id === "opencode",
|
|
||||||
"size-5": i.id !== "opencode",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<Tag>Recommended</Tag>
|
<Tag>Recommended</Tag>
|
||||||
|
|
@ -105,7 +96,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||||
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
||||||
icon="dot-grid"
|
icon="dot-grid"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dialog.replace(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
View all providers
|
View all providers
|
||||||
|
|
@ -28,14 +28,13 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||||
class="h-7 -my-1 text-14-medium"
|
class="h-7 -my-1 text-14-medium"
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={() => dialog.replace(() => <DialogSelectProvider />)}
|
onClick={() => dialog.show(() => <DialogSelectProvider />)}
|
||||||
>
|
>
|
||||||
Connect provider
|
Connect provider
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search models", autofocus: true }}
|
search={{ placeholder: "Search models", autofocus: true }}
|
||||||
emptyMessage="No model results"
|
emptyMessage="No model results"
|
||||||
key={(x) => `${x.provider.id}:${x.id}`}
|
key={(x) => `${x.provider.id}:${x.id}`}
|
||||||
|
|
@ -57,11 +56,11 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||||
recent: true,
|
recent: true,
|
||||||
})
|
})
|
||||||
dialog.clear()
|
dialog.close()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-2.5">
|
<div class="w-full flex items-center gap-x-3">
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
||||||
<Tag>Free</Tag>
|
<Tag>Free</Tag>
|
||||||
|
|
@ -75,7 +74,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
||||||
onClick={() => dialog.replace(() => <DialogManageModels />)}
|
onClick={() => dialog.show(() => <DialogManageModels />)}
|
||||||
>
|
>
|
||||||
Manage models
|
Manage models
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -15,7 +15,6 @@ export const DialogSelectProvider: Component = () => {
|
||||||
return (
|
return (
|
||||||
<Dialog title="Connect provider">
|
<Dialog title="Connect provider">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search providers", autofocus: true }}
|
search={{ placeholder: "Search providers", autofocus: true }}
|
||||||
activeIcon="plus-small"
|
activeIcon="plus-small"
|
||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
|
|
@ -34,21 +33,12 @@ export const DialogSelectProvider: Component = () => {
|
||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
dialog.replace(() => <DialogConnectProvider provider={x.id} />)
|
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="px-1.25 w-full flex items-center gap-x-4">
|
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||||
<ProviderIcon
|
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
||||||
data-slot="list-item-extra-icon"
|
|
||||||
id={i.id as IconName}
|
|
||||||
// TODO: clean this up after we update icon in models.dev
|
|
||||||
classList={{
|
|
||||||
"text-icon-weak-base": true,
|
|
||||||
"size-4 mx-0.5": i.id === "opencode",
|
|
||||||
"size-5": i.id !== "opencode",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<Tag>Recommended</Tag>
|
<Tag>Recommended</Tag>
|
||||||
209
packages/app/src/components/header.tsx
Normal file
209
packages/app/src/components/header.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
import { A, useParams } from "@solidjs/router"
|
||||||
|
import { createMemo, createResource, Show } from "solid-js"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
|
|
||||||
|
export function Header(props: {
|
||||||
|
navigateToProject: (directory: string) => void
|
||||||
|
navigateToSession: (session: Session | undefined) => void
|
||||||
|
onMobileMenuToggle?: () => void
|
||||||
|
}) {
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
|
const globalSDK = useGlobalSDK()
|
||||||
|
const layout = useLayout()
|
||||||
|
const params = useParams()
|
||||||
|
const command = useCommand()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
|
||||||
|
onClick={props.onMobileMenuToggle}
|
||||||
|
>
|
||||||
|
<Icon name="menu" size="small" />
|
||||||
|
</button>
|
||||||
|
<A
|
||||||
|
href="/"
|
||||||
|
classList={{
|
||||||
|
"hidden xl:flex": true,
|
||||||
|
"w-12 shrink-0 px-4 py-3.5": true,
|
||||||
|
"items-center justify-start self-stretch": true,
|
||||||
|
"border-r border-border-weak-base": true,
|
||||||
|
}}
|
||||||
|
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
<Mark class="shrink-0" />
|
||||||
|
</A>
|
||||||
|
<div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
|
||||||
|
<Show when={layout.projects.list().length > 0 && params.dir}>
|
||||||
|
{(directory) => {
|
||||||
|
const currentDirectory = createMemo(() => base64Decode(directory()))
|
||||||
|
const store = createMemo(() => globalSync.child(currentDirectory())[0])
|
||||||
|
const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
|
||||||
|
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
||||||
|
const shareEnabled = createMemo(() => store().config.share !== "disabled")
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<div class="hidden xl:flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
options={layout.projects.list().map((project) => project.worktree)}
|
||||||
|
current={currentDirectory()}
|
||||||
|
label={(x) => getFilename(x)}
|
||||||
|
onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
|
||||||
|
class="text-14-regular text-text-base"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
{(i) => (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon name="folder" size="small" />
|
||||||
|
<div class="text-text-strong">{getFilename(i)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
<div class="text-text-weaker">/</div>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
options={sessions()}
|
||||||
|
current={currentSession()}
|
||||||
|
placeholder="New session"
|
||||||
|
label={(x) => x.title}
|
||||||
|
value={(x) => x.id}
|
||||||
|
onSelect={props.navigateToSession}
|
||||||
|
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={currentSession()}>
|
||||||
|
<Tooltip
|
||||||
|
class="hidden xl:block"
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>New session</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
|
||||||
|
New session
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Tooltip
|
||||||
|
class="hidden md:block shrink-0"
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>Toggle review</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
|
||||||
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
|
<Icon
|
||||||
|
size="small"
|
||||||
|
name={layout.review.opened() ? "layout-right-full" : "layout-right"}
|
||||||
|
class="group-hover/review-toggle:hidden"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
size="small"
|
||||||
|
name="layout-right-partial"
|
||||||
|
class="hidden group-hover/review-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
size="small"
|
||||||
|
name={layout.review.opened() ? "layout-right" : "layout-right-full"}
|
||||||
|
class="hidden group-active/review-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
class="hidden md:block shrink-0"
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>Toggle terminal</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
|
||||||
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
|
<Icon
|
||||||
|
size="small"
|
||||||
|
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
||||||
|
class="group-hover/terminal-toggle:hidden"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
size="small"
|
||||||
|
name="layout-bottom-partial"
|
||||||
|
class="hidden group-hover/terminal-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
size="small"
|
||||||
|
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
||||||
|
class="hidden group-active/terminal-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Show when={shareEnabled() && currentSession()}>
|
||||||
|
<Popover
|
||||||
|
title="Share session"
|
||||||
|
trigger={
|
||||||
|
<Tooltip class="shrink-0" value="Share session">
|
||||||
|
<IconButton icon="share" variant="ghost" class="" />
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{iife(() => {
|
||||||
|
const [url] = createResource(
|
||||||
|
() => currentSession(),
|
||||||
|
async (session) => {
|
||||||
|
if (!session) return
|
||||||
|
let shareURL = session.share?.url
|
||||||
|
if (!shareURL) {
|
||||||
|
shareURL = await globalSDK.client.session
|
||||||
|
.share({ sessionID: session.id, directory: currentDirectory() })
|
||||||
|
.then((r) => r.data?.share?.url)
|
||||||
|
}
|
||||||
|
return shareURL
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Show when={url()}>
|
||||||
|
{(url) => <TextField value={url()} readOnly copyable class="w-72" />}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Popover>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
|
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { makePersisted } from "@solid-primitives/storage"
|
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
|
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
|
||||||
|
|
@ -20,7 +19,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
import { DialogSelectModel } 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, formatKeybind } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
|
import { persisted } from "@/utils/persist"
|
||||||
|
import { Identifier } from "@/utils/id"
|
||||||
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
|
|
||||||
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||||
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||||
|
|
@ -99,6 +101,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
placeholder: number
|
placeholder: number
|
||||||
dragging: boolean
|
dragging: boolean
|
||||||
imageAttachments: ImageAttachmentPart[]
|
imageAttachments: ImageAttachmentPart[]
|
||||||
|
mode: "normal" | "shell"
|
||||||
|
applyingHistory: boolean
|
||||||
|
userHasEdited: boolean
|
||||||
}>({
|
}>({
|
||||||
popover: null,
|
popover: null,
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
|
|
@ -106,18 +111,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
||||||
dragging: false,
|
dragging: false,
|
||||||
imageAttachments: [],
|
imageAttachments: [],
|
||||||
|
mode: "normal",
|
||||||
|
applyingHistory: false,
|
||||||
|
userHasEdited: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const MAX_HISTORY = 100
|
const MAX_HISTORY = 100
|
||||||
const [history, setHistory] = makePersisted(
|
const [history, setHistory] = persisted(
|
||||||
|
"prompt-history.v1",
|
||||||
|
createStore<{
|
||||||
|
entries: Prompt[]
|
||||||
|
}>({
|
||||||
|
entries: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const [shellHistory, setShellHistory] = persisted(
|
||||||
|
"prompt-history-shell.v1",
|
||||||
createStore<{
|
createStore<{
|
||||||
entries: Prompt[]
|
entries: Prompt[]
|
||||||
}>({
|
}>({
|
||||||
entries: [],
|
entries: [],
|
||||||
}),
|
}),
|
||||||
{
|
|
||||||
name: "prompt-history.v1",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const clonePromptParts = (prompt: Prompt): Prompt =>
|
const clonePromptParts = (prompt: Prompt): Prompt =>
|
||||||
|
|
@ -135,10 +149,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
|
|
||||||
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("userHasEdited", false)
|
||||||
prompt.set(p, length)
|
prompt.set(p, length)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
editorRef.focus()
|
editorRef.focus()
|
||||||
setCursorPosition(editorRef, length)
|
setCursorPosition(editorRef, length)
|
||||||
|
setStore("applyingHistory", false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,31 +397,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const parseFromDOM = (): Prompt => {
|
const parseFromDOM = (): Prompt => {
|
||||||
const newParts: Prompt = []
|
const newParts: Prompt = []
|
||||||
let position = 0
|
let position = 0
|
||||||
editorRef.childNodes.forEach((node) => {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
const pushText = (content: string) => {
|
||||||
if (node.textContent) {
|
if (!content) return
|
||||||
const content = node.textContent
|
newParts.push({ type: "text", content, start: position, end: position + content.length })
|
||||||
newParts.push({ type: "text", content, start: position, end: position + content.length })
|
position += content.length
|
||||||
position += content.length
|
}
|
||||||
}
|
|
||||||
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) {
|
const rangeText = (range: Range) => {
|
||||||
switch ((node as HTMLElement).dataset.type) {
|
const fragment = range.cloneContents()
|
||||||
case "file":
|
const container = document.createElement("div")
|
||||||
const content = node.textContent!
|
container.append(fragment)
|
||||||
newParts.push({
|
return container.innerText
|
||||||
type: "file",
|
}
|
||||||
path: (node as HTMLElement).dataset.path!,
|
|
||||||
content,
|
const files = Array.from(editorRef.querySelectorAll<HTMLElement>("[data-type=file]"))
|
||||||
start: position,
|
let last: HTMLElement | undefined
|
||||||
end: position + content.length,
|
|
||||||
})
|
files.forEach((file) => {
|
||||||
position += content.length
|
const before = document.createRange()
|
||||||
break
|
before.selectNodeContents(editorRef)
|
||||||
default:
|
if (last) before.setStartAfter(last)
|
||||||
break
|
before.setEndBefore(file)
|
||||||
}
|
pushText(rangeText(before))
|
||||||
}
|
|
||||||
|
const content = file.textContent ?? ""
|
||||||
|
newParts.push({
|
||||||
|
type: "file",
|
||||||
|
path: file.dataset.path!,
|
||||||
|
content,
|
||||||
|
start: position,
|
||||||
|
end: position + content.length,
|
||||||
|
})
|
||||||
|
position += content.length
|
||||||
|
last = file
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const after = document.createRange()
|
||||||
|
after.selectNodeContents(editorRef)
|
||||||
|
if (last) after.setStartAfter(last)
|
||||||
|
pushText(rangeText(after))
|
||||||
|
|
||||||
if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
|
if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
|
||||||
return newParts
|
return newParts
|
||||||
}
|
}
|
||||||
|
|
@ -413,25 +446,51 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const rawParts = parseFromDOM()
|
const rawParts = parseFromDOM()
|
||||||
const cursorPosition = getCursorPosition(editorRef)
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
||||||
|
const trimmed = rawText.replace(/\u200B/g, "").trim()
|
||||||
|
const hasNonText = rawParts.some((part) => part.type !== "text")
|
||||||
|
const shouldReset = trimmed.length === 0 && !hasNonText
|
||||||
|
|
||||||
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
if (shouldReset) {
|
||||||
const slashMatch = rawText.match(/^\/(\S*)$/)
|
setStore("popover", null)
|
||||||
|
setStore("userHasEdited", false)
|
||||||
|
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
||||||
|
setStore("historyIndex", -1)
|
||||||
|
setStore("savedPrompt", null)
|
||||||
|
}
|
||||||
|
if (prompt.dirty()) {
|
||||||
|
prompt.set(DEFAULT_PROMPT, 0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (atMatch) {
|
const shellMode = store.mode === "shell"
|
||||||
onInput(atMatch[1])
|
|
||||||
setStore("popover", "file")
|
if (!shellMode) {
|
||||||
} else if (slashMatch) {
|
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
|
||||||
slashOnInput(slashMatch[1])
|
const slashMatch = rawText.match(/^\/(\S*)$/)
|
||||||
setStore("popover", "slash")
|
|
||||||
|
if (atMatch) {
|
||||||
|
onInput(atMatch[1])
|
||||||
|
setStore("popover", "file")
|
||||||
|
} else if (slashMatch) {
|
||||||
|
slashOnInput(slashMatch[1])
|
||||||
|
setStore("popover", "slash")
|
||||||
|
} else {
|
||||||
|
setStore("popover", null)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setStore("popover", null)
|
setStore("popover", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (store.historyIndex >= 0) {
|
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
||||||
setStore("historyIndex", -1)
|
setStore("historyIndex", -1)
|
||||||
setStore("savedPrompt", null)
|
setStore("savedPrompt", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!store.applyingHistory) {
|
||||||
|
setStore("userHasEdited", true)
|
||||||
|
}
|
||||||
|
|
||||||
prompt.set(rawParts, cursorPosition)
|
prompt.set(rawParts, cursorPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -505,7 +564,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
sessionID: params.id!,
|
sessionID: params.id!,
|
||||||
})
|
})
|
||||||
|
|
||||||
const addToHistory = (prompt: Prompt) => {
|
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||||
const text = prompt
|
const text = prompt
|
||||||
.map((p) => ("content" in p ? p.content : ""))
|
.map((p) => ("content" in p ? p.content : ""))
|
||||||
.join("")
|
.join("")
|
||||||
|
|
@ -513,17 +572,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
|
||||||
const entry = clonePromptParts(prompt)
|
const entry = clonePromptParts(prompt)
|
||||||
const lastEntry = history.entries[0]
|
const currentHistory = mode === "shell" ? shellHistory : history
|
||||||
|
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
||||||
|
const lastEntry = currentHistory.entries[0]
|
||||||
if (lastEntry) {
|
if (lastEntry) {
|
||||||
const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
|
const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
|
||||||
if (lastText === text) return
|
if (lastText === text) return
|
||||||
}
|
}
|
||||||
|
|
||||||
setHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
|
setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateHistory = (direction: "up" | "down") => {
|
const navigateHistory = (direction: "up" | "down") => {
|
||||||
const entries = history.entries
|
if (store.userHasEdited) return false
|
||||||
|
|
||||||
|
const entries = store.mode === "shell" ? shellHistory.entries : history.entries
|
||||||
const current = store.historyIndex
|
const current = store.historyIndex
|
||||||
|
|
||||||
if (direction === "up") {
|
if (direction === "up") {
|
||||||
|
|
@ -565,6 +628,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "!" && store.mode === "normal") {
|
||||||
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
|
if (cursorPosition === 0) {
|
||||||
|
setStore("mode", "shell")
|
||||||
|
setStore("popover", null)
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (store.mode === "shell") {
|
||||||
|
const { collapsed, cursorPosition, textLength } = getCaretState()
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setStore("mode", "normal")
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
|
||||||
|
setStore("mode", "normal")
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||||
if (store.popover === "file") {
|
if (store.popover === "file") {
|
||||||
onKeyDown(event)
|
onKeyDown(event)
|
||||||
|
|
@ -577,13 +663,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
|
|
||||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||||
const { collapsed, cursorPosition, textLength } = getCaretState()
|
const { collapsed } = getCaretState()
|
||||||
if (!collapsed) return
|
if (!collapsed) return
|
||||||
|
|
||||||
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
|
const textLength = promptLength(prompt.current())
|
||||||
|
const textContent = editorRef.textContent ?? ""
|
||||||
|
const isEmpty = textContent.trim() === "" || textLength <= 1
|
||||||
|
const hasNewlines = textContent.includes("\n")
|
||||||
const inHistory = store.historyIndex >= 0
|
const inHistory = store.historyIndex >= 0
|
||||||
const atAbsoluteStart = cursorPosition === 0
|
const atStart = cursorPosition <= (isEmpty ? 1 : 0)
|
||||||
const atAbsoluteEnd = cursorPosition === textLength
|
const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
|
||||||
const allowUp = (inHistory && atAbsoluteEnd) || atAbsoluteStart
|
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
|
||||||
const allowDown = (inHistory && atAbsoluteStart) || atAbsoluteEnd
|
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
|
||||||
|
|
||||||
if (event.key === "ArrowUp") {
|
if (event.key === "ArrowUp") {
|
||||||
if (!allowUp) return
|
if (!allowUp) return
|
||||||
|
|
@ -622,9 +714,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
addToHistory(currentPrompt)
|
addToHistory(currentPrompt, store.mode)
|
||||||
setStore("historyIndex", -1)
|
setStore("historyIndex", -1)
|
||||||
setStore("savedPrompt", null)
|
setStore("savedPrompt", null)
|
||||||
|
setStore("userHasEdited", false)
|
||||||
|
|
||||||
let existing = info()
|
let existing = info()
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
|
@ -645,6 +738,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
||||||
: ""
|
: ""
|
||||||
return {
|
return {
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
mime: "text/plain",
|
mime: "text/plain",
|
||||||
url: `file://${absolute}${query}`,
|
url: `file://${absolute}${query}`,
|
||||||
|
|
@ -662,16 +756,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
|
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
mime: attachment.mime,
|
mime: attachment.mime,
|
||||||
url: attachment.dataUrl,
|
url: attachment.dataUrl,
|
||||||
filename: attachment.filename,
|
filename: attachment.filename,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const isShellMode = store.mode === "shell"
|
||||||
tabs().setActive(undefined)
|
tabs().setActive(undefined)
|
||||||
editorRef.innerHTML = ""
|
editorRef.innerHTML = ""
|
||||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||||
setStore("imageAttachments", [])
|
setStore("imageAttachments", [])
|
||||||
|
setStore("mode", "normal")
|
||||||
|
|
||||||
|
const model = {
|
||||||
|
modelID: local.model.current()!.id,
|
||||||
|
providerID: local.model.current()!.provider.id,
|
||||||
|
}
|
||||||
|
const agent = local.agent.current()!.name
|
||||||
|
|
||||||
|
if (isShellMode) {
|
||||||
|
sdk.client.session.shell({
|
||||||
|
sessionID: existing.id,
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
command: text,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (text.startsWith("/")) {
|
if (text.startsWith("/")) {
|
||||||
const [cmdName, ...args] = text.split(" ")
|
const [cmdName, ...args] = text.split(" ")
|
||||||
|
|
@ -682,28 +795,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
sessionID: existing.id,
|
sessionID: existing.id,
|
||||||
command: commandName,
|
command: commandName,
|
||||||
arguments: args.join(" "),
|
arguments: args.join(" "),
|
||||||
agent: local.agent.current()!.name,
|
agent,
|
||||||
model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`,
|
model: `${model.providerID}/${model.modelID}`,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageID = Identifier.ascending("message")
|
||||||
|
const textPart = {
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "text" as const,
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
|
||||||
|
const optimisticParts = requestParts.map((part) => ({
|
||||||
|
...part,
|
||||||
|
sessionID: existing.id,
|
||||||
|
messageID,
|
||||||
|
}))
|
||||||
|
|
||||||
|
sync.session.addOptimisticMessage({
|
||||||
|
sessionID: existing.id,
|
||||||
|
messageID,
|
||||||
|
parts: optimisticParts,
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
|
||||||
sdk.client.session.prompt({
|
sdk.client.session.prompt({
|
||||||
sessionID: existing.id,
|
sessionID: existing.id,
|
||||||
agent: local.agent.current()!.name,
|
agent,
|
||||||
model: {
|
model,
|
||||||
modelID: local.model.current()!.id,
|
messageID,
|
||||||
providerID: local.model.current()!.provider.id,
|
parts: requestParts,
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
...fileAttachmentParts,
|
|
||||||
...imageAttachmentParts,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -765,8 +890,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
custom
|
custom
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={cmd.keybind}>
|
<Show when={command.keybind(cmd.id)}>
|
||||||
<span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
|
<span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -833,6 +958,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
</Show>
|
</Show>
|
||||||
<div class="relative max-h-[240px] overflow-y-auto">
|
<div class="relative max-h-[240px] overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
|
data-component="prompt-input"
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
editorRef = el
|
editorRef = el
|
||||||
props.ref?.(el)
|
props.ref?.(el)
|
||||||
|
|
@ -843,34 +969,73 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
classList={{
|
classList={{
|
||||||
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||||
"[&>[data-type=file]]:text-icon-info-active": true,
|
"[&>[data-type=file]]:text-icon-info-active": true,
|
||||||
|
"font-mono!": store.mode === "shell",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
|
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
|
||||||
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
|
<div class="absolute top-0 inset-x-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
||||||
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
|
{store.mode === "shell"
|
||||||
|
? "Enter shell command..."
|
||||||
|
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative p-3 flex items-center justify-between">
|
<div class="relative p-3 flex items-center justify-between">
|
||||||
<div class="flex items-center justify-start gap-1">
|
<div class="flex items-center justify-start gap-1">
|
||||||
<Select
|
<Switch>
|
||||||
options={local.agent.list().map((agent) => agent.name)}
|
<Match when={store.mode === "shell"}>
|
||||||
current={local.agent.current().name}
|
<div class="flex items-center gap-2 px-2 h-6">
|
||||||
onSelect={local.agent.set}
|
<Icon name="console" size="small" class="text-icon-primary" />
|
||||||
class="capitalize"
|
<span class="text-12-regular text-text-primary">Shell</span>
|
||||||
variant="ghost"
|
<span class="text-12-regular text-text-weak">esc to exit</span>
|
||||||
/>
|
</div>
|
||||||
<Button
|
</Match>
|
||||||
as="div"
|
<Match when={store.mode === "normal"}>
|
||||||
variant="ghost"
|
<Tooltip
|
||||||
onClick={() =>
|
placement="top"
|
||||||
dialog.push(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />))
|
value={
|
||||||
}
|
<div class="flex items-center gap-2">
|
||||||
>
|
<span>Cycle agent</span>
|
||||||
{local.model.current()?.name ?? "Select model"}
|
<span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
|
||||||
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
|
</div>
|
||||||
<Icon name="chevron-down" size="small" />
|
}
|
||||||
</Button>
|
>
|
||||||
|
<Select
|
||||||
|
options={local.agent.list().map((agent) => agent.name)}
|
||||||
|
current={local.agent.current().name}
|
||||||
|
onSelect={local.agent.set}
|
||||||
|
class="capitalize"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>Choose model</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as="div"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
dialog.show(() =>
|
||||||
|
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{local.model.current()?.name ?? "Select model"}
|
||||||
|
<span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
|
||||||
|
{local.model.current()?.provider.name}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
<SessionContextUsage />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 absolute right-2 bottom-2">
|
<div class="flex items-center gap-1 absolute right-2 bottom-2">
|
||||||
<input
|
<input
|
||||||
|
|
@ -884,15 +1049,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
e.currentTarget.value = ""
|
e.currentTarget.value = ""
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tooltip placement="top" value="Attach image">
|
<Show when={store.mode === "normal"}>
|
||||||
<IconButton
|
<Tooltip placement="top" value="Attach image">
|
||||||
type="button"
|
<IconButton
|
||||||
icon="photo"
|
type="button"
|
||||||
variant="ghost"
|
icon="photo"
|
||||||
class="h-10 w-8"
|
variant="ghost"
|
||||||
onClick={() => fileInputRef.click()}
|
class="h-10 w-8"
|
||||||
/>
|
onClick={() => fileInputRef.click()}
|
||||||
</Tooltip>
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement="top"
|
placement="top"
|
||||||
inactive={!prompt.dirty() && !working()}
|
inactive={!prompt.dirty() && !working()}
|
||||||
64
packages/app/src/components/session-context-usage.tsx
Normal file
64
packages/app/src/components/session-context-usage.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
export function SessionContextUsage() {
|
||||||
|
const sync = useSync()
|
||||||
|
const params = useParams()
|
||||||
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
|
|
||||||
|
const cost = createMemo(() => {
|
||||||
|
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
const context = createMemo(() => {
|
||||||
|
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
|
||||||
|
if (!last) return
|
||||||
|
const total =
|
||||||
|
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||||
|
const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||||
|
return {
|
||||||
|
tokens: total.toLocaleString(),
|
||||||
|
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={context?.()}>
|
||||||
|
{(ctx) => (
|
||||||
|
<Tooltip
|
||||||
|
openDelay={300}
|
||||||
|
value={
|
||||||
|
<div class="flex flex-col gap-1 p-2">
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<span class="text-text-weaker">Tokens</span>
|
||||||
|
<span class="text-text-strong">{ctx().tokens}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<span class="text-text-weaker">Usage</span>
|
||||||
|
<span class="text-text-strong">{ctx().percentage ?? 0}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<span class="text-text-weaker">Cost</span>
|
||||||
|
<span class="text-text-strong">{cost()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span>
|
||||||
|
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||||
import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js"
|
import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { SerializeAddon } from "@/addons/serialize"
|
import { SerializeAddon } from "@/addons/serialize"
|
||||||
import { LocalPTY } from "@/context/terminal"
|
import { LocalPTY } from "@/context/terminal"
|
||||||
|
|
@ -31,7 +31,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
term = new Term({
|
term = new Term({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "TX-02, monospace",
|
fontFamily: "IBM Plex Mono, monospace",
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
theme: prefersDark()
|
theme: prefersDark()
|
||||||
? {
|
? {
|
||||||
|
|
@ -148,6 +148,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
<div
|
<div
|
||||||
ref={container}
|
ref={container}
|
||||||
data-component="terminal"
|
data-component="terminal"
|
||||||
|
data-prevent-autofocus
|
||||||
classList={{
|
classList={{
|
||||||
...(local.classList ?? {}),
|
...(local.classList ?? {}),
|
||||||
"size-full px-6 py-3 font-mono": true,
|
"size-full px-6 py-3 font-mono": true,
|
||||||
|
|
@ -119,7 +119,6 @@ function DialogCommand(props: { options: CommandOption[] }) {
|
||||||
return (
|
return (
|
||||||
<Dialog title="Commands">
|
<Dialog title="Commands">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search commands", autofocus: true }}
|
search={{ placeholder: "Search commands", autofocus: true }}
|
||||||
emptyMessage="No commands found"
|
emptyMessage="No commands found"
|
||||||
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
|
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
|
||||||
|
|
@ -128,7 +127,7 @@ function DialogCommand(props: { options: CommandOption[] }) {
|
||||||
groupBy={(x) => x.category ?? ""}
|
groupBy={(x) => x.category ?? ""}
|
||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (option) {
|
if (option) {
|
||||||
dialog.clear()
|
dialog.close()
|
||||||
option.onSelect?.("palette")
|
option.onSelect?.("palette")
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -174,8 +173,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||||
const suspended = () => suspendCount() > 0
|
const suspended = () => suspendCount() > 0
|
||||||
|
|
||||||
const showPalette = () => {
|
const showPalette = () => {
|
||||||
if (dialog.stack.length === 0) {
|
if (!dialog.active) {
|
||||||
dialog.replace(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
|
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,6 +225,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
keybind(id: string) {
|
||||||
|
const option = options().find((x) => x.id === id || x.id === "suggested." + id)
|
||||||
|
if (!option?.keybind) return ""
|
||||||
|
return formatKeybind(option.keybind)
|
||||||
|
},
|
||||||
show: showPalette,
|
show: showPalette,
|
||||||
keybinds(enabled: boolean) {
|
keybinds(enabled: boolean) {
|
||||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||||
|
|
@ -1,30 +1,32 @@
|
||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient, 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 { onCleanup } from "solid-js"
|
import { usePlatform } from "./platform"
|
||||||
|
|
||||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||||
name: "GlobalSDK",
|
name: "GlobalSDK",
|
||||||
init: (props: { url: string }) => {
|
init: (props: { url: string }) => {
|
||||||
const abort = new AbortController()
|
const eventSdk = createOpencodeClient({
|
||||||
const sdk = createOpencodeClient({
|
|
||||||
baseUrl: props.url,
|
baseUrl: props.url,
|
||||||
signal: abort.signal,
|
// signal: AbortSignal.timeout(1000 * 60 * 10),
|
||||||
})
|
})
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key: string]: Event
|
[key: string]: Event
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
sdk.global.event().then(async (events) => {
|
eventSdk.global.event().then(async (events) => {
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
// console.log("event", event)
|
// console.log("event", event)
|
||||||
emitter.emit(event.directory ?? "global", event.payload)
|
emitter.emit(event.directory ?? "global", event.payload)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
const platform = usePlatform()
|
||||||
abort.abort()
|
const sdk = createOpencodeClient({
|
||||||
|
baseUrl: props.url,
|
||||||
|
signal: AbortSignal.timeout(1000 * 60 * 10),
|
||||||
|
fetch: platform.fetch,
|
||||||
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { url: props.url, client: sdk, event: emitter }
|
return { url: props.url, client: sdk, event: emitter }
|
||||||
376
packages/app/src/context/global-sync.tsx
Normal file
376
packages/app/src/context/global-sync.tsx
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
import {
|
||||||
|
type Message,
|
||||||
|
type Agent,
|
||||||
|
type Session,
|
||||||
|
type Part,
|
||||||
|
type Config,
|
||||||
|
type Path,
|
||||||
|
type File,
|
||||||
|
type FileNode,
|
||||||
|
type Project,
|
||||||
|
type FileDiff,
|
||||||
|
type Todo,
|
||||||
|
type SessionStatus,
|
||||||
|
type ProviderListResponse,
|
||||||
|
type ProviderAuthResponse,
|
||||||
|
type Command,
|
||||||
|
createOpencodeClient,
|
||||||
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
|
import { ErrorPage, type InitError } from "../pages/error"
|
||||||
|
import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
ready: boolean
|
||||||
|
agent: Agent[]
|
||||||
|
command: Command[]
|
||||||
|
project: string
|
||||||
|
provider: ProviderListResponse
|
||||||
|
config: Config
|
||||||
|
path: Path
|
||||||
|
session: Session[]
|
||||||
|
session_status: {
|
||||||
|
[sessionID: string]: SessionStatus
|
||||||
|
}
|
||||||
|
session_diff: {
|
||||||
|
[sessionID: string]: FileDiff[]
|
||||||
|
}
|
||||||
|
todo: {
|
||||||
|
[sessionID: string]: Todo[]
|
||||||
|
}
|
||||||
|
limit: number
|
||||||
|
message: {
|
||||||
|
[sessionID: string]: Message[]
|
||||||
|
}
|
||||||
|
part: {
|
||||||
|
[messageID: string]: Part[]
|
||||||
|
}
|
||||||
|
node: FileNode[]
|
||||||
|
changes: File[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGlobalSync() {
|
||||||
|
const globalSDK = useGlobalSDK()
|
||||||
|
const [globalStore, setGlobalStore] = createStore<{
|
||||||
|
ready: boolean
|
||||||
|
error?: InitError
|
||||||
|
path: Path
|
||||||
|
project: Project[]
|
||||||
|
provider: ProviderListResponse
|
||||||
|
provider_auth: ProviderAuthResponse
|
||||||
|
children: Record<string, State>
|
||||||
|
}>({
|
||||||
|
ready: false,
|
||||||
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
|
project: [],
|
||||||
|
provider: { all: [], connected: [], default: {} },
|
||||||
|
provider_auth: {},
|
||||||
|
children: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
|
||||||
|
function child(directory: string) {
|
||||||
|
if (!directory) console.error("No directory provided")
|
||||||
|
if (!children[directory]) {
|
||||||
|
setGlobalStore("children", directory, {
|
||||||
|
project: "",
|
||||||
|
provider: { all: [], connected: [], default: {} },
|
||||||
|
config: {},
|
||||||
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
|
ready: false,
|
||||||
|
agent: [],
|
||||||
|
command: [],
|
||||||
|
session: [],
|
||||||
|
session_status: {},
|
||||||
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
|
limit: 5,
|
||||||
|
message: {},
|
||||||
|
part: {},
|
||||||
|
node: [],
|
||||||
|
changes: [],
|
||||||
|
})
|
||||||
|
children[directory] = createStore(globalStore.children[directory])
|
||||||
|
bootstrapInstance(directory)
|
||||||
|
}
|
||||||
|
return children[directory]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessions(directory: string) {
|
||||||
|
const [store, setStore] = child(directory)
|
||||||
|
globalSDK.client.session
|
||||||
|
.list({ directory })
|
||||||
|
.then((x) => {
|
||||||
|
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
|
||||||
|
const nonArchived = (x.data ?? [])
|
||||||
|
.slice()
|
||||||
|
.filter((s) => !s.time.archived)
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
// Include up to the limit, plus any updated in the last 4 hours
|
||||||
|
const sessions = nonArchived.filter((s, i) => {
|
||||||
|
if (i < store.limit) return true
|
||||||
|
const updated = new Date(s.time.updated).getTime()
|
||||||
|
return updated > fourHoursAgo
|
||||||
|
})
|
||||||
|
setStore("session", sessions)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to load sessions", err)
|
||||||
|
const project = getFilename(directory)
|
||||||
|
showToast({ title: `Failed to load sessions for ${project}`, description: err.message })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapInstance(directory: string) {
|
||||||
|
if (!directory) return
|
||||||
|
const [, setStore] = child(directory)
|
||||||
|
const sdk = createOpencodeClient({
|
||||||
|
baseUrl: globalSDK.url,
|
||||||
|
directory,
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
const load = {
|
||||||
|
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
||||||
|
provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
|
||||||
|
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||||
|
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||||
|
command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||||
|
session: () => loadSessions(directory),
|
||||||
|
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
||||||
|
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||||
|
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
|
||||||
|
node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
|
||||||
|
}
|
||||||
|
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
||||||
|
.then(() => setStore("ready", true))
|
||||||
|
.catch((e) => setGlobalStore("error", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
globalSDK.event.listen((e) => {
|
||||||
|
const directory = e.name
|
||||||
|
const event = e.details
|
||||||
|
|
||||||
|
if (directory === "global") {
|
||||||
|
switch (event?.type) {
|
||||||
|
case "global.disposed": {
|
||||||
|
bootstrap()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "project.updated": {
|
||||||
|
const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
|
||||||
|
if (result.found) {
|
||||||
|
setGlobalStore("project", result.index, reconcile(event.properties))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGlobalStore(
|
||||||
|
"project",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, event.properties)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [store, setStore] = child(directory)
|
||||||
|
switch (event.type) {
|
||||||
|
case "server.instance.disposed": {
|
||||||
|
bootstrapInstance(directory)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.updated": {
|
||||||
|
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||||
|
if (event.properties.info.time.archived) {
|
||||||
|
if (result.found) {
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (result.found) {
|
||||||
|
setStore("session", result.index, reconcile(event.properties.info))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, event.properties.info)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.diff":
|
||||||
|
setStore("session_diff", event.properties.sessionID, event.properties.diff)
|
||||||
|
break
|
||||||
|
case "todo.updated":
|
||||||
|
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||||
|
break
|
||||||
|
case "session.status": {
|
||||||
|
setStore("session_status", event.properties.sessionID, event.properties.status)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "message.updated": {
|
||||||
|
const messages = store.message[event.properties.info.sessionID]
|
||||||
|
if (!messages) {
|
||||||
|
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||||
|
if (result.found) {
|
||||||
|
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
"message",
|
||||||
|
event.properties.info.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, event.properties.info)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "message.removed": {
|
||||||
|
const messages = store.message[event.properties.sessionID]
|
||||||
|
if (!messages) break
|
||||||
|
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
|
||||||
|
if (result.found) {
|
||||||
|
setStore(
|
||||||
|
"message",
|
||||||
|
event.properties.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "message.part.updated": {
|
||||||
|
const part = event.properties.part
|
||||||
|
const parts = store.part[part.messageID]
|
||||||
|
if (!parts) {
|
||||||
|
setStore("part", part.messageID, [part])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const result = Binary.search(parts, part.id, (p) => p.id)
|
||||||
|
if (result.found) {
|
||||||
|
setStore("part", part.messageID, result.index, reconcile(part))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
"part",
|
||||||
|
part.messageID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, part)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "message.part.removed": {
|
||||||
|
const parts = store.part[event.properties.messageID]
|
||||||
|
if (!parts) break
|
||||||
|
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
|
||||||
|
if (result.found) {
|
||||||
|
setStore(
|
||||||
|
"part",
|
||||||
|
event.properties.messageID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const health = await globalSDK.client.global.health().then((x) => x.data)
|
||||||
|
if (!health?.healthy) {
|
||||||
|
setGlobalStore(
|
||||||
|
"error",
|
||||||
|
new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
retry(() =>
|
||||||
|
globalSDK.client.path.get().then((x) => {
|
||||||
|
setGlobalStore("path", x.data!)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
retry(() =>
|
||||||
|
globalSDK.client.project.list().then(async (x) => {
|
||||||
|
setGlobalStore(
|
||||||
|
"project",
|
||||||
|
x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
retry(() =>
|
||||||
|
globalSDK.client.provider.list().then((x) => {
|
||||||
|
setGlobalStore("provider", x.data ?? {})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
retry(() =>
|
||||||
|
globalSDK.client.provider.auth().then((x) => {
|
||||||
|
setGlobalStore("provider_auth", x.data ?? {})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(() => setGlobalStore("ready", true))
|
||||||
|
.catch((e) => setGlobalStore("error", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
bootstrap()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: globalStore,
|
||||||
|
get ready() {
|
||||||
|
return globalStore.ready
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return globalStore.error
|
||||||
|
},
|
||||||
|
child,
|
||||||
|
bootstrap,
|
||||||
|
project: {
|
||||||
|
loadSessions,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
||||||
|
|
||||||
|
export function GlobalSyncProvider(props: ParentProps) {
|
||||||
|
const value = createGlobalSync()
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Match when={value.error}>
|
||||||
|
<ErrorPage error={value.error} />
|
||||||
|
</Match>
|
||||||
|
<Match when={value.ready}>
|
||||||
|
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalSync() {
|
||||||
|
const context = useContext(GlobalSyncContext)
|
||||||
|
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { batch, createMemo, onMount } from "solid-js"
|
import { batch, createMemo, onMount } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { makePersisted } from "@solid-primitives/storage"
|
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { Project } from "@opencode-ai/sdk/v2"
|
import { Project } from "@opencode-ai/sdk/v2"
|
||||||
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||||
|
|
@ -27,12 +27,15 @@ type SessionTabs = {
|
||||||
all: string[]
|
all: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||||
|
|
||||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
init: () => {
|
init: () => {
|
||||||
const globalSdk = useGlobalSDK()
|
const globalSdk = useGlobalSDK()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const [store, setStore] = makePersisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
"layout.v3",
|
||||||
createStore({
|
createStore({
|
||||||
projects: [] as { worktree: string; expanded: boolean }[],
|
projects: [] as { worktree: string; expanded: boolean }[],
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
|
@ -44,13 +47,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
height: 280,
|
height: 280,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
state: "pane" as "pane" | "tab",
|
opened: true,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
width: 600,
|
||||||
},
|
},
|
||||||
sessionTabs: {} as Record<string, SessionTabs>,
|
sessionTabs: {} as Record<string, SessionTabs>,
|
||||||
}),
|
}),
|
||||||
{
|
|
||||||
name: "layout.v3",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const usedColors = new Set<AvatarColorKey>()
|
const usedColors = new Set<AvatarColorKey>()
|
||||||
|
|
@ -63,21 +66,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
|
|
||||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||||
const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
|
const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
|
||||||
if (!metadata) return []
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...project,
|
...project,
|
||||||
...metadata,
|
...(metadata ?? {}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorize(project: Project & { expanded: boolean }) {
|
function colorize(project: LocalProject) {
|
||||||
if (project.icon?.color) return project
|
if (project.icon?.color) return project
|
||||||
const color = pickAvailableColor()
|
const color = pickAvailableColor()
|
||||||
usedColors.add(color)
|
usedColors.add(color)
|
||||||
project.icon = { ...project.icon, color }
|
project.icon = { ...project.icon, color }
|
||||||
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
|
if (project.id) {
|
||||||
|
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
|
||||||
|
}
|
||||||
return project
|
return project
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,10 +97,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ready,
|
||||||
projects: {
|
projects: {
|
||||||
list,
|
list,
|
||||||
open(directory: string) {
|
open(directory: string) {
|
||||||
if (store.projects.find((x) => x.worktree === directory)) return
|
if (store.projects.find((x) => x.worktree === directory)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
globalSync.project.loadSessions(directory)
|
globalSync.project.loadSessions(directory)
|
||||||
setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
|
setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
|
||||||
},
|
},
|
||||||
|
|
@ -104,10 +111,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
|
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
|
||||||
},
|
},
|
||||||
expand(directory: string) {
|
expand(directory: string) {
|
||||||
setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: true } : x)))
|
const index = store.projects.findIndex((x) => x.worktree === directory)
|
||||||
|
if (index !== -1) setStore("projects", index, "expanded", true)
|
||||||
},
|
},
|
||||||
collapse(directory: string) {
|
collapse(directory: string) {
|
||||||
setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: false } : x)))
|
const index = store.projects.findIndex((x) => x.worktree === directory)
|
||||||
|
if (index !== -1) setStore("projects", index, "expanded", false)
|
||||||
},
|
},
|
||||||
move(directory: string, toIndex: number) {
|
move(directory: string, toIndex: number) {
|
||||||
setStore("projects", (projects) => {
|
setStore("projects", (projects) => {
|
||||||
|
|
@ -153,12 +162,25 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
state: createMemo(() => store.review?.state ?? "closed"),
|
opened: createMemo(() => store.review?.opened ?? true),
|
||||||
pane() {
|
open() {
|
||||||
setStore("review", "state", "pane")
|
setStore("review", "opened", true)
|
||||||
},
|
},
|
||||||
tab() {
|
close() {
|
||||||
setStore("review", "state", "tab")
|
setStore("review", "opened", false)
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
setStore("review", "opened", (x) => !x)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
width: createMemo(() => store.session?.width ?? 600),
|
||||||
|
resize(width: number) {
|
||||||
|
if (!store.session) {
|
||||||
|
setStore("session", { width })
|
||||||
|
} else {
|
||||||
|
setStore("session", "width", width)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tabs(sessionKey: string) {
|
tabs(sessionKey: string) {
|
||||||
|
|
@ -182,14 +204,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async open(tab: string) {
|
async open(tab: string) {
|
||||||
if (tab === "chat") {
|
|
||||||
if (!store.sessionTabs[sessionKey]) {
|
|
||||||
setStore("sessionTabs", sessionKey, { all: [], active: undefined })
|
|
||||||
} else {
|
|
||||||
setStore("sessionTabs", sessionKey, "active", undefined)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const current = store.sessionTabs[sessionKey] ?? { all: [] }
|
const current = store.sessionTabs[sessionKey] ?? { all: [] }
|
||||||
if (tab !== "review") {
|
if (tab !== "review") {
|
||||||
if (!current.all.includes(tab)) {
|
if (!current.all.includes(tab)) {
|
||||||
|
|
@ -7,8 +7,8 @@ import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { makePersisted } from "@solid-primitives/storage"
|
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
export type LocalFile = FileNode &
|
export type LocalFile = FileNode &
|
||||||
Partial<{
|
Partial<{
|
||||||
|
|
@ -110,7 +110,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const model = (() => {
|
const model = (() => {
|
||||||
const [store, setStore] = makePersisted(
|
const [store, setStore, _, modelReady] = persisted(
|
||||||
|
"model.v1",
|
||||||
createStore<{
|
createStore<{
|
||||||
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
|
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
|
||||||
recent: ModelKey[]
|
recent: ModelKey[]
|
||||||
|
|
@ -118,7 +119,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
user: [],
|
user: [],
|
||||||
recent: [],
|
recent: [],
|
||||||
}),
|
}),
|
||||||
{ name: "model.v1" },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const [ephemeral, setEphemeral] = createStore<{
|
const [ephemeral, setEphemeral] = createStore<{
|
||||||
|
|
@ -242,6 +242,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ready: modelReady,
|
||||||
current,
|
current,
|
||||||
recent,
|
recent,
|
||||||
list,
|
list,
|
||||||
|
|
@ -336,6 +337,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
const load = async (path: string) => {
|
const load = async (path: string) => {
|
||||||
const relativePath = relative(path)
|
const relativePath = relative(path)
|
||||||
await sdk.client.file.read({ path: relativePath }).then((x) => {
|
await sdk.client.file.read({ path: relativePath }).then((x) => {
|
||||||
|
if (!store.node[relativePath]) return
|
||||||
setStore(
|
setStore(
|
||||||
"node",
|
"node",
|
||||||
relativePath,
|
relativePath,
|
||||||
|
|
@ -358,7 +360,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
const init = async (path: string) => {
|
const init = async (path: string) => {
|
||||||
const relativePath = relative(path)
|
const relativePath = relative(path)
|
||||||
if (!store.node[relativePath]) await fetch(path)
|
if (!store.node[relativePath]) await fetch(path)
|
||||||
if (store.node[relativePath].loaded) return
|
if (store.node[relativePath]?.loaded) return
|
||||||
return load(relativePath)
|
return load(relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,7 +380,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
context.addActive()
|
context.addActive()
|
||||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||||
if (store.node[relativePath].loaded) return
|
if (store.node[relativePath]?.loaded) return
|
||||||
return load(relativePath)
|
return load(relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,7 +426,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
init,
|
init,
|
||||||
expand(path: string) {
|
expand(path: string) {
|
||||||
setStore("node", path, "expanded", true)
|
setStore("node", path, "expanded", true)
|
||||||
if (store.node[path].loaded) return
|
if (store.node[path]?.loaded) return
|
||||||
setStore("node", path, "loaded", true)
|
setStore("node", path, "loaded", true)
|
||||||
list(path)
|
list(path)
|
||||||
},
|
},
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { makePersisted } from "@solid-primitives/storage"
|
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
|
|
@ -8,6 +7,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||||
import { makeAudioPlayer } from "@solid-primitives/audio"
|
import { makeAudioPlayer } from "@solid-primitives/audio"
|
||||||
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
|
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||||
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
|
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
|
||||||
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
type NotificationBase = {
|
type NotificationBase = {
|
||||||
directory?: string
|
directory?: string
|
||||||
|
|
@ -31,25 +31,26 @@ export type Notification = TurnCompleteNotification | ErrorNotification
|
||||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||||
name: "Notification",
|
name: "Notification",
|
||||||
init: () => {
|
init: () => {
|
||||||
const idlePlayer = makeAudioPlayer(idleSound)
|
let idlePlayer: ReturnType<typeof makeAudioPlayer> | undefined
|
||||||
const errorPlayer = makeAudioPlayer(errorSound)
|
let errorPlayer: ReturnType<typeof makeAudioPlayer> | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
idlePlayer = makeAudioPlayer(idleSound)
|
||||||
|
errorPlayer = makeAudioPlayer(errorSound)
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Failed to load audio", err)
|
||||||
|
}
|
||||||
|
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
|
|
||||||
const [store, setStore] = makePersisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
"notification.v1",
|
||||||
createStore({
|
createStore({
|
||||||
list: [] as Notification[],
|
list: [] as Notification[],
|
||||||
}),
|
}),
|
||||||
{
|
|
||||||
name: "notification.v1",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// onMount(() => {
|
|
||||||
// const daysToKeep = 7
|
|
||||||
// // setStore("list", (n) => n.filter((n) => !n.viewed && n.time + 1000 * 60 * 60 * 24 * daysToKeep < Date.now()))
|
|
||||||
// })
|
|
||||||
|
|
||||||
globalSDK.event.listen((e) => {
|
globalSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const event = e.details
|
const event = e.details
|
||||||
|
|
@ -65,7 +66,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||||
const isChild = match.found && syncStore.session[match.index].parentID
|
const isChild = match.found && syncStore.session[match.index].parentID
|
||||||
if (isChild) break
|
if (isChild) break
|
||||||
idlePlayer.play()
|
try {
|
||||||
|
idlePlayer?.play()
|
||||||
|
} catch {}
|
||||||
setStore("list", store.list.length, {
|
setStore("list", store.list.length, {
|
||||||
...base,
|
...base,
|
||||||
type: "turn-complete",
|
type: "turn-complete",
|
||||||
|
|
@ -81,7 +84,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||||
const isChild = match.found && syncStore.session[match.index].parentID
|
const isChild = match.found && syncStore.session[match.index].parentID
|
||||||
if (isChild) break
|
if (isChild) break
|
||||||
}
|
}
|
||||||
errorPlayer.play()
|
try {
|
||||||
|
errorPlayer?.play()
|
||||||
|
} catch {}
|
||||||
setStore("list", store.list.length, {
|
setStore("list", store.list.length, {
|
||||||
...base,
|
...base,
|
||||||
type: "error",
|
type: "error",
|
||||||
|
|
@ -94,6 +99,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ready,
|
||||||
session: {
|
session: {
|
||||||
all(session: string) {
|
all(session: string) {
|
||||||
return store.list.filter((n) => n.session === session)
|
return store.list.filter((n) => n.session === session)
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||||
|
|
||||||
export type Platform = {
|
export type Platform = {
|
||||||
/** Platform discriminator */
|
/** Platform discriminator */
|
||||||
platform: "web" | "tauri"
|
platform: "web" | "tauri"
|
||||||
|
|
||||||
|
/** Open a URL in the default browser */
|
||||||
|
openLink(url: string): void
|
||||||
|
|
||||||
|
/** Restart the app */
|
||||||
|
restart(): Promise<void>
|
||||||
|
|
||||||
/** Open native directory picker dialog (Tauri only) */
|
/** Open native directory picker dialog (Tauri only) */
|
||||||
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
||||||
|
|
||||||
|
|
@ -13,8 +20,17 @@ export type Platform = {
|
||||||
/** Save file picker dialog (Tauri only) */
|
/** Save file picker dialog (Tauri only) */
|
||||||
saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>
|
saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>
|
||||||
|
|
||||||
/** Open a URL in the default browser */
|
/** Storage mechanism, defaults to localStorage */
|
||||||
openLink(url: string): void
|
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||||
|
|
||||||
|
/** Check for updates (Tauri only) */
|
||||||
|
checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
|
||||||
|
|
||||||
|
/** Install updates (Tauri only) */
|
||||||
|
update?(): Promise<void>
|
||||||
|
|
||||||
|
/** Fetch override */
|
||||||
|
fetch?: typeof fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { makePersisted } from "@solid-primitives/storage"
|
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { TextSelection } from "./local"
|
import { TextSelection } from "./local"
|
||||||
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
interface PartBase {
|
interface PartBase {
|
||||||
content: string
|
content: string
|
||||||
|
|
@ -77,7 +77,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
|
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
|
||||||
|
|
||||||
const [store, setStore] = makePersisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
name(),
|
||||||
createStore<{
|
createStore<{
|
||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
cursor?: number
|
cursor?: number
|
||||||
|
|
@ -85,12 +86,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||||
cursor: undefined,
|
cursor: undefined,
|
||||||
}),
|
}),
|
||||||
{
|
|
||||||
name: name(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ready,
|
||||||
current: createMemo(() => store.prompt),
|
current: createMemo(() => store.prompt),
|
||||||
cursor: createMemo(() => store.cursor),
|
cursor: createMemo(() => store.cursor),
|
||||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient, 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 { onCleanup } from "solid-js"
|
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
|
import { usePlatform } from "./platform"
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
init: (props: { directory: string }) => {
|
init: (props: { directory: string }) => {
|
||||||
|
const platform = usePlatform()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const abort = new AbortController()
|
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: globalSDK.url,
|
baseUrl: globalSDK.url,
|
||||||
signal: abort.signal,
|
signal: AbortSignal.timeout(1000 * 60 * 10),
|
||||||
|
fetch: platform.fetch,
|
||||||
directory: props.directory,
|
directory: props.directory,
|
||||||
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
|
|
@ -23,10 +25,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
emitter.emit(event.type, event)
|
emitter.emit(event.type, event)
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
abort.abort()
|
|
||||||
})
|
|
||||||
|
|
||||||
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
|
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { produce } from "solid-js/store"
|
import { produce } from "solid-js/store"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
|
|
@ -30,12 +32,40 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
if (match.found) return store.session[match.index]
|
if (match.found) return store.session[match.index]
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
|
addOptimisticMessage(input: {
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
parts: Part[]
|
||||||
|
agent: string
|
||||||
|
model: { providerID: string; modelID: string }
|
||||||
|
}) {
|
||||||
|
const message: Message = {
|
||||||
|
id: input.messageID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
const messages = draft.message[input.sessionID]
|
||||||
|
if (!messages) {
|
||||||
|
draft.message[input.sessionID] = [message]
|
||||||
|
} else {
|
||||||
|
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||||
|
messages.splice(result.index, 0, message)
|
||||||
|
}
|
||||||
|
draft.part[input.messageID] = input.parts.slice()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
async sync(sessionID: string, _isRetry = false) {
|
async sync(sessionID: string, _isRetry = false) {
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
retry(() => sdk.client.session.get({ sessionID })),
|
||||||
sdk.client.session.messages({ sessionID, limit: 100 }),
|
retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
|
||||||
sdk.client.session.todo({ sessionID }),
|
retry(() => sdk.client.session.todo({ sessionID })),
|
||||||
sdk.client.session.diff({ sessionID }),
|
retry(() => sdk.client.session.diff({ sessionID })),
|
||||||
])
|
])
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { makePersisted } from "@solid-primitives/storage"
|
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
export type LocalPTY = {
|
export type LocalPTY = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -21,19 +21,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
|
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
|
||||||
|
|
||||||
const [store, setStore] = makePersisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
name(),
|
||||||
createStore<{
|
createStore<{
|
||||||
active?: string
|
active?: string
|
||||||
all: LocalPTY[]
|
all: LocalPTY[]
|
||||||
}>({
|
}>({
|
||||||
all: [],
|
all: [],
|
||||||
}),
|
}),
|
||||||
{
|
|
||||||
name: name(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ready,
|
||||||
all: createMemo(() => Object.values(store.all)),
|
all: createMemo(() => Object.values(store.all)),
|
||||||
active: createMemo(() => store.active),
|
active: createMemo(() => store.active),
|
||||||
new() {
|
new() {
|
||||||
|
|
@ -15,6 +15,9 @@ const platform: Platform = {
|
||||||
openLink(url: string) {
|
openLink(url: string) {
|
||||||
window.open(url, "_blank")
|
window.open(url, "_blank")
|
||||||
},
|
},
|
||||||
|
restart: async () => {
|
||||||
|
window.location.reload()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
155
packages/app/src/pages/error.tsx
Normal file
155
packages/app/src/pages/error.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { Logo } from "@opencode-ai/ui/logo"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Component } from "solid-js"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
|
||||||
|
export type InitError = {
|
||||||
|
name: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInitError(error: unknown): error is InitError {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"name" in error &&
|
||||||
|
"data" in error &&
|
||||||
|
typeof (error as InitError).data === "object"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInitError(error: InitError): string {
|
||||||
|
const data = error.data
|
||||||
|
switch (error.name) {
|
||||||
|
case "MCPFailed":
|
||||||
|
return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||||
|
case "ProviderModelNotFoundError": {
|
||||||
|
const { providerID, modelID, suggestions } = data as {
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
suggestions?: string[]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`Model not found: ${providerID}/${modelID}`,
|
||||||
|
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||||
|
`Check your config (opencode.json) provider/model names`,
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
case "ProviderInitError":
|
||||||
|
return `Failed to initialize provider "${data.providerID}". Check credentials and configuration.`
|
||||||
|
case "ConfigJsonError":
|
||||||
|
return `Config file at ${data.path} is not valid JSON(C)` + (data.message ? `: ${data.message}` : "")
|
||||||
|
case "ConfigDirectoryTypoError":
|
||||||
|
return `Directory "${data.dir}" in ${data.path} is not valid. Rename the directory to "${data.suggestion}" or remove it. This is a common typo.`
|
||||||
|
case "ConfigFrontmatterError":
|
||||||
|
return `Failed to parse frontmatter in ${data.path}:\n${data.message}`
|
||||||
|
case "ConfigInvalidError": {
|
||||||
|
const issues = Array.isArray(data.issues)
|
||||||
|
? data.issues.map(
|
||||||
|
(issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
return [`Config file at ${data.path} is invalid` + (data.message ? `: ${data.message}` : ""), ...issues].join(
|
||||||
|
"\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case "UnknownError":
|
||||||
|
return String(data.message)
|
||||||
|
default:
|
||||||
|
return data.message ? String(data.message) : JSON.stringify(data, null, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): string {
|
||||||
|
if (!error) return "Unknown error"
|
||||||
|
|
||||||
|
if (isInitError(error)) {
|
||||||
|
const message = formatInitError(error)
|
||||||
|
if (depth > 0 && parentMessage === message) return ""
|
||||||
|
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||||
|
return indent + message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const isDuplicate = depth > 0 && parentMessage === error.message
|
||||||
|
const parts: string[] = []
|
||||||
|
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||||
|
|
||||||
|
if (!isDuplicate) {
|
||||||
|
// Stack already includes error name and message, so prefer it
|
||||||
|
parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
|
||||||
|
} else if (error.stack) {
|
||||||
|
// Duplicate message - only show the stack trace lines (skip message)
|
||||||
|
const trace = error.stack.split("\n").slice(1).join("\n").trim()
|
||||||
|
if (trace) {
|
||||||
|
parts.push(trace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.cause) {
|
||||||
|
const causeResult = formatErrorChain(error.cause, depth + 1, error.message)
|
||||||
|
if (causeResult) {
|
||||||
|
parts.push(causeResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "string") {
|
||||||
|
if (depth > 0 && parentMessage === error) return ""
|
||||||
|
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||||
|
return indent + error
|
||||||
|
}
|
||||||
|
|
||||||
|
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||||
|
return indent + JSON.stringify(error, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatError(error: unknown): string {
|
||||||
|
return formatErrorChain(error, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorPageProps {
|
||||||
|
error: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||||
|
const platform = usePlatform()
|
||||||
|
return (
|
||||||
|
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
|
||||||
|
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
||||||
|
<Logo class="w-58.5 opacity-12 shrink-0" />
|
||||||
|
<div class="flex flex-col items-center gap-2 text-center">
|
||||||
|
<h1 class="text-lg font-medium text-text-strong">Something went wrong</h1>
|
||||||
|
<p class="text-sm text-text-weak">An error occurred while loading the application.</p>
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
value={formatError(props.error)}
|
||||||
|
readOnly
|
||||||
|
copyable
|
||||||
|
multiline
|
||||||
|
class="max-h-96 w-full font-mono text-xs no-scrollbar whitespace-pre"
|
||||||
|
label="Error Details"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<Button size="large" onClick={platform.restart}>
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
<div class="flex items-center justify-center gap-1">
|
||||||
|
Please report this error to the OpenCode team
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center text-text-interactive-base gap-1"
|
||||||
|
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
|
>
|
||||||
|
<div>on Discord</div>
|
||||||
|
<Icon name="discord" class="text-text-interactive-base" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js"
|
import {
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
Match,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
ParentProps,
|
||||||
|
Show,
|
||||||
|
Switch,
|
||||||
|
type JSX,
|
||||||
|
} from "solid-js"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useLayout, getAvatarColors } from "@/context/layout"
|
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
|
|
@ -15,7 +27,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Session, Project } from "@opencode-ai/sdk/v2/client"
|
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import {
|
import {
|
||||||
|
|
@ -25,11 +37,10 @@ import {
|
||||||
SortableProvider,
|
SortableProvider,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
createSortable,
|
createSortable,
|
||||||
useDragDropContext,
|
|
||||||
} from "@thisbeyond/solid-dnd"
|
} from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { Toast } from "@opencode-ai/ui/toast"
|
import { showToast, Toast } from "@opencode-ai/ui/toast"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
|
|
@ -37,23 +48,36 @@ import { Header } from "@/components/header"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
|
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
lastSession: {} as { [directory: string]: string },
|
lastSession: {} as { [directory: string]: string },
|
||||||
activeDraggable: undefined as string | undefined,
|
activeDraggable: undefined as string | undefined,
|
||||||
|
mobileSidebarOpen: false,
|
||||||
|
mobileProjectsExpanded: {} as Record<string, boolean>,
|
||||||
})
|
})
|
||||||
|
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
const mobileSidebar = {
|
||||||
|
open: () => store.mobileSidebarOpen,
|
||||||
function scrollToSession(sessionId: string) {
|
show: () => setStore("mobileSidebarOpen", true),
|
||||||
if (!scrollContainerRef) return
|
hide: () => setStore("mobileSidebarOpen", false),
|
||||||
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
|
toggle: () => setStore("mobileSidebarOpen", (x) => !x),
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ block: "center", behavior: "smooth" })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mobileProjects = {
|
||||||
|
expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
|
||||||
|
expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
|
||||||
|
collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false),
|
||||||
|
}
|
||||||
|
|
||||||
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
|
const xlQuery = window.matchMedia("(min-width: 1280px)")
|
||||||
|
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
|
||||||
|
const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
|
||||||
|
xlQuery.addEventListener("change", handleViewportChange)
|
||||||
|
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
|
||||||
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
|
|
@ -65,6 +89,33 @@ export default function Layout(props: ParentProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (platform.checkUpdate && platform.update && platform.restart) {
|
||||||
|
const { updateAvailable, version } = await platform.checkUpdate()
|
||||||
|
if (updateAvailable) {
|
||||||
|
showToast({
|
||||||
|
persistent: true,
|
||||||
|
icon: "download",
|
||||||
|
title: "Update available",
|
||||||
|
description: `A new version of OpenCode (${version}) is now available to install.`,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: "Install and restart",
|
||||||
|
onClick: async () => {
|
||||||
|
await platform.update!()
|
||||||
|
await platform.restart!()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Not yet",
|
||||||
|
onClick: "dismiss",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function flattenSessions(sessions: Session[]): Session[] {
|
function flattenSessions(sessions: Session[]): Session[] {
|
||||||
const childrenMap = new Map<string, Session[]>()
|
const childrenMap = new Map<string, Session[]>()
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
|
|
@ -87,10 +138,26 @@ export default function Layout(props: ParentProps) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToSession(sessionId: string) {
|
||||||
|
if (!scrollContainerRef) return
|
||||||
|
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectSessions(directory: string) {
|
||||||
|
if (!directory) return []
|
||||||
|
const sessions = globalSync
|
||||||
|
.child(directory)[0]
|
||||||
|
.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||||
|
return flattenSessions(sessions ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
const currentSessions = createMemo(() => {
|
const currentSessions = createMemo(() => {
|
||||||
if (!params.dir) return []
|
if (!params.dir) return []
|
||||||
const directory = base64Decode(params.dir)
|
const directory = base64Decode(params.dir)
|
||||||
return flattenSessions(globalSync.child(directory)[0].session ?? [])
|
return projectSessions(directory)
|
||||||
})
|
})
|
||||||
|
|
||||||
function navigateSessionByOffset(offset: number) {
|
function navigateSessionByOffset(offset: number) {
|
||||||
|
|
@ -127,7 +194,7 @@ export default function Layout(props: ParentProps) {
|
||||||
const nextProject = projects[nextProjectIndex]
|
const nextProject = projects[nextProjectIndex]
|
||||||
if (!nextProject) return
|
if (!nextProject) return
|
||||||
|
|
||||||
const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? [])
|
const nextProjectSessions = projectSessions(nextProject.worktree)
|
||||||
if (nextProjectSessions.length === 0) {
|
if (nextProjectSessions.length === 0) {
|
||||||
navigateToProject(nextProject.worktree)
|
navigateToProject(nextProject.worktree)
|
||||||
return
|
return
|
||||||
|
|
@ -217,18 +284,20 @@ export default function Layout(props: ParentProps) {
|
||||||
])
|
])
|
||||||
|
|
||||||
function connectProvider() {
|
function connectProvider() {
|
||||||
dialog.replace(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToProject(directory: string | undefined) {
|
function navigateToProject(directory: string | undefined) {
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const lastSession = store.lastSession[directory]
|
const lastSession = store.lastSession[directory]
|
||||||
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
|
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
|
||||||
|
mobileSidebar.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToSession(session: Session | undefined) {
|
function navigateToSession(session: Session | undefined) {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
navigate(`/${params.dir}/session/${session?.id}`)
|
navigate(`/${params.dir}/session/${session?.id}`)
|
||||||
|
mobileSidebar.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openProject(directory: string, navigate = true) {
|
function openProject(directory: string, navigate = true) {
|
||||||
|
|
@ -267,8 +336,12 @@ export default function Layout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
if (isLargeViewport()) {
|
||||||
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
||||||
|
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
||||||
|
} else {
|
||||||
|
document.documentElement.style.setProperty("--dialog-left-margin", "0px")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function getDraggableId(event: unknown): string | undefined {
|
function getDraggableId(event: unknown): string | undefined {
|
||||||
|
|
@ -301,30 +374,8 @@ export default function Layout(props: ParentProps) {
|
||||||
setStore("activeDraggable", undefined)
|
setStore("activeDraggable", undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConstrainDragXAxis = (): JSX.Element => {
|
|
||||||
const context = useDragDropContext()
|
|
||||||
if (!context) return <></>
|
|
||||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
|
||||||
const transformer: Transformer = {
|
|
||||||
id: "constrain-x-axis",
|
|
||||||
order: 100,
|
|
||||||
callback: (transform) => ({ ...transform, x: 0 }),
|
|
||||||
}
|
|
||||||
onDragStart((event) => {
|
|
||||||
const id = getDraggableId(event)
|
|
||||||
if (!id) return
|
|
||||||
addTransformer("draggables", id, transformer)
|
|
||||||
})
|
|
||||||
onDragEnd((event) => {
|
|
||||||
const id = getDraggableId(event)
|
|
||||||
if (!id) return
|
|
||||||
removeTransformer("draggables", id, transformer.id)
|
|
||||||
})
|
|
||||||
return <></>
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectAvatar = (props: {
|
const ProjectAvatar = (props: {
|
||||||
project: Project
|
project: LocalProject
|
||||||
class?: string
|
class?: string
|
||||||
expandable?: boolean
|
expandable?: boolean
|
||||||
notify?: boolean
|
notify?: boolean
|
||||||
|
|
@ -337,7 +388,7 @@ export default function Layout(props: ParentProps) {
|
||||||
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-5 shrink-0 rounded-sm overflow-hidden">
|
<div class="relative size-5 shrink-0 rounded-sm">
|
||||||
<Avatar
|
<Avatar
|
||||||
fallback={name()}
|
fallback={name()}
|
||||||
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
|
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
|
||||||
|
|
@ -367,7 +418,7 @@ export default function Layout(props: ParentProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProjectVisual = (props: { project: Project & { expanded: boolean }; class?: string }): JSX.Element => {
|
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
|
||||||
const name = createMemo(() => getFilename(props.project.worktree))
|
const name = createMemo(() => getFilename(props.project.worktree))
|
||||||
const current = createMemo(() => base64Decode(params.dir ?? ""))
|
const current = createMemo(() => base64Decode(params.dir ?? ""))
|
||||||
return (
|
return (
|
||||||
|
|
@ -403,9 +454,10 @@ export default function Layout(props: ParentProps) {
|
||||||
const SessionItem = (props: {
|
const SessionItem = (props: {
|
||||||
session: Session
|
session: Session
|
||||||
slug: string
|
slug: string
|
||||||
project: Project
|
project: LocalProject
|
||||||
depth?: number
|
depth?: number
|
||||||
childrenMap: Map<string, Session[]>
|
childrenMap: Map<string, Session[]>
|
||||||
|
mobile?: boolean
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const depth = props.depth ?? 0
|
const depth = props.depth ?? 0
|
||||||
|
|
@ -413,11 +465,11 @@ export default function Layout(props: ParentProps) {
|
||||||
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
||||||
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
||||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||||
const isWorking = createMemo(
|
const isWorking = createMemo(() => {
|
||||||
() =>
|
if (props.session.id === params.id) return false
|
||||||
props.session.id !== params.id &&
|
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
|
||||||
globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy",
|
return status?.type === "busy" || status?.type === "retry"
|
||||||
)
|
})
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|
@ -426,7 +478,7 @@ export default function Layout(props: ParentProps) {
|
||||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
|
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
|
||||||
style={{ "padding-left": `${16 + depth * 12}px` }}
|
style={{ "padding-left": `${16 + depth * 12}px` }}
|
||||||
>
|
>
|
||||||
<Tooltip placement="right" value={props.session.title} gutter={10}>
|
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
||||||
<A
|
<A
|
||||||
href={`${props.slug}/session/${props.session.id}`}
|
href={`${props.slug}/session/${props.session.id}`}
|
||||||
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
|
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
|
||||||
|
|
@ -473,7 +525,7 @@ export default function Layout(props: ParentProps) {
|
||||||
</A>
|
</A>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
|
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
|
||||||
<Tooltip placement="right" value="Archive session">
|
<Tooltip placement={props.mobile ? "bottom" : "right"} value="Archive session">
|
||||||
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
|
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -486,6 +538,7 @@ export default function Layout(props: ParentProps) {
|
||||||
project={props.project}
|
project={props.project}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
childrenMap={props.childrenMap}
|
childrenMap={props.childrenMap}
|
||||||
|
mobile={props.mobile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
@ -493,12 +546,15 @@ export default function Layout(props: ParentProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => {
|
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
||||||
const sortable = createSortable(props.project.worktree)
|
const sortable = createSortable(props.project.worktree)
|
||||||
|
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
|
||||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||||
const name = createMemo(() => getFilename(props.project.worktree))
|
const name = createMemo(() => getFilename(props.project.worktree))
|
||||||
const [store] = globalSync.child(props.project.worktree)
|
const [store, setProjectStore] = globalSync.child(props.project.worktree)
|
||||||
const sessions = createMemo(() => store.session ?? [])
|
const sessions = createMemo(() =>
|
||||||
|
store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)),
|
||||||
|
)
|
||||||
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
|
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
|
||||||
const childSessionsByParent = createMemo(() => {
|
const childSessionsByParent = createMemo(() => {
|
||||||
const map = new Map<string, Session[]>()
|
const map = new Map<string, Session[]>()
|
||||||
|
|
@ -511,13 +567,29 @@ export default function Layout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
const [expanded, setExpanded] = createSignal(true)
|
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
|
||||||
|
const loadMoreSessions = async () => {
|
||||||
|
setProjectStore("limit", (limit) => limit + 5)
|
||||||
|
await globalSync.project.loadSessions(props.project.worktree)
|
||||||
|
}
|
||||||
|
const isExpanded = createMemo(() =>
|
||||||
|
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
|
||||||
|
)
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (props.mobile) {
|
||||||
|
if (open) mobileProjects.expand(props.project.worktree)
|
||||||
|
else mobileProjects.collapse(props.project.worktree)
|
||||||
|
} else {
|
||||||
|
if (open) layout.projects.expand(props.project.worktree)
|
||||||
|
else layout.projects.collapse(props.project.worktree)
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={layout.sidebar.opened()}>
|
<Match when={showExpanded()}>
|
||||||
<Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0" onOpenChange={setExpanded}>
|
<Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
|
||||||
<Button
|
<Button
|
||||||
as={"div"}
|
as={"div"}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -528,7 +600,7 @@ export default function Layout(props: ParentProps) {
|
||||||
project={props.project}
|
project={props.project}
|
||||||
class="group-hover/session:hidden"
|
class="group-hover/session:hidden"
|
||||||
expandable
|
expandable
|
||||||
notify={!expanded()}
|
notify={!isExpanded()}
|
||||||
/>
|
/>
|
||||||
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
|
|
@ -557,6 +629,7 @@ export default function Layout(props: ParentProps) {
|
||||||
slug={slug()}
|
slug={slug()}
|
||||||
project={props.project}
|
project={props.project}
|
||||||
childrenMap={childSessionsByParent()}
|
childrenMap={childSessionsByParent()}
|
||||||
|
mobile={props.mobile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
@ -567,7 +640,7 @@ export default function Layout(props: ParentProps) {
|
||||||
>
|
>
|
||||||
<div class="flex items-center self-stretch w-full">
|
<div class="flex items-center self-stretch w-full">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<Tooltip placement="right" value="New session">
|
<Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
|
||||||
<A
|
<A
|
||||||
href={`${slug()}/session`}
|
href={`${slug()}/session`}
|
||||||
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
|
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
|
||||||
|
|
@ -583,6 +656,18 @@ export default function Layout(props: ParentProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={hasMoreSessions()}>
|
||||||
|
<div class="relative w-full py-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5"
|
||||||
|
size="large"
|
||||||
|
onClick={loadMoreSessions}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</nav>
|
</nav>
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
@ -610,31 +695,23 @@ export default function Layout(props: ParentProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
|
||||||
<div class="relative flex-1 min-h-0 flex flex-col">
|
const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
|
||||||
<Header navigateToProject={navigateToProject} navigateToSession={navigateToSession} />
|
return (
|
||||||
<div class="flex-1 min-h-0 flex">
|
<>
|
||||||
<div
|
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
|
||||||
classList={{
|
<Show when={!sidebarProps.mobile}>
|
||||||
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
|
<Tooltip
|
||||||
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
|
class="shrink-0"
|
||||||
"border-r border-border-weak-base": true,
|
placement="right"
|
||||||
}}
|
value={
|
||||||
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
<div class="flex items-center gap-2">
|
||||||
>
|
<span>Toggle sidebar</span>
|
||||||
<Show when={layout.sidebar.opened()}>
|
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
|
||||||
<ResizeHandle
|
</div>
|
||||||
direction="horizontal"
|
}
|
||||||
size={layout.sidebar.width()}
|
inactive={expanded()}
|
||||||
min={150}
|
>
|
||||||
max={window.innerWidth * 0.3}
|
|
||||||
collapseThreshold={80}
|
|
||||||
onResize={layout.sidebar.resize}
|
|
||||||
onCollapse={layout.sidebar.close}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
|
|
||||||
<Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="large"
|
size="large"
|
||||||
|
|
@ -665,102 +742,161 @@ export default function Layout(props: ParentProps) {
|
||||||
</Show>
|
</Show>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DragDropProvider
|
</Show>
|
||||||
onDragStart={handleDragStart}
|
<DragDropProvider
|
||||||
onDragEnd={handleDragEnd}
|
onDragStart={handleDragStart}
|
||||||
onDragOver={handleDragOver}
|
onDragEnd={handleDragEnd}
|
||||||
collisionDetector={closestCenter}
|
onDragOver={handleDragOver}
|
||||||
|
collisionDetector={closestCenter}
|
||||||
|
>
|
||||||
|
<DragDropSensors />
|
||||||
|
<ConstrainDragXAxis />
|
||||||
|
<div
|
||||||
|
ref={sidebarProps.mobile ? undefined : scrollContainerRef}
|
||||||
|
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
|
||||||
>
|
>
|
||||||
<DragDropSensors />
|
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
|
||||||
<ConstrainDragXAxis />
|
<For each={layout.projects.list()}>
|
||||||
<div
|
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
|
||||||
ref={scrollContainerRef}
|
</For>
|
||||||
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
|
</SortableProvider>
|
||||||
>
|
</div>
|
||||||
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
|
<DragOverlay>
|
||||||
<For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For>
|
<ProjectDragOverlay />
|
||||||
</SortableProvider>
|
</DragOverlay>
|
||||||
</div>
|
</DragDropProvider>
|
||||||
<DragOverlay>
|
</div>
|
||||||
<ProjectDragOverlay />
|
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
|
||||||
</DragOverlay>
|
<Switch>
|
||||||
</DragDropProvider>
|
<Match when={!providers.paid().length && expanded()}>
|
||||||
</div>
|
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
|
||||||
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
|
<div class="p-3 flex flex-col gap-2">
|
||||||
<Switch>
|
<div class="text-12-medium text-text-strong">Getting started</div>
|
||||||
<Match when={!providers.paid().length && layout.sidebar.opened()}>
|
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
|
||||||
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
|
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
|
||||||
<div class="p-3 flex flex-col gap-2">
|
|
||||||
<div class="text-12-medium text-text-strong">Getting started</div>
|
|
||||||
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
|
|
||||||
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
|
|
||||||
</div>
|
|
||||||
<Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
|
|
||||||
<Button
|
|
||||||
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
|
|
||||||
size="large"
|
|
||||||
icon="plus"
|
|
||||||
onClick={connectProvider}
|
|
||||||
>
|
|
||||||
<Show when={layout.sidebar.opened()}>Connect provider</Show>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
|
||||||
<Match when={true}>
|
|
||||||
<Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
|
|
||||||
<Button
|
<Button
|
||||||
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
|
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
size="large"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
onClick={connectProvider}
|
onClick={connectProvider}
|
||||||
>
|
>
|
||||||
<Show when={layout.sidebar.opened()}>Connect provider</Show>
|
Connect provider
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Match>
|
</div>
|
||||||
</Switch>
|
</Match>
|
||||||
<Show when={platform.openDirectoryPickerDialog}>
|
<Match when={true}>
|
||||||
<Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
|
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
|
||||||
<Button
|
<Button
|
||||||
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
|
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="large"
|
size="large"
|
||||||
icon="folder-add-left"
|
icon="plus"
|
||||||
onClick={chooseProject}
|
onClick={connectProvider}
|
||||||
>
|
>
|
||||||
<Show when={layout.sidebar.opened()}>Open project</Show>
|
<Show when={expanded()}>Connect provider</Show>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Show>
|
</Match>
|
||||||
{/* <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> */}
|
</Switch>
|
||||||
{/* <Button */}
|
<Show when={platform.openDirectoryPickerDialog}>
|
||||||
{/* disabled */}
|
<Tooltip
|
||||||
{/* class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2" */}
|
placement="right"
|
||||||
{/* variant="ghost" */}
|
value={
|
||||||
{/* size="large" */}
|
<div class="flex items-center gap-2">
|
||||||
{/* icon="settings-gear" */}
|
<span>Open project</span>
|
||||||
{/* > */}
|
<Show when={!sidebarProps.mobile}>
|
||||||
{/* <Show when={layout.sidebar.opened()}>Settings</Show> */}
|
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
|
||||||
{/* </Button> */}
|
</Show>
|
||||||
{/* </Tooltip> */}
|
</div>
|
||||||
<Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
|
}
|
||||||
|
inactive={expanded()}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
as={"a"}
|
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||||
href="https://opencode.ai/desktop-feedback"
|
|
||||||
target="_blank"
|
|
||||||
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="large"
|
size="large"
|
||||||
icon="bubble-5"
|
icon="folder-add-left"
|
||||||
|
onClick={chooseProject}
|
||||||
>
|
>
|
||||||
<Show when={layout.sidebar.opened()}>Share feedback</Show>
|
<Show when={expanded()}>Open project</Show>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
<Tooltip placement="right" value="Share feedback" inactive={expanded()}>
|
||||||
|
<Button
|
||||||
|
as={"a"}
|
||||||
|
href="https://opencode.ai/desktop-feedback"
|
||||||
|
target="_blank"
|
||||||
|
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||||
|
variant="ghost"
|
||||||
|
size="large"
|
||||||
|
icon="bubble-5"
|
||||||
|
>
|
||||||
|
<Show when={expanded()}>Share feedback</Show>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="relative flex-1 min-h-0 flex flex-col">
|
||||||
|
<Header
|
||||||
|
navigateToProject={navigateToProject}
|
||||||
|
navigateToSession={navigateToSession}
|
||||||
|
onMobileMenuToggle={mobileSidebar.toggle}
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-h-0 flex">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"hidden xl:flex": true,
|
||||||
|
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
|
||||||
|
"flex-col gap-5.5 items-start self-stretch justify-between": true,
|
||||||
|
"border-r border-border-weak-base contain-strict": true,
|
||||||
|
}}
|
||||||
|
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
||||||
|
>
|
||||||
|
<Show when={layout.sidebar.opened()}>
|
||||||
|
<ResizeHandle
|
||||||
|
direction="horizontal"
|
||||||
|
size={layout.sidebar.width()}
|
||||||
|
min={150}
|
||||||
|
max={window.innerWidth * 0.3}
|
||||||
|
collapseThreshold={80}
|
||||||
|
onResize={layout.sidebar.resize}
|
||||||
|
onCollapse={layout.sidebar.close}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<SidebarContent />
|
||||||
|
</div>
|
||||||
|
<div class="xl:hidden">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
|
||||||
|
"opacity-100 pointer-events-auto": mobileSidebar.open(),
|
||||||
|
"opacity-0 pointer-events-none": !mobileSidebar.open(),
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) mobileSidebar.hide()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
|
||||||
|
"translate-x-0": mobileSidebar.open(),
|
||||||
|
"-translate-x-full": !mobileSidebar.open(),
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<SidebarContent mobile />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
|
|
||||||
|
<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
|
||||||
</div>
|
</div>
|
||||||
<Toast.Region />
|
<Toast.Region />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,4 +1,19 @@
|
||||||
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect, on } from "solid-js"
|
import {
|
||||||
|
For,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
Show,
|
||||||
|
Match,
|
||||||
|
Switch,
|
||||||
|
createResource,
|
||||||
|
createMemo,
|
||||||
|
createEffect,
|
||||||
|
on,
|
||||||
|
createRenderEffect,
|
||||||
|
batch,
|
||||||
|
} from "solid-js"
|
||||||
|
|
||||||
|
import { Dynamic } from "solid-js/web"
|
||||||
import { useLocal, type LocalFile } from "@/context/local"
|
import { useLocal, type LocalFile } from "@/context/local"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
|
|
@ -8,11 +23,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { Code } from "@opencode-ai/ui/code"
|
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||||
|
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||||
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
|
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
|
||||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,9 +37,8 @@ import {
|
||||||
SortableProvider,
|
SortableProvider,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
createSortable,
|
createSortable,
|
||||||
useDragDropContext,
|
|
||||||
} from "@thisbeyond/solid-dnd"
|
} from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
|
|
@ -37,10 +51,11 @@ import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
|
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
|
@ -48,6 +63,7 @@ export default function Page() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const codeComponent = useCodeComponent()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -56,7 +72,6 @@ export default function Page() {
|
||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
|
|
@ -65,7 +80,6 @@ export default function Page() {
|
||||||
.filter((m) => m.role === "user")
|
.filter((m) => m.role === "user")
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
)
|
)
|
||||||
// Visible user messages excludes reverted messages (those >= revertMessageID)
|
|
||||||
const visibleUserMessages = createMemo(() => {
|
const visibleUserMessages = createMemo(() => {
|
||||||
const revert = revertMessageID()
|
const revert = revertMessageID()
|
||||||
if (!revert) return userMessages()
|
if (!revert) return userMessages()
|
||||||
|
|
@ -73,15 +87,24 @@ export default function Page() {
|
||||||
})
|
})
|
||||||
const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
|
const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
|
||||||
|
|
||||||
const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({})
|
const [store, setStore] = createStore({
|
||||||
|
clickTimer: undefined as number | undefined,
|
||||||
|
activeDraggable: undefined as string | undefined,
|
||||||
|
activeTerminalDraggable: undefined as string | undefined,
|
||||||
|
userInteracted: false,
|
||||||
|
stepsExpanded: true,
|
||||||
|
mobileStepsExpanded: {} as Record<string, boolean>,
|
||||||
|
messageId: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
const activeMessage = createMemo(() => {
|
const activeMessage = createMemo(() => {
|
||||||
if (!messageStore.messageId) return lastUserMessage()
|
if (!store.messageId) return lastUserMessage()
|
||||||
// If the stored message is no longer visible (e.g., was reverted), fall back to last visible
|
// If the stored message is no longer visible (e.g., was reverted), fall back to last visible
|
||||||
const found = visibleUserMessages()?.find((m) => m.id === messageStore.messageId)
|
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
|
||||||
return found ?? lastUserMessage()
|
return found ?? lastUserMessage()
|
||||||
})
|
})
|
||||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||||
setMessageStore("messageId", message?.id)
|
setStore("messageId", message?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateMessageByOffset(offset: number) {
|
function navigateMessageByOffset(offset: number) {
|
||||||
|
|
@ -103,33 +126,8 @@ export default function Page() {
|
||||||
setActiveMessage(msgs[targetIndex])
|
setActiveMessage(msgs[targetIndex])
|
||||||
}
|
}
|
||||||
|
|
||||||
const last = createMemo(
|
|
||||||
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
|
|
||||||
)
|
|
||||||
const model = createMemo(() =>
|
|
||||||
last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
|
|
||||||
)
|
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
|
|
||||||
const tokens = createMemo(() => {
|
|
||||||
if (!last()) return
|
|
||||||
const t = last().tokens
|
|
||||||
return t.input + t.output + t.reasoning + t.cache.read + t.cache.write
|
|
||||||
})
|
|
||||||
|
|
||||||
const context = createMemo(() => {
|
|
||||||
const total = tokens()
|
|
||||||
const limit = model()?.limit.context
|
|
||||||
if (!total || !limit) return 0
|
|
||||||
return Math.round((total / limit) * 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
clickTimer: undefined as number | undefined,
|
|
||||||
activeDraggable: undefined as string | undefined,
|
|
||||||
activeTerminalDraggable: undefined as string | undefined,
|
|
||||||
stepsExpanded: false,
|
|
||||||
})
|
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -150,14 +148,35 @@ export default function Page() {
|
||||||
() => visibleUserMessages().at(-1)?.id,
|
() => visibleUserMessages().at(-1)?.id,
|
||||||
(lastId, prevLastId) => {
|
(lastId, prevLastId) => {
|
||||||
if (lastId && prevLastId && lastId > prevLastId) {
|
if (lastId && prevLastId && lastId > prevLastId) {
|
||||||
setMessageStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
params.id
|
||||||
|
const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" }
|
||||||
|
batch(() => {
|
||||||
|
setStore("userInteracted", false)
|
||||||
|
setStore("stepsExpanded", status.type !== "idle")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
|
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
|
||||||
|
const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
|
||||||
|
|
||||||
|
createRenderEffect((prev) => {
|
||||||
|
const isWorking = working()
|
||||||
|
if (!prev && isWorking) {
|
||||||
|
setStore("stepsExpanded", true)
|
||||||
|
}
|
||||||
|
if (prev && !isWorking && !store.userInteracted) {
|
||||||
|
setStore("stepsExpanded", false)
|
||||||
|
}
|
||||||
|
return isWorking
|
||||||
|
}, working())
|
||||||
|
|
||||||
command.register(() => [
|
command.register(() => [
|
||||||
{
|
{
|
||||||
|
|
@ -176,7 +195,7 @@ export default function Page() {
|
||||||
category: "File",
|
category: "File",
|
||||||
keybind: "mod+p",
|
keybind: "mod+p",
|
||||||
slash: "open",
|
slash: "open",
|
||||||
onSelect: () => dialog.replace(() => <DialogSelectFile />),
|
onSelect: () => dialog.show(() => <DialogSelectFile />),
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// id: "theme.toggle",
|
// id: "theme.toggle",
|
||||||
|
|
@ -202,6 +221,15 @@ export default function Page() {
|
||||||
slash: "terminal",
|
slash: "terminal",
|
||||||
onSelect: () => layout.terminal.toggle(),
|
onSelect: () => layout.terminal.toggle(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "review.toggle",
|
||||||
|
title: "Toggle review",
|
||||||
|
description: "Show or hide the review panel",
|
||||||
|
category: "View",
|
||||||
|
keybind: "mod+b",
|
||||||
|
slash: "review",
|
||||||
|
onSelect: () => layout.review.toggle(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "terminal.new",
|
id: "terminal.new",
|
||||||
title: "New terminal",
|
title: "New terminal",
|
||||||
|
|
@ -245,7 +273,7 @@ export default function Page() {
|
||||||
category: "Model",
|
category: "Model",
|
||||||
keybind: "mod+'",
|
keybind: "mod+'",
|
||||||
slash: "model",
|
slash: "model",
|
||||||
onSelect: () => dialog.replace(() => <DialogSelectModel />),
|
onSelect: () => dialog.show(() => <DialogSelectModel />),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "agent.cycle",
|
id: "agent.cycle",
|
||||||
|
|
@ -256,12 +284,19 @@ export default function Page() {
|
||||||
slash: "agent",
|
slash: "agent",
|
||||||
onSelect: () => local.agent.move(1),
|
onSelect: () => local.agent.move(1),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "agent.cycle.reverse",
|
||||||
|
title: "Cycle agent backwards",
|
||||||
|
description: "Switch to the previous agent",
|
||||||
|
category: "Agent",
|
||||||
|
keybind: "shift+mod+.",
|
||||||
|
onSelect: () => local.agent.move(-1),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "session.undo",
|
id: "session.undo",
|
||||||
title: "Undo",
|
title: "Undo",
|
||||||
description: "Undo the last message",
|
description: "Undo the last message",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
keybind: "mod+z",
|
|
||||||
slash: "undo",
|
slash: "undo",
|
||||||
disabled: !params.id || visibleUserMessages().length === 0,
|
disabled: !params.id || visibleUserMessages().length === 0,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
|
|
@ -291,7 +326,6 @@ export default function Page() {
|
||||||
title: "Redo",
|
title: "Redo",
|
||||||
description: "Redo the last undone message",
|
description: "Redo the last undone message",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
keybind: "mod+shift+z",
|
|
||||||
slash: "redo",
|
slash: "redo",
|
||||||
disabled: !params.id || !info()?.revert?.messageID,
|
disabled: !params.id || !info()?.revert?.messageID,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
|
|
@ -319,24 +353,15 @@ export default function Page() {
|
||||||
])
|
])
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
|
const activeElement = document.activeElement as HTMLElement | undefined
|
||||||
if (dialog.stack.length > 0) return
|
if (activeElement) {
|
||||||
|
const isProtected = activeElement.closest("[data-prevent-autofocus]")
|
||||||
if (event.key === "PageUp" || event.key === "PageDown") {
|
const isInput = /^(INPUT|TEXTAREA|SELECT)$/.test(activeElement.tagName) || activeElement.isContentEditable
|
||||||
const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement
|
if (isProtected || isInput) return
|
||||||
if (scrollContainer) {
|
|
||||||
event.preventDefault()
|
|
||||||
const scrollAmount = scrollContainer.clientHeight * 0.8
|
|
||||||
scrollContainer.scrollBy({
|
|
||||||
top: event.key === "PageUp" ? -scrollAmount : scrollAmount,
|
|
||||||
behavior: "instant",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if (dialog.active) return
|
||||||
|
|
||||||
const focused = document.activeElement === inputRef
|
if (activeElement === inputRef) {
|
||||||
if (focused) {
|
|
||||||
if (event.key === "Escape") inputRef?.blur()
|
if (event.key === "Escape") inputRef?.blur()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -517,304 +542,321 @@ export default function Page() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConstrainDragYAxis = (): JSX.Element => {
|
const showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0))
|
||||||
const context = useDragDropContext()
|
|
||||||
if (!context) return <></>
|
|
||||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
|
||||||
const transformer: Transformer = {
|
|
||||||
id: "constrain-y-axis",
|
|
||||||
order: 100,
|
|
||||||
callback: (transform) => ({ ...transform, y: 0 }),
|
|
||||||
}
|
|
||||||
onDragStart((event) => {
|
|
||||||
const id = getDraggableId(event)
|
|
||||||
if (!id) return
|
|
||||||
addTransformer("draggables", id, transformer)
|
|
||||||
})
|
|
||||||
onDragEnd((event) => {
|
|
||||||
const id = getDraggableId(event)
|
|
||||||
if (!id) return
|
|
||||||
removeTransformer("draggables", id, transformer.id)
|
|
||||||
})
|
|
||||||
return <></>
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDraggableId = (event: unknown): string | undefined => {
|
const mobileWorking = createMemo(() => status().type !== "idle")
|
||||||
if (typeof event !== "object" || event === null) return undefined
|
const mobileAutoScroll = createAutoScroll({
|
||||||
if (!("draggable" in event)) return undefined
|
working: mobileWorking,
|
||||||
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
onUserInteracted: () => setStore("userInteracted", true),
|
||||||
if (!draggable) return undefined
|
})
|
||||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length)
|
const MobileTurns = () => (
|
||||||
|
<div
|
||||||
|
ref={mobileAutoScroll.scrollRef}
|
||||||
|
onScroll={mobileAutoScroll.handleScroll}
|
||||||
|
onClick={mobileAutoScroll.handleInteraction}
|
||||||
|
class="relative mt-2 min-w-0 w-full h-full overflow-y-auto no-scrollbar pb-12"
|
||||||
|
>
|
||||||
|
<div ref={mobileAutoScroll.contentRef} class="flex flex-col gap-45 items-start justify-start mt-4">
|
||||||
|
<For each={visibleUserMessages()}>
|
||||||
|
{(message) => (
|
||||||
|
<SessionTurn
|
||||||
|
sessionID={params.id!}
|
||||||
|
messageID={message.id}
|
||||||
|
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
|
||||||
|
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
|
||||||
|
onUserInteracted={() => setStore("userInteracted", true)}
|
||||||
|
classes={{
|
||||||
|
root: "min-w-0 w-full relative",
|
||||||
|
content:
|
||||||
|
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
||||||
|
container: "px-4",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const NewSessionView = () => (
|
||||||
|
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
|
||||||
|
<div class="text-20-medium text-text-weaker">New session</div>
|
||||||
|
<div class="flex justify-center items-center gap-3">
|
||||||
|
<Icon name="folder" size="small" />
|
||||||
|
<div class="text-12-medium text-text-weak">
|
||||||
|
{getDirectory(sync.data.path.directory)}
|
||||||
|
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={sync.project}>
|
||||||
|
{(project) => (
|
||||||
|
<div class="flex justify-center items-center gap-3">
|
||||||
|
<Icon name="pencil-line" size="small" />
|
||||||
|
<div class="text-12-medium text-text-weak">
|
||||||
|
Last modified
|
||||||
|
<span class="text-text-strong">
|
||||||
|
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const DesktopSessionContent = () => (
|
||||||
|
<Switch>
|
||||||
|
<Match when={params.id}>
|
||||||
|
<div class="flex items-start justify-start h-full min-h-0">
|
||||||
|
<SessionMessageRail
|
||||||
|
messages={visibleUserMessages()}
|
||||||
|
current={activeMessage()}
|
||||||
|
onMessageSelect={setActiveMessage}
|
||||||
|
wide={!showTabs()}
|
||||||
|
/>
|
||||||
|
<Show when={activeMessage()}>
|
||||||
|
<SessionTurn
|
||||||
|
sessionID={params.id!}
|
||||||
|
messageID={activeMessage()!.id}
|
||||||
|
stepsExpanded={store.stepsExpanded}
|
||||||
|
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
|
||||||
|
onUserInteracted={() => setStore("userInteracted", true)}
|
||||||
|
classes={{
|
||||||
|
root: "pb-20 flex-1 min-w-0",
|
||||||
|
content: "pb-20",
|
||||||
|
container:
|
||||||
|
"w-full " +
|
||||||
|
(!showTabs() ? "max-w-200 mx-auto px-6" : visibleUserMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<NewSessionView />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
|
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||||
<div class="min-h-0 grow w-full">
|
<div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
|
||||||
<DragDropProvider
|
<Switch>
|
||||||
onDragStart={handleDragStart}
|
<Match when={!params.id}>
|
||||||
onDragEnd={handleDragEnd}
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
onDragOver={handleDragOver}
|
<NewSessionView />
|
||||||
collisionDetector={closestCenter}
|
|
||||||
>
|
|
||||||
<DragDropSensors />
|
|
||||||
<ConstrainDragYAxis />
|
|
||||||
<Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}>
|
|
||||||
<div class="sticky top-0 shrink-0 flex">
|
|
||||||
<Tabs.List>
|
|
||||||
<Tabs.Trigger value="chat">
|
|
||||||
<div class="flex gap-x-[17px] items-center">
|
|
||||||
<div>Session</div>
|
|
||||||
<Tooltip
|
|
||||||
value={`${new Intl.NumberFormat("en-US", {
|
|
||||||
notation: "compact",
|
|
||||||
compactDisplay: "short",
|
|
||||||
}).format(tokens() ?? 0)} Tokens`}
|
|
||||||
class="flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<ProgressCircle percentage={context() ?? 0} />
|
|
||||||
<div class="text-14-regular text-text-weak text-left w-7">{context() ?? 0}%</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Show when={layout.review.state() === "tab" && diffs().length}>
|
|
||||||
<Tabs.Trigger
|
|
||||||
value="review"
|
|
||||||
closeButton={
|
|
||||||
<Tooltip value="Close tab" placement="bottom">
|
|
||||||
<IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Show when={diffs()}>
|
|
||||||
<DiffChanges changes={diffs()} variant="bars" />
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<div>Review</div>
|
|
||||||
<Show when={info()?.summary?.files}>
|
|
||||||
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
|
||||||
{info()?.summary?.files ?? 0}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</Show>
|
|
||||||
<SortableProvider ids={tabs().all() ?? []}>
|
|
||||||
<For each={tabs().all() ?? []}>
|
|
||||||
{(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
|
|
||||||
</For>
|
|
||||||
</SortableProvider>
|
|
||||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
|
||||||
<Tooltip value="Open file" class="flex items-center">
|
|
||||||
<IconButton
|
|
||||||
icon="plus-small"
|
|
||||||
variant="ghost"
|
|
||||||
iconSize="large"
|
|
||||||
onClick={() => dialog.replace(() => <DialogSelectFile />)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Tabs.List>
|
|
||||||
</div>
|
</div>
|
||||||
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
|
</Match>
|
||||||
<div
|
<Match when={diffs().length > 0}>
|
||||||
classList={{
|
<Tabs class="flex-1 min-h-0 flex flex-col pb-28">
|
||||||
"w-full flex-1 min-h-0": true,
|
<Tabs.List>
|
||||||
grid: layout.review.state() === "tab",
|
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
|
||||||
flex: layout.review.state() === "pane",
|
Session
|
||||||
}}
|
</Tabs.Trigger>
|
||||||
>
|
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
|
||||||
<div
|
{diffs().length} Files Changed
|
||||||
classList={{
|
</Tabs.Trigger>
|
||||||
"relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true,
|
</Tabs.List>
|
||||||
"max-w-146 mx-auto": !wide(),
|
<Tabs.Content value="session" class="flex-1 !overflow-hidden">
|
||||||
}}
|
<MobileTurns />
|
||||||
>
|
</Tabs.Content>
|
||||||
<Switch>
|
<Tabs.Content forceMount value="review" class="flex-1 !overflow-hidden hidden data-[selected]:block">
|
||||||
<Match when={params.id}>
|
<div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
|
||||||
<div class="flex items-start justify-start h-full min-h-0">
|
|
||||||
<SessionMessageRail
|
|
||||||
messages={visibleUserMessages()}
|
|
||||||
current={activeMessage()}
|
|
||||||
onMessageSelect={setActiveMessage}
|
|
||||||
wide={wide()}
|
|
||||||
/>
|
|
||||||
<Show when={activeMessage()}>
|
|
||||||
<SessionTurn
|
|
||||||
sessionID={params.id!}
|
|
||||||
messageID={activeMessage()!.id}
|
|
||||||
stepsExpanded={store.stepsExpanded}
|
|
||||||
onStepsExpandedChange={(expanded) => setStore("stepsExpanded", expanded)}
|
|
||||||
classes={{
|
|
||||||
root: "pb-20 flex-1 min-w-0",
|
|
||||||
content: "pb-20",
|
|
||||||
container:
|
|
||||||
"w-full " +
|
|
||||||
(wide()
|
|
||||||
? "max-w-146 mx-auto px-6"
|
|
||||||
: visibleUserMessages().length > 1
|
|
||||||
? "pr-6 pl-18"
|
|
||||||
: "px-6"),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-146 mx-auto px-6">
|
|
||||||
<div class="text-20-medium text-text-weaker">New session</div>
|
|
||||||
<div class="flex justify-center items-center gap-3">
|
|
||||||
<Icon name="folder" size="small" />
|
|
||||||
<div class="text-12-medium text-text-weak">
|
|
||||||
{getDirectory(sync.data.path.directory)}
|
|
||||||
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={sync.project}>
|
|
||||||
{(project) => (
|
|
||||||
<div class="flex justify-center items-center gap-3">
|
|
||||||
<Icon name="pencil-line" size="small" />
|
|
||||||
<div class="text-12-medium text-text-weak">
|
|
||||||
Last modified
|
|
||||||
<span class="text-text-strong">
|
|
||||||
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
|
|
||||||
<div class="w-full max-w-146 px-6">
|
|
||||||
<PromptInput
|
|
||||||
ref={(el) => {
|
|
||||||
inputRef = el
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={layout.review.state() === "pane" && diffs().length}>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SessionReview
|
|
||||||
classes={{
|
|
||||||
root: "pb-20",
|
|
||||||
header: "px-6",
|
|
||||||
container: "px-6",
|
|
||||||
}}
|
|
||||||
diffs={diffs()}
|
|
||||||
actions={
|
|
||||||
<Tooltip value="Open in tab">
|
|
||||||
<IconButton
|
|
||||||
icon="expand"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
layout.review.tab()
|
|
||||||
tabs().setActive("review")
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
|
||||||
<Show when={layout.review.state() === "tab" && diffs().length}>
|
|
||||||
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"relative pt-3 flex-1 min-h-0 overflow-hidden": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SessionReview
|
<SessionReview
|
||||||
classes={{
|
|
||||||
root: "pb-40",
|
|
||||||
header: "px-6",
|
|
||||||
container: "px-6",
|
|
||||||
}}
|
|
||||||
diffs={diffs()}
|
diffs={diffs()}
|
||||||
split
|
classes={{
|
||||||
|
root: "pb-32",
|
||||||
|
header: "px-4",
|
||||||
|
container: "px-4",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
</Tabs>
|
||||||
<For each={tabs().all()}>
|
</Match>
|
||||||
{(tab) => {
|
<Match when={true}>
|
||||||
const [file] = createResource(
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
() => tab,
|
<MobileTurns />
|
||||||
async (tab) => {
|
</div>
|
||||||
if (tab.startsWith("file://")) {
|
</Match>
|
||||||
return local.file.node(tab.replace("file://", ""))
|
</Switch>
|
||||||
}
|
<div class="absolute inset-x-0 bottom-4 flex flex-col justify-center items-center z-50 px-4">
|
||||||
return undefined
|
<div class="w-full">
|
||||||
},
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<Tabs.Content value={tab} class="select-text mt-3">
|
|
||||||
<Switch>
|
|
||||||
<Match when={file()}>
|
|
||||||
{(f) => (
|
|
||||||
<Code
|
|
||||||
file={{
|
|
||||||
name: f().path,
|
|
||||||
contents: f().content?.content ?? "",
|
|
||||||
cacheKey: checksum(f().content?.content ?? ""),
|
|
||||||
}}
|
|
||||||
overflow="scroll"
|
|
||||||
class="pb-40"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Tabs.Content>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Tabs>
|
|
||||||
<DragOverlay>
|
|
||||||
<Show when={store.activeDraggable}>
|
|
||||||
{(draggedFile) => {
|
|
||||||
const [file] = createResource(
|
|
||||||
() => draggedFile(),
|
|
||||||
async (tab) => {
|
|
||||||
if (tab.startsWith("file://")) {
|
|
||||||
return local.file.node(tab.replace("file://", ""))
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
|
|
||||||
<Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
</DragOverlay>
|
|
||||||
</DragDropProvider>
|
|
||||||
<Show when={tabs().active()}>
|
|
||||||
<div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
|
|
||||||
<PromptInput
|
<PromptInput
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
inputRef = el
|
inputRef = el
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden md:flex min-h-0 grow w-full">
|
||||||
|
<div
|
||||||
|
class="@container relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
|
||||||
|
style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<DesktopSessionContent />
|
||||||
|
</div>
|
||||||
|
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"w-full px-6": true,
|
||||||
|
"max-w-200": !showTabs(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PromptInput
|
||||||
|
ref={(el) => {
|
||||||
|
inputRef = el
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={showTabs()}>
|
||||||
|
<ResizeHandle
|
||||||
|
direction="horizontal"
|
||||||
|
size={layout.session.width()}
|
||||||
|
min={450}
|
||||||
|
max={window.innerWidth * 0.45}
|
||||||
|
onResize={layout.session.resize}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={showTabs()}>
|
||||||
|
<div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
|
||||||
|
<DragDropProvider
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
collisionDetector={closestCenter}
|
||||||
|
>
|
||||||
|
<DragDropSensors />
|
||||||
|
<ConstrainDragYAxis />
|
||||||
|
<Tabs value={tabs().active() ?? "review"} onChange={tabs().open}>
|
||||||
|
<div class="sticky top-0 shrink-0 flex">
|
||||||
|
<Tabs.List>
|
||||||
|
<Show when={diffs().length}>
|
||||||
|
<Tabs.Trigger value="review">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Show when={diffs()}>
|
||||||
|
<DiffChanges changes={diffs()} variant="bars" />
|
||||||
|
</Show>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div>Review</div>
|
||||||
|
<Show when={info()?.summary?.files}>
|
||||||
|
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
||||||
|
{info()?.summary?.files ?? 0}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
|
<SortableProvider ids={tabs().all() ?? []}>
|
||||||
|
<For each={tabs().all() ?? []}>
|
||||||
|
{(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
|
||||||
|
</For>
|
||||||
|
</SortableProvider>
|
||||||
|
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||||
|
<Tooltip
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>Open file</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("file.open")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="plus-small"
|
||||||
|
variant="ghost"
|
||||||
|
iconSize="large"
|
||||||
|
onClick={() => dialog.show(() => <DialogSelectFile />)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Tabs.List>
|
||||||
|
</div>
|
||||||
|
<Show when={diffs().length}>
|
||||||
|
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
|
||||||
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
|
<SessionReview
|
||||||
|
classes={{
|
||||||
|
root: "pb-40",
|
||||||
|
header: "px-6",
|
||||||
|
container: "px-6",
|
||||||
|
}}
|
||||||
|
diffs={diffs()}
|
||||||
|
split
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Show>
|
||||||
|
<For each={tabs().all()}>
|
||||||
|
{(tab) => {
|
||||||
|
const [file] = createResource(
|
||||||
|
() => tab,
|
||||||
|
async (tab) => {
|
||||||
|
if (tab.startsWith("file://")) {
|
||||||
|
return local.file.node(tab.replace("file://", ""))
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Tabs.Content value={tab} class="select-text mt-3">
|
||||||
|
<Switch>
|
||||||
|
<Match when={file()}>
|
||||||
|
{(f) => (
|
||||||
|
<Dynamic
|
||||||
|
component={codeComponent}
|
||||||
|
file={{
|
||||||
|
name: f().path,
|
||||||
|
contents: f().content?.content ?? "",
|
||||||
|
cacheKey: checksum(f().content?.content ?? ""),
|
||||||
|
}}
|
||||||
|
overflow="scroll"
|
||||||
|
class="pb-40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Tabs.Content>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Tabs>
|
||||||
|
<DragOverlay>
|
||||||
|
<Show when={store.activeDraggable}>
|
||||||
|
{(draggedFile) => {
|
||||||
|
const [file] = createResource(
|
||||||
|
() => draggedFile(),
|
||||||
|
async (tab) => {
|
||||||
|
if (tab.startsWith("file://")) {
|
||||||
|
return local.file.node(tab.replace("file://", ""))
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
|
||||||
|
<Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={layout.terminal.opened()}>
|
<Show when={layout.terminal.opened()}>
|
||||||
<div
|
<div
|
||||||
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
|
class="hidden md:flex relative w-full flex-col shrink-0 border-t border-border-weak-base"
|
||||||
style={{ height: `${layout.terminal.height()}px` }}
|
style={{ height: `${layout.terminal.height()}px` }}
|
||||||
>
|
>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
|
|
@ -840,7 +882,15 @@ export default function Page() {
|
||||||
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
<div class="h-full flex items-center justify-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
<Tooltip value="New Terminal" class="flex items-center">
|
<Tooltip
|
||||||
|
value={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>New terminal</span>
|
||||||
|
<span class="text-icon-base text-12-medium">{command.keybind("terminal.new")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
99
packages/app/src/utils/id.ts
Normal file
99
packages/app/src/utils/id.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import z from "zod"
|
||||||
|
|
||||||
|
const prefixes = {
|
||||||
|
session: "ses",
|
||||||
|
message: "msg",
|
||||||
|
permission: "per",
|
||||||
|
user: "usr",
|
||||||
|
part: "prt",
|
||||||
|
pty: "pty",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const LENGTH = 26
|
||||||
|
let lastTimestamp = 0
|
||||||
|
let counter = 0
|
||||||
|
|
||||||
|
type Prefix = keyof typeof prefixes
|
||||||
|
export namespace Identifier {
|
||||||
|
export function schema(prefix: Prefix) {
|
||||||
|
return z.string().startsWith(prefixes[prefix])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ascending(prefix: Prefix, given?: string) {
|
||||||
|
return generateID(prefix, false, given)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function descending(prefix: Prefix, given?: string) {
|
||||||
|
return generateID(prefix, true, given)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateID(prefix: Prefix, descending: boolean, given?: string): string {
|
||||||
|
if (!given) {
|
||||||
|
return create(prefix, descending)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!given.startsWith(prefixes[prefix])) {
|
||||||
|
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return given
|
||||||
|
}
|
||||||
|
|
||||||
|
function create(prefix: Prefix, descending: boolean, timestamp?: number): string {
|
||||||
|
const currentTimestamp = timestamp ?? Date.now()
|
||||||
|
|
||||||
|
if (currentTimestamp !== lastTimestamp) {
|
||||||
|
lastTimestamp = currentTimestamp
|
||||||
|
counter = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
|
||||||
|
|
||||||
|
if (descending) {
|
||||||
|
now = ~now
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeBytes = new Uint8Array(6)
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
let hex = ""
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
hex += bytes[i].toString(16).padStart(2, "0")
|
||||||
|
}
|
||||||
|
return hex
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomBase62(length: number): string {
|
||||||
|
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
const bytes = getRandomBytes(length)
|
||||||
|
let result = ""
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
result += chars[bytes[i] % 62]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomBytes(length: number): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(length)
|
||||||
|
const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined
|
||||||
|
|
||||||
|
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
|
||||||
|
cryptoObj.getRandomValues(bytes)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
bytes[i] = Math.floor(Math.random() * 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
26
packages/app/src/utils/persist.ts
Normal file
26
packages/app/src/utils/persist.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { makePersisted } from "@solid-primitives/storage"
|
||||||
|
import { createResource, type Accessor } from "solid-js"
|
||||||
|
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||||
|
|
||||||
|
type InitType = Promise<string> | string | null
|
||||||
|
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
|
||||||
|
|
||||||
|
export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
|
||||||
|
|
||||||
|
// Create a resource that resolves when the store is initialized
|
||||||
|
// This integrates with Suspense and provides a ready signal
|
||||||
|
const isAsync = init instanceof Promise
|
||||||
|
const [ready] = createResource(
|
||||||
|
() => init,
|
||||||
|
async (initValue) => {
|
||||||
|
if (initValue instanceof Promise) await initValue
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{ initialValue: !isAsync },
|
||||||
|
)
|
||||||
|
|
||||||
|
return [state, setState, init, () => ready() === true]
|
||||||
|
}
|
||||||
55
packages/app/src/utils/solid-dnd.tsx
Normal file
55
packages/app/src/utils/solid-dnd.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { useDragDropContext } from "@thisbeyond/solid-dnd"
|
||||||
|
import { JSXElement } from "solid-js"
|
||||||
|
import type { Transformer } from "@thisbeyond/solid-dnd"
|
||||||
|
|
||||||
|
export const getDraggableId = (event: unknown): string | undefined => {
|
||||||
|
if (typeof event !== "object" || event === null) return undefined
|
||||||
|
if (!("draggable" in event)) return undefined
|
||||||
|
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
||||||
|
if (!draggable) return undefined
|
||||||
|
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConstrainDragXAxis = (): JSXElement => {
|
||||||
|
const context = useDragDropContext()
|
||||||
|
if (!context) return <></>
|
||||||
|
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||||
|
const transformer: Transformer = {
|
||||||
|
id: "constrain-x-axis",
|
||||||
|
order: 100,
|
||||||
|
callback: (transform) => ({ ...transform, x: 0 }),
|
||||||
|
}
|
||||||
|
onDragStart((event) => {
|
||||||
|
const id = getDraggableId(event)
|
||||||
|
if (!id) return
|
||||||
|
addTransformer("draggables", id, transformer)
|
||||||
|
})
|
||||||
|
onDragEnd((event) => {
|
||||||
|
const id = getDraggableId(event)
|
||||||
|
if (!id) return
|
||||||
|
removeTransformer("draggables", id, transformer.id)
|
||||||
|
})
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConstrainDragYAxis = (): JSXElement => {
|
||||||
|
const context = useDragDropContext()
|
||||||
|
if (!context) return <></>
|
||||||
|
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||||
|
const transformer: Transformer = {
|
||||||
|
id: "constrain-y-axis",
|
||||||
|
order: 100,
|
||||||
|
callback: (transform) => ({ ...transform, y: 0 }),
|
||||||
|
}
|
||||||
|
onDragStart((event) => {
|
||||||
|
const id = getDraggableId(event)
|
||||||
|
if (!id) return
|
||||||
|
addTransformer("draggables", id, transformer)
|
||||||
|
})
|
||||||
|
onDragEnd((event) => {
|
||||||
|
const id = getDraggableId(event)
|
||||||
|
if (!id) return
|
||||||
|
removeTransformer("draggables", id, transformer.id)
|
||||||
|
})
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
{
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
@ -10,11 +12,13 @@
|
||||||
"jsxImportSource": "solid-js",
|
"jsxImportSource": "solid-js",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"outDir": "node_modules/.ts-dist",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"paths": {
|
||||||
"emitDeclarationOnly": false,
|
"@/*": ["./src/*"]
|
||||||
"outDir": "node_modules/.ts-dist"
|
}
|
||||||
},
|
},
|
||||||
"references": [{ "path": "../desktop" }],
|
"exclude": ["dist", "ts-dist"]
|
||||||
"include": ["src"]
|
|
||||||
}
|
}
|
||||||
15
packages/app/vite.config.ts
Normal file
15
packages/app/vite.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from "vite"
|
||||||
|
import desktopPlugin from "./vite"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [desktopPlugin] as any,
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
allowedHosts: true,
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: "esnext",
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue