mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-09 10:24:11 +00:00
Compare commits
863 Commits
v1.1.45
...
thinking-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b6a21f173 | ||
|
|
f7bdf0b74f | ||
|
|
7539cbeea2 | ||
|
|
b683567c2c | ||
|
|
6965805eeb | ||
|
|
7a511a1a25 | ||
|
|
f3437d9a49 | ||
|
|
ccf1826400 | ||
|
|
f6b24d81d4 | ||
|
|
3127f4f30e | ||
|
|
d7a3a13034 | ||
|
|
a92af6b9ef | ||
|
|
21e4fe3718 | ||
|
|
fa64085aba | ||
|
|
06307dc8c3 | ||
|
|
1bf950179a | ||
|
|
b112c8132c | ||
|
|
b279da3684 | ||
|
|
dcfeeb6919 | ||
|
|
0762a794ab | ||
|
|
f28628b744 | ||
|
|
336d165fd2 | ||
|
|
af89e7f117 | ||
|
|
57ad8211d5 | ||
|
|
3fc23c3e8b | ||
|
|
d6899cf9b0 | ||
|
|
58c643f205 | ||
|
|
dcc5b7d6db | ||
|
|
1c35e59e4f | ||
|
|
c1d21f9b11 | ||
|
|
822c20b320 | ||
|
|
fbe676d1da | ||
|
|
3f3c0f370b | ||
|
|
3e4b8e2bf5 | ||
|
|
ccd182f522 | ||
|
|
bbf59fcb12 | ||
|
|
b40852abe3 | ||
|
|
b6d8779c22 | ||
|
|
b479893c9a | ||
|
|
fbc9120dc3 | ||
|
|
95074ab541 | ||
|
|
e98a2a5e47 | ||
|
|
e60ef0286d | ||
|
|
a64e24a991 | ||
|
|
01ca39791d | ||
|
|
13b6a47e77 | ||
|
|
6fab9d2ac3 | ||
|
|
2381a83fc7 | ||
|
|
4ed0dc1dcc | ||
|
|
01dd70eab1 | ||
|
|
3c5529bf19 | ||
|
|
9a2f955910 | ||
|
|
fefb9e5e0c | ||
|
|
61bad1fd90 | ||
|
|
e42d586460 | ||
|
|
7c4d1e9443 | ||
|
|
b5e5bf549e | ||
|
|
f91839889f | ||
|
|
512b3088f7 | ||
|
|
0b5df9c4c4 | ||
|
|
371a1fd115 | ||
|
|
87d904422b | ||
|
|
8d85f975d8 | ||
|
|
37f98de6e6 | ||
|
|
1285f67b39 | ||
|
|
4c44a09991 | ||
|
|
7466c08277 | ||
|
|
d2da59f844 | ||
|
|
52d81bdfd2 | ||
|
|
b3ab52fe79 | ||
|
|
b39cf15833 | ||
|
|
cc39dbedf2 | ||
|
|
6242baea0a | ||
|
|
b43a91db78 | ||
|
|
19c1ed11b8 | ||
|
|
b4d25efbb9 | ||
|
|
fd56d8da8d | ||
|
|
8a1c3702a5 | ||
|
|
a0621840d4 | ||
|
|
073fba09a4 | ||
|
|
49fa4fce1c | ||
|
|
b59a021c94 | ||
|
|
571a6ec6fc | ||
|
|
7b89bb2a3b | ||
|
|
f6a622d2fd | ||
|
|
b47c9a4f9e | ||
|
|
807d61cb94 | ||
|
|
32cf23ffef | ||
|
|
f2ceb55514 | ||
|
|
cdc9c007bd | ||
|
|
5de8dd131b | ||
|
|
a663ab81e5 | ||
|
|
4154067dfd | ||
|
|
4dcd196135 | ||
|
|
7fe4e76666 | ||
|
|
c8b69d2ad0 | ||
|
|
754d332692 | ||
|
|
a2cd45ada1 | ||
|
|
a806023bdc | ||
|
|
74a93f871f | ||
|
|
59f1b41f73 | ||
|
|
d06aae5544 | ||
|
|
4b236451e9 | ||
|
|
0ff29879de | ||
|
|
2ade0d8f3e | ||
|
|
78046d06e1 | ||
|
|
270505b0be | ||
|
|
c1cea004f4 | ||
|
|
6cd9e5d3ac | ||
|
|
df04dfc957 | ||
|
|
fa53ab0cce | ||
|
|
f22dd0646e | ||
|
|
73e5315077 | ||
|
|
7ea4a63257 | ||
|
|
e94499d178 | ||
|
|
05e8dfb8a2 | ||
|
|
e8ca1d1f7d | ||
|
|
b8c2f9db59 | ||
|
|
245a63e3cf | ||
|
|
0877ba302d | ||
|
|
3f2aa25a94 | ||
|
|
fe91899428 | ||
|
|
e8bce0aaf2 | ||
|
|
8767cca3ea | ||
|
|
6ef69dd905 | ||
|
|
24f9ce30fc | ||
|
|
523c47d285 | ||
|
|
466ed745ab | ||
|
|
1f2a4537bc | ||
|
|
0e50b4adea | ||
|
|
e7cf8b0813 | ||
|
|
459db5c655 | ||
|
|
0e6be83872 | ||
|
|
3612e09e95 | ||
|
|
67e74aef10 | ||
|
|
3f9ee3330f | ||
|
|
180e1f35ef | ||
|
|
e2e36adab7 | ||
|
|
3b4519911e | ||
|
|
74cd5456b9 | ||
|
|
6417a72f0b | ||
|
|
dfdde091f5 | ||
|
|
72cb02f4d5 | ||
|
|
7692af47c5 | ||
|
|
441f5ba911 | ||
|
|
ad93850696 | ||
|
|
c19a0c5d09 | ||
|
|
5d0fd6e7b9 | ||
|
|
7dc18c35c2 | ||
|
|
9c2d6c7f6a | ||
|
|
aafa717d4c | ||
|
|
327dd225a8 | ||
|
|
eb5c544e45 | ||
|
|
6d1a9227b9 | ||
|
|
a73a36e533 | ||
|
|
2d6c3a886c | ||
|
|
b926c4f75a | ||
|
|
e2b65f6ebd | ||
|
|
3341f3ae42 | ||
|
|
45a147858d | ||
|
|
38a67c2e5e | ||
|
|
d5fd078a42 | ||
|
|
5ce08ba95a | ||
|
|
80333e13e1 | ||
|
|
4a6facee03 | ||
|
|
0cf94b991a | ||
|
|
4c4bbc89e1 | ||
|
|
dd4b348154 | ||
|
|
9ffa3ab24e | ||
|
|
d1581fbcf2 | ||
|
|
0c4b36a433 | ||
|
|
139eb94bba | ||
|
|
ce8ea03bd4 | ||
|
|
0ccf6a8e2e | ||
|
|
cc35060491 | ||
|
|
11878623bb | ||
|
|
ed59558070 | ||
|
|
992327a05d | ||
|
|
4ccd407259 | ||
|
|
3fef2d3b43 | ||
|
|
30e6af1b2f | ||
|
|
1d71f33b71 | ||
|
|
e96fdcf042 | ||
|
|
2d22216826 | ||
|
|
c81633aa3c | ||
|
|
f8563e901b | ||
|
|
6f4ddd15c9 | ||
|
|
d3eb85e330 | ||
|
|
86148821eb | ||
|
|
15c6052711 | ||
|
|
b0c88929ed | ||
|
|
58a9cb901e | ||
|
|
997f84a5bd | ||
|
|
4a347586d0 | ||
|
|
10745d5bef | ||
|
|
35e6fc42b0 | ||
|
|
523ed96df8 | ||
|
|
834f144c52 | ||
|
|
d5bf844e2c | ||
|
|
64a8852637 | ||
|
|
73578d69cb | ||
|
|
f4bd11a43d | ||
|
|
82826c0dde | ||
|
|
cc4a1e8849 | ||
|
|
de20383402 | ||
|
|
baee918ed0 | ||
|
|
67db1ef771 | ||
|
|
1ac00cebea | ||
|
|
2fd23715f4 | ||
|
|
a9ae1ef2c1 | ||
|
|
141d24c28c | ||
|
|
f30992001f | ||
|
|
e79b9ad111 | ||
|
|
72a9e23926 | ||
|
|
21d81dcf4e | ||
|
|
811c8aac5e | ||
|
|
2cf69ec114 | ||
|
|
439af1740a | ||
|
|
cf3d91e15b | ||
|
|
344cdbb36b | ||
|
|
8ca4fdafd9 | ||
|
|
2fb212071b | ||
|
|
61be2c7f83 | ||
|
|
e81c496bb6 | ||
|
|
c9a42f760f | ||
|
|
b6089e4371 | ||
|
|
f8b0ee9637 | ||
|
|
0b7b6d105e | ||
|
|
59cc15ecde | ||
|
|
9236478182 | ||
|
|
fa9edbe9d2 | ||
|
|
99e04ad2ed | ||
|
|
e70ae665d0 | ||
|
|
ac03ab1e10 | ||
|
|
cc572343be | ||
|
|
b0c860a17b | ||
|
|
bfcbdaceb7 | ||
|
|
73e8caa5e9 | ||
|
|
3c6c7c5278 | ||
|
|
86d34aee8e | ||
|
|
dcee57f572 | ||
|
|
249431d84a | ||
|
|
806c21c535 | ||
|
|
309e594e41 | ||
|
|
355323f180 | ||
|
|
9f4c5d6736 | ||
|
|
24ed94bc1f | ||
|
|
599da3ee8f | ||
|
|
b1cd7d617b | ||
|
|
ddab63be61 | ||
|
|
18ff962f6e | ||
|
|
5a84872777 | ||
|
|
1282f3457d | ||
|
|
2ea105735d | ||
|
|
2e7086e321 | ||
|
|
64a50bb030 | ||
|
|
320a434ae7 | ||
|
|
09875f5984 | ||
|
|
89ce5d9ea2 | ||
|
|
330e4a7c73 | ||
|
|
79134dd16a | ||
|
|
10e454481c | ||
|
|
d9b1b876e5 | ||
|
|
e21c6d93e0 | ||
|
|
7eba950c6d | ||
|
|
53149554c8 | ||
|
|
2dc9528867 | ||
|
|
b7a9cc5fd3 | ||
|
|
a8f5e69ede | ||
|
|
6fe028dd21 | ||
|
|
6d6bc0140b | ||
|
|
a22a67b8bc | ||
|
|
f2ddf5a7d4 | ||
|
|
d4cb80a5e1 | ||
|
|
221093a52b | ||
|
|
80271f939c | ||
|
|
0cc373de7b | ||
|
|
d3f42d5302 | ||
|
|
d0fb6e72fe | ||
|
|
9e9eaabe68 | ||
|
|
c04c92778b | ||
|
|
76b0ac2204 | ||
|
|
a81cd3c8c4 | ||
|
|
3bd665c5b6 | ||
|
|
2fa7b01072 | ||
|
|
84a2180d0b | ||
|
|
6b21504dbc | ||
|
|
244afdf4ef | ||
|
|
8bc49ccf2c | ||
|
|
0f5ce6fb04 | ||
|
|
0d1f8aa40e | ||
|
|
eb6ae39917 | ||
|
|
c57e4dc411 | ||
|
|
61be42e08c | ||
|
|
9f1ed1c186 | ||
|
|
1cd4cafc35 | ||
|
|
0ec1e14c63 | ||
|
|
bfeef85d59 | ||
|
|
31de4fc223 | ||
|
|
267447018e | ||
|
|
53d9c745cd | ||
|
|
bfe2ddf81a | ||
|
|
72bf8b4b6d | ||
|
|
a0c279d75e | ||
|
|
0e8303c016 | ||
|
|
7d88443ca8 | ||
|
|
85ef34ca06 | ||
|
|
3fdeee126a | ||
|
|
7654839159 | ||
|
|
af66701f44 | ||
|
|
25e25a2110 | ||
|
|
f57538ebba | ||
|
|
cbe06c47b3 | ||
|
|
cdfdd63bee | ||
|
|
753b7362a3 | ||
|
|
bd24e1a715 | ||
|
|
b1e7975bf8 | ||
|
|
2d47d5cf83 | ||
|
|
1e867702a3 | ||
|
|
2a3b9bde24 | ||
|
|
2fec3c1859 | ||
|
|
c60ef697f2 | ||
|
|
eb84c5820f | ||
|
|
b7c3d0dd9d | ||
|
|
e5954a97b4 | ||
|
|
13e1091f0f | ||
|
|
0d94abc69a | ||
|
|
5d5c2523b8 | ||
|
|
5bac846e98 | ||
|
|
997cf6e207 | ||
|
|
8dfdd09dcb | ||
|
|
1024724cc6 | ||
|
|
2774e4f6fd | ||
|
|
29b22b46db | ||
|
|
9d75fbd1b2 | ||
|
|
6557fa2e0b | ||
|
|
6e5d7fcae3 | ||
|
|
2912ba9526 | ||
|
|
8ec421a3be | ||
|
|
b279b5022c | ||
|
|
78364f0137 | ||
|
|
67a7483505 | ||
|
|
a023273d9e | ||
|
|
64dd604ea0 | ||
|
|
34d9c657e8 | ||
|
|
1fcacfd889 | ||
|
|
979a50ecfe | ||
|
|
901801d3d8 | ||
|
|
f5468a0399 | ||
|
|
b9dd35bfcc | ||
|
|
192975e07e | ||
|
|
669a7e0897 | ||
|
|
1eaa71e236 | ||
|
|
583f0aa5ef | ||
|
|
6c159d4f78 | ||
|
|
160b26f360 | ||
|
|
f85a57d4af | ||
|
|
38eb36f4f6 | ||
|
|
01ddf7deb6 | ||
|
|
be47071ce5 | ||
|
|
7bc6ac85a3 | ||
|
|
65ca3c463a | ||
|
|
d30536efa6 | ||
|
|
1682fdf0d1 | ||
|
|
d2919b1d74 | ||
|
|
72c4cadb22 | ||
|
|
35787ffaed | ||
|
|
c0fe626748 | ||
|
|
3306d8f666 | ||
|
|
5e8b1d8f4a | ||
|
|
9ce16ac197 | ||
|
|
8597343df0 | ||
|
|
47444b398b | ||
|
|
b95be947c6 | ||
|
|
f17e4fbaf0 | ||
|
|
fd9e54978a | ||
|
|
59e6454823 | ||
|
|
8199a3b5e5 | ||
|
|
881465a7d0 | ||
|
|
feb9f9dbd6 | ||
|
|
5c47adb782 | ||
|
|
bf653d44d1 | ||
|
|
71af1859fa | ||
|
|
f084a0cfee | ||
|
|
28d2ae5208 | ||
|
|
cdb7949280 | ||
|
|
f02d1ef4a1 | ||
|
|
59968ebd25 | ||
|
|
a346d22c25 | ||
|
|
f15db51fe0 | ||
|
|
92dfbdc08b | ||
|
|
923bad1f70 | ||
|
|
551c5283bd | ||
|
|
a05851b0ea | ||
|
|
d888a318c5 | ||
|
|
cfa95461b2 | ||
|
|
182cf2d878 | ||
|
|
6473b3f91a | ||
|
|
6c8a7f05be | ||
|
|
d8a450ce17 | ||
|
|
d96fa874b0 | ||
|
|
29541578b1 | ||
|
|
4c805fb327 | ||
|
|
906bb8821c | ||
|
|
df93dbe347 | ||
|
|
dc7562189b | ||
|
|
e88ef5b294 | ||
|
|
16b7e85256 | ||
|
|
5e403d3a9e | ||
|
|
d058c0aa2d | ||
|
|
ba081d64ad | ||
|
|
24005e3fc8 | ||
|
|
9f3942d7da | ||
|
|
536b4e9055 | ||
|
|
4d1d499c93 | ||
|
|
f5c5eff3ac | ||
|
|
2e0c6d5274 | ||
|
|
39e4b85273 | ||
|
|
0b4e84b9fa | ||
|
|
72105666cc | ||
|
|
2e3c122462 | ||
|
|
80935a2dd1 | ||
|
|
a858dae20b | ||
|
|
2e431a0269 | ||
|
|
66aee1bffd | ||
|
|
0c2bf6870b | ||
|
|
e73b8cf171 | ||
|
|
0c4d68e9d3 | ||
|
|
92270f582a | ||
|
|
9aabebd644 | ||
|
|
859ee9904d | ||
|
|
d02b54085e | ||
|
|
65be48a14d | ||
|
|
0b44adf551 | ||
|
|
eed4a950c9 | ||
|
|
cc932a5f4f | ||
|
|
d4c89df601 | ||
|
|
f707f6254e | ||
|
|
de933a68c9 | ||
|
|
24a9f95cc7 | ||
|
|
6469ab5f95 | ||
|
|
c31545351a | ||
|
|
51e5bc3e2b | ||
|
|
6759a50bee | ||
|
|
71e92a7e76 | ||
|
|
ac25f3c738 | ||
|
|
52d0dbb2be | ||
|
|
83b3cf6135 | ||
|
|
b67b876b00 | ||
|
|
e1b7bdb35f | ||
|
|
198ccc5ef7 | ||
|
|
04665c8c53 | ||
|
|
89ef686546 | ||
|
|
dc1cc77ee1 | ||
|
|
f5a1cdad2f | ||
|
|
9c1dbcdaae | ||
|
|
26e80c6ce3 | ||
|
|
de9aee8f40 | ||
|
|
d34464593c | ||
|
|
1ece7ebe50 | ||
|
|
97c6bd5ab2 | ||
|
|
81d367bd8e | ||
|
|
ea99db7d72 | ||
|
|
8ecd7566c1 | ||
|
|
ede5871cf4 | ||
|
|
9aebc4d976 | ||
|
|
0a1ef150c6 | ||
|
|
e1b2fd5044 | ||
|
|
f84e54517f | ||
|
|
71a1aa5b16 | ||
|
|
f0b2dfce6e | ||
|
|
9e842e5fbf | ||
|
|
7becca83ae | ||
|
|
7b333eb352 | ||
|
|
7091299de5 | ||
|
|
09172e9a55 | ||
|
|
371a47ca11 | ||
|
|
e5c6960b4c | ||
|
|
193303a325 | ||
|
|
836a7f5d83 | ||
|
|
b4b633eee3 | ||
|
|
2f6e4e7e77 | ||
|
|
a43981fa66 | ||
|
|
8b344dd8d1 | ||
|
|
ddb1105a94 | ||
|
|
6b3e0923fe | ||
|
|
8a19de4672 | ||
|
|
247d1b34cf | ||
|
|
3e11f1d3ec | ||
|
|
b860ff8901 | ||
|
|
9c2ee8320e | ||
|
|
c83f2f3676 | ||
|
|
b9eb55b772 | ||
|
|
c7a7fced41 | ||
|
|
91d1b65920 | ||
|
|
59ddf2a823 | ||
|
|
befd8b59ee | ||
|
|
aa51979592 | ||
|
|
5a9b52e92e | ||
|
|
4222758324 | ||
|
|
edd7032cb8 | ||
|
|
eff31afd33 | ||
|
|
d1fc208e4a | ||
|
|
628cab0f6b | ||
|
|
bf1f0a52c0 | ||
|
|
61aecc92ea | ||
|
|
a652fe9076 | ||
|
|
92f4fdc568 | ||
|
|
c64c29821a | ||
|
|
5b65e14c2b | ||
|
|
3f8f6177d4 | ||
|
|
3a46b78994 | ||
|
|
d14f767381 | ||
|
|
355e027ea6 | ||
|
|
f4e851d9a1 | ||
|
|
5524c9c6dc | ||
|
|
566e0f75a4 | ||
|
|
daf387861d | ||
|
|
c0b9f1c147 | ||
|
|
978c6a30f6 | ||
|
|
96719bdf52 | ||
|
|
561e9f35ae | ||
|
|
201b3f4014 | ||
|
|
b3e173fb8f | ||
|
|
9f6032f250 | ||
|
|
6362cff7cb | ||
|
|
0ae1c170c2 | ||
|
|
6afe7337a9 | ||
|
|
831ef65a41 | ||
|
|
797271e2e2 | ||
|
|
f268d81e16 | ||
|
|
1b28bd68f6 | ||
|
|
798dee27d0 | ||
|
|
463bf7ab3a | ||
|
|
e4d7bb4869 | ||
|
|
80f1173652 | ||
|
|
be7b9c05a5 | ||
|
|
31897bf974 | ||
|
|
09a2d3b8b7 | ||
|
|
f80e555a58 | ||
|
|
59a618ddcb | ||
|
|
1cbc9e252c | ||
|
|
9e98bd3283 | ||
|
|
15ca31370d | ||
|
|
d9a6000a33 | ||
|
|
daaf88b8e4 | ||
|
|
c15b581131 | ||
|
|
67973728ef | ||
|
|
3d7bdfc52e | ||
|
|
02c8c2cb67 | ||
|
|
53c386e2e5 | ||
|
|
196ce5af40 | ||
|
|
299e88b9cf | ||
|
|
0302b8317b | ||
|
|
cc7fa6af83 | ||
|
|
5dd09b13b1 | ||
|
|
77c7b094a8 | ||
|
|
2fdf6e1029 | ||
|
|
c3f1e631a0 | ||
|
|
9c8b670886 | ||
|
|
d0cb59c000 | ||
|
|
9dbbdcc555 | ||
|
|
8b3e0422ee | ||
|
|
13e26ff18a | ||
|
|
183cbc1981 | ||
|
|
25ca8cec4d | ||
|
|
01261e7f79 | ||
|
|
e598524b40 | ||
|
|
582d9b4187 | ||
|
|
babba4e6db | ||
|
|
419db64fc9 | ||
|
|
f386601cbd | ||
|
|
12d5a83ba3 | ||
|
|
cc17c3338e | ||
|
|
3460e29107 | ||
|
|
e11051a29a | ||
|
|
debba2ab37 | ||
|
|
91b7c3bfa4 | ||
|
|
8c15365133 | ||
|
|
b4db2b1bdd | ||
|
|
5570b9f05f | ||
|
|
370aa7b515 | ||
|
|
e8c2e494d0 | ||
|
|
434e12666b | ||
|
|
1de89153d8 | ||
|
|
22192a2e92 | ||
|
|
73b6464998 | ||
|
|
3f571c6117 | ||
|
|
4126084dfd | ||
|
|
5529d1b741 | ||
|
|
3555acbe78 | ||
|
|
331ece741f | ||
|
|
039f853d80 | ||
|
|
38a0f49595 | ||
|
|
f82ac7a7b8 | ||
|
|
a8ff5b5174 | ||
|
|
3b1b621d00 | ||
|
|
200ea62f56 | ||
|
|
158f68070f | ||
|
|
aea35df988 | ||
|
|
73b44d198e | ||
|
|
3b75ffdc41 | ||
|
|
bf8210d750 | ||
|
|
033212c201 | ||
|
|
dc5df2cd10 | ||
|
|
ca2f281a30 | ||
|
|
85ba9b7831 | ||
|
|
1f3129fe96 | ||
|
|
b0c241a59f | ||
|
|
780e32bc2b | ||
|
|
133e0983d6 | ||
|
|
1f24cccd66 | ||
|
|
3774fafb26 | ||
|
|
c0842ec113 | ||
|
|
2560cba9ef | ||
|
|
9b470429de | ||
|
|
e8dba6264f | ||
|
|
bb47a1e0f8 | ||
|
|
7e9f77117f | ||
|
|
ca7ac43cff | ||
|
|
471ec0350c | ||
|
|
90077b6dd0 | ||
|
|
5fc0a55f54 | ||
|
|
3ad2a40e89 | ||
|
|
59da2e16b2 | ||
|
|
a9a5cf0a76 | ||
|
|
e9f899cb9a | ||
|
|
8b976833a3 | ||
|
|
fc5493e8bd | ||
|
|
ecef4d2a92 | ||
|
|
2771367dc4 | ||
|
|
e0ca160360 | ||
|
|
94d6c1bc92 | ||
|
|
f58c96fea5 | ||
|
|
f4336b504a | ||
|
|
acd2d77e47 | ||
|
|
c0aaba9f65 | ||
|
|
58a37e72e7 | ||
|
|
589cdc40e3 | ||
|
|
b77826f3c5 | ||
|
|
46b04f1163 | ||
|
|
31ddd0798a | ||
|
|
813c330c47 | ||
|
|
6205f0bde0 | ||
|
|
c28709827f | ||
|
|
ce7f4c0e6e | ||
|
|
362502cf6f | ||
|
|
5d29af3b45 | ||
|
|
a13af69de5 | ||
|
|
653c181312 | ||
|
|
8dca9051f1 | ||
|
|
6f56b69264 | ||
|
|
64b8f0a1a9 | ||
|
|
6fd6eecbf5 | ||
|
|
74d4a6bcb1 | ||
|
|
47eaef468f | ||
|
|
1d68c34be6 | ||
|
|
9fb67310bb | ||
|
|
f23fc83d3c | ||
|
|
8138c36343 | ||
|
|
9ffaaf9bbf | ||
|
|
52bced424f | ||
|
|
ba1cd2777a | ||
|
|
f0da095552 | ||
|
|
ccaf562c9e | ||
|
|
973162c444 | ||
|
|
be261c6cdb | ||
|
|
27da9c1852 | ||
|
|
779311d064 | ||
|
|
25d6d17296 | ||
|
|
ab48ff0471 | ||
|
|
1078ae03a0 | ||
|
|
a4aa758043 | ||
|
|
9536746fb7 | ||
|
|
b088180464 | ||
|
|
211524cb0b | ||
|
|
c7b8b7c49e | ||
|
|
105cfa8d4e | ||
|
|
eab1b44f66 | ||
|
|
668c932631 | ||
|
|
5fb20a52a7 | ||
|
|
73b74581f8 | ||
|
|
9ac9396aec | ||
|
|
3df9f708b3 | ||
|
|
dc65a46fb8 | ||
|
|
71cb4c3ddc | ||
|
|
0a556bb646 | ||
|
|
c8e1950522 | ||
|
|
32bbc07e36 | ||
|
|
c76a00fc3f | ||
|
|
ce4d0d76ea | ||
|
|
df844dc408 | ||
|
|
10276e8b3f | ||
|
|
ea1c063d98 | ||
|
|
83249e69f3 | ||
|
|
b612b939c9 | ||
|
|
0ffc67792c | ||
|
|
37d0ef3362 | ||
|
|
62e5e42707 | ||
|
|
f8af042811 | ||
|
|
45d85a71c6 | ||
|
|
cd76512659 | ||
|
|
9a4e7319df | ||
|
|
c6ed81fa8d | ||
|
|
ba4cd97d16 | ||
|
|
bc2d2297bb | ||
|
|
731233c96d | ||
|
|
bcb5840537 | ||
|
|
0bf1c40eff | ||
|
|
48eeb4e80d | ||
|
|
64552fe7e1 | ||
|
|
c7b5831a0f | ||
|
|
70a8efc843 | ||
|
|
b98bd90d50 | ||
|
|
c985ef172d | ||
|
|
83a320a2e7 | ||
|
|
cc82a2711b | ||
|
|
7b83294590 | ||
|
|
4e4a24b769 | ||
|
|
9fbf6d4bd6 | ||
|
|
f27b737038 | ||
|
|
5246ecfc88 | ||
|
|
d1c3bb2616 | ||
|
|
e9f8b7d91f | ||
|
|
bdd5e63529 | ||
|
|
7584bd0d71 | ||
|
|
60fadb253a | ||
|
|
de635e3c51 | ||
|
|
6c0638c73b | ||
|
|
238ca62ba7 | ||
|
|
d8aba3a35f | ||
|
|
8a518c8b2a | ||
|
|
bc7f81c1a3 | ||
|
|
c8d735de6c | ||
|
|
f4e1fa28b8 | ||
|
|
262d836dd7 | ||
|
|
01c2ba2e4a | ||
|
|
3b59308282 | ||
|
|
b6714c645e | ||
|
|
0b60e82266 | ||
|
|
321354529b | ||
|
|
3efb17e91e | ||
|
|
df1bc0e9bb | ||
|
|
ac843d39ea | ||
|
|
dbfdb54315 | ||
|
|
2816e84f0a | ||
|
|
5b7581ebbe | ||
|
|
62bcdf3144 | ||
|
|
9746d89cc1 | ||
|
|
74fb054040 | ||
|
|
527836a90b | ||
|
|
481147c946 | ||
|
|
3babb33f8d | ||
|
|
1192e16278 | ||
|
|
e59d479947 | ||
|
|
2980eff805 | ||
|
|
72c0af5b6c | ||
|
|
deaf35707d | ||
|
|
73b9705410 | ||
|
|
782ed12244 | ||
|
|
696d14167c | ||
|
|
e9eb799464 | ||
|
|
05846ed29d | ||
|
|
d80634642c | ||
|
|
d7f32a57ff | ||
|
|
51267f256e | ||
|
|
860b62a6e8 | ||
|
|
c952505526 | ||
|
|
1f8ac107e9 | ||
|
|
eceac1e727 | ||
|
|
4202298422 | ||
|
|
43e50b713e | ||
|
|
f7544b729c | ||
|
|
b84530821e | ||
|
|
c808815ec8 | ||
|
|
280d309a52 | ||
|
|
a8d7b8e946 | ||
|
|
7a494abea9 | ||
|
|
48ff68512a | ||
|
|
deb6e37cc1 | ||
|
|
5257b22135 | ||
|
|
b376d5b622 | ||
|
|
484416b503 | ||
|
|
8ed42c8bbb | ||
|
|
91a6f77ff0 | ||
|
|
84975a5d82 | ||
|
|
74319df2e3 | ||
|
|
a9c179db7a | ||
|
|
e85d53f2a9 | ||
|
|
06e93668ee | ||
|
|
a837cef3cb | ||
|
|
5ee52d2f84 | ||
|
|
f1ceee1f2e | ||
|
|
8e7269d985 | ||
|
|
829d1bc084 | ||
|
|
10e0cd619d | ||
|
|
c8e64f208a | ||
|
|
5ddc0fd901 | ||
|
|
9bd8c6d3d3 | ||
|
|
11100c4ba2 | ||
|
|
7bc072c9ad | ||
|
|
e2530833e3 | ||
|
|
cd3f2412e8 | ||
|
|
e8a20cfe9e | ||
|
|
071a87177a | ||
|
|
c1fda41fad | ||
|
|
fa275b0bf9 | ||
|
|
1e3cd35cb8 | ||
|
|
4425f13430 | ||
|
|
5c2f616c73 | ||
|
|
04d71a1820 | ||
|
|
11062f83ae | ||
|
|
0925ea6c7e | ||
|
|
fe1e0b52c7 | ||
|
|
ac8f4ab678 | ||
|
|
72d1aadea1 | ||
|
|
4ca8dcf79d | ||
|
|
db8ee3471b | ||
|
|
c06b78859a | ||
|
|
b319187151 | ||
|
|
4746338e8e | ||
|
|
5221251b5c | ||
|
|
e6520dd6a9 | ||
|
|
845a21da35 | ||
|
|
b41333d30f | ||
|
|
f9c24ba266 | ||
|
|
0d6eba5392 | ||
|
|
9abbd74a51 | ||
|
|
376639f6b8 | ||
|
|
f31151deaf | ||
|
|
22487904ab | ||
|
|
8b1b411989 | ||
|
|
70b6c4dfc9 | ||
|
|
03be72bb2b | ||
|
|
c4c36ea54b | ||
|
|
3d751c0da1 | ||
|
|
385ac04c1d | ||
|
|
7cdd33199e | ||
|
|
1a83ffdf01 | ||
|
|
44084dac5f | ||
|
|
28066ecfbd | ||
|
|
b2d028693d | ||
|
|
dfe6c58b04 | ||
|
|
644e360a63 | ||
|
|
8cee140196 | ||
|
|
c301703f89 | ||
|
|
57b7f112d0 | ||
|
|
e45d014b7b | ||
|
|
9dfacb8dd1 | ||
|
|
ddf44233e7 | ||
|
|
010d1298c0 | ||
|
|
729eca8095 | ||
|
|
aed32dd840 | ||
|
|
24c9d2b891 | ||
|
|
2062dd1086 | ||
|
|
3f580ef53e | ||
|
|
fb9ea51018 | ||
|
|
807c421248 | ||
|
|
8992072b95 | ||
|
|
41319a2885 | ||
|
|
c316ef35d9 | ||
|
|
86f9c3acce | ||
|
|
bacf705ee5 |
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:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install opencode
|
||||
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:
|
||||
push:
|
||||
branches-ignore:
|
||||
- production
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- production
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -14,6 +11,7 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -25,14 +23,29 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Generate SDK
|
||||
run: |
|
||||
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: Generate
|
||||
run: ./script/generate.ts
|
||||
|
||||
- name: Format
|
||||
run: ./script/format.ts
|
||||
env:
|
||||
CI: true
|
||||
PUSH_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }}
|
||||
- name: Commit and push
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "No changes to commit"
|
||||
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:
|
||||
release:
|
||||
types: [published] # fires only when a release is published
|
||||
types: [released] # fires when a draft release is published
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
|
||||
2
.github/workflows/opencode.yml
vendored
2
.github/workflows/opencode.yml
vendored
@@ -31,4 +31,4 @@ jobs:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_PERMISSION: '{"bash": "deny"}'
|
||||
with:
|
||||
model: opencode/claude-haiku-4-5
|
||||
model: opencode/claude-opus-4-5
|
||||
|
||||
102
.github/workflows/publish.yml
vendored
102
.github/workflows/publish.yml
vendored
@@ -21,7 +21,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -31,7 +31,7 @@ permissions:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'sst/opencode' && github.ref == 'refs/heads/dev'
|
||||
if: github.repository == 'sst/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
@@ -41,21 +41,9 @@ jobs:
|
||||
|
||||
- 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
|
||||
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
|
||||
uses: docker/login-action@v3
|
||||
@@ -64,14 +52,26 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
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
|
||||
id: publish
|
||||
run: ./script/publish.ts
|
||||
run: ./script/publish-start.ts
|
||||
env:
|
||||
OPENCODE_BUMP: ${{ inputs.bump }}
|
||||
OPENCODE_VERSION: ${{ inputs.version }}
|
||||
@@ -79,9 +79,16 @@ jobs:
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: false
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
outputs:
|
||||
releaseId: ${{ steps.publish.outputs.releaseId }}
|
||||
tagName: ${{ steps.publish.outputs.tagName }}
|
||||
release: ${{ steps.publish.outputs.release }}
|
||||
tag: ${{ steps.publish.outputs.tag }}
|
||||
version: ${{ steps.publish.outputs.version }}
|
||||
|
||||
publish-tauri:
|
||||
needs: publish
|
||||
@@ -98,11 +105,14 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-ubuntu-2404
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- host: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.publish.outputs.tag }}
|
||||
|
||||
- uses: apple-actions/import-codesign-certs@v2
|
||||
if: ${{ runner.os == 'macOS' }}
|
||||
@@ -141,29 +151,33 @@ jobs:
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: packages/tauri/src-tauri
|
||||
workspaces: packages/desktop/src-tauri
|
||||
shared-key: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
cd packages/tauri
|
||||
cd packages/desktop
|
||||
bun ./scripts/prepare.ts
|
||||
env:
|
||||
OPENCODE_BUMP: ${{ inputs.bump }}
|
||||
OPENCODE_VERSION: ${{ inputs.version }}
|
||||
OPENCODE_CHANNEL: latest
|
||||
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
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
|
||||
- run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage
|
||||
- name: Install tauri-cli from portable appimage branch
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: |
|
||||
cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
|
||||
echo "Installed tauri-cli version:"
|
||||
cargo tauri --version
|
||||
|
||||
- name: Build and upload artifacts
|
||||
timeout-minutes: 20
|
||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -177,11 +191,43 @@ jobs:
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||
with:
|
||||
projectPath: packages/tauri
|
||||
projectPath: packages/desktop
|
||||
uploadWorkflowArtifacts: true
|
||||
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
|
||||
releaseId: ${{ needs.publish.outputs.releaseId }}
|
||||
tagName: ${{ needs.publish.outputs.tagName }}
|
||||
releaseId: ${{ needs.publish.outputs.release }}
|
||||
tagName: ${{ needs.publish.outputs.tag }}
|
||||
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:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install opencode
|
||||
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)
|
||||
|
||||
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.
|
||||
\`\`\`
|
||||
|
||||
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
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
stats:
|
||||
if: github.repository == 'sst/opencode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
6
.github/workflows/sync-zed-extension.yml
vendored
6
.github/workflows/sync-zed-extension.yml
vendored
@@ -2,8 +2,8 @@ name: "sync-zed-extension"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# release:
|
||||
# types: [published]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
zed:
|
||||
@@ -31,4 +31,4 @@ jobs:
|
||||
run: |
|
||||
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}
|
||||
|
||||
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
|
||||
a.out
|
||||
target
|
||||
.scripts
|
||||
|
||||
@@ -6,6 +6,8 @@ You are an expert technical documentation writer
|
||||
|
||||
You are not verbose
|
||||
|
||||
Use a relaxed and friendly tone
|
||||
|
||||
The title of the page should be a word or a 2-3 word phrase
|
||||
|
||||
The description should be one short line, should not start with "The", should
|
||||
|
||||
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
|
||||
model: opencode/glm-4.6
|
||||
subtask: true
|
||||
---
|
||||
|
||||
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
|
||||
}
|
||||
@@ -10,4 +10,8 @@
|
||||
"options": {},
|
||||
},
|
||||
},
|
||||
"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
|
||||
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environment:
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
|
||||
@@ -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`
|
||||
|
||||
> [!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)
|
||||
|
||||
@@ -53,12 +53,12 @@ your debugger via that URL. Other methods can result in breakpoints being mapped
|
||||
|
||||
Caveats:
|
||||
|
||||
- `*.tsx` files won't have their breakpoints correctly mapped. This seems due to Bun currently not supporting source maps on code transformed
|
||||
via `BunPlugin`s (currently necessary due to our dependency on `@opentui/solid`). Currently, the best you can do in terms of debugging `*.tsx`
|
||||
files is writing a `debugger;` statement. Debugging facilities like stepping won't work, but at least you will be informed if a specific code
|
||||
is triggered.
|
||||
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
|
||||
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
|
||||
- If `spawn` does not work for you, you can debug the server separately:
|
||||
- Debug server: `bun run --inspect=ws://localhost:6499/ ./src/index.ts serve --port 4096`,
|
||||
then attach TUI with `opencode attach http://localhost:4096`
|
||||
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --conditions=browser ./src/index.ts`
|
||||
|
||||
Other tips and tricks:
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -30,13 +30,29 @@ scoop bucket add extras; scoop install extras/opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install opencode # macOS and 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
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 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
|
||||
|
||||
The install script respects the following priority order for the installation path:
|
||||
@@ -63,7 +79,7 @@ you can switch between these using the `Tab` key.
|
||||
- Asks permission before running bash commands
|
||||
- Ideal for exploring unfamiliar codebases or planning changes
|
||||
|
||||
Also, included is a **general** subagent for complex searches and multi-step tasks.
|
||||
Also, included is a **general** subagent for complex searches and multistep tasks.
|
||||
This is used internally and can be invoked using `@general` in messages.
|
||||
|
||||
Learn more about [agents](https://opencode.ai/docs/agents).
|
||||
@@ -78,11 +94,11 @@ If you're interested in contributing to OpenCode, please read our [contributing
|
||||
|
||||
### 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
|
||||
|
||||
#### How is this different than Claude Code?
|
||||
#### How is this different from Claude Code?
|
||||
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
|
||||
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)
|
||||
15
STATS.md
15
STATS.md
@@ -167,3 +167,18 @@
|
||||
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
|
||||
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
|
||||
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
|
||||
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
|
||||
| 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-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) |
|
||||
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
|
||||
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
|
||||
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
|
||||
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1765270179,
|
||||
"narHash": "sha256-g2a4MhRKu4ymR4xwo+I+auTknXt/+j37Lnf0Mvfl1rE=",
|
||||
"lastModified": 1766870016,
|
||||
"narHash": "sha256-fHmxAesa6XNqnIkcS6+nIHuEmgd/iZSP/VXxweiEuQw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "677fbe97984e7af3175b6c121f3c39ee5c8d62c9",
|
||||
"rev": "5c2bc52fb9f8c264ed6c93bd20afa2ff5e763dce",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -6,7 +6,7 @@ Mention `/opencode` in your comment, and opencode will execute tasks within your
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -14,7 +14,7 @@ Leave the following comment on a GitHub issue. `opencode` will read the entire t
|
||||
/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.
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ inputs:
|
||||
description: "Model to use"
|
||||
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:
|
||||
description: "Share the opencode session (defaults to true for public repos)"
|
||||
required: false
|
||||
@@ -17,6 +21,19 @@ inputs:
|
||||
description: "Custom prompt to override the default prompt"
|
||||
required: false
|
||||
|
||||
use_github_token:
|
||||
description: "Use GITHUB_TOKEN directly instead of OpenCode App token exchange. When true, skips OIDC and uses the GITHUB_TOKEN env var."
|
||||
required: 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:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -49,5 +66,9 @@ runs:
|
||||
run: opencode github run
|
||||
env:
|
||||
MODEL: ${{ inputs.model }}
|
||||
AGENT: ${{ inputs.agent }}
|
||||
SHARE: ${{ inputs.share }}
|
||||
PROMPT: ${{ inputs.prompt }}
|
||||
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}`
|
||||
}
|
||||
|
||||
function useEnvAgent() {
|
||||
return process.env["AGENT"] || undefined
|
||||
}
|
||||
|
||||
function useEnvShare() {
|
||||
const value = process.env["SHARE"]
|
||||
if (!value) return undefined
|
||||
@@ -570,24 +574,49 @@ async function subscribeSessionEvents() {
|
||||
}
|
||||
|
||||
async function summarize(response: string) {
|
||||
const payload = useContext().payload as IssueCommentEvent
|
||||
try {
|
||||
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
||||
} catch (e) {
|
||||
if (isScheduleEvent()) {
|
||||
return "Scheduled task changes"
|
||||
}
|
||||
const payload = useContext().payload as IssueCommentEvent
|
||||
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 = []) {
|
||||
console.log("Sending message to opencode...")
|
||||
const { providerID, modelID } = useEnvModel()
|
||||
const agent = await resolveAgent()
|
||||
|
||||
const chat = await client.session.chat<true>({
|
||||
path: session,
|
||||
body: {
|
||||
providerID,
|
||||
modelID,
|
||||
agent: "build",
|
||||
agent,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@octokit/graphql": "9.0.1",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,3 +44,12 @@ new sst.cloudflare.x.Astro("Web", {
|
||||
VITE_API_URL: api.url.apply((url) => url!),
|
||||
},
|
||||
})
|
||||
|
||||
new sst.cloudflare.StaticSite("WebApp", {
|
||||
domain: "app." + domain,
|
||||
path: "packages/app",
|
||||
build: {
|
||||
command: "bun turbo build",
|
||||
output: "./dist",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -102,6 +102,8 @@ const ZEN_MODELS = [
|
||||
new sst.Secret("ZEN_MODELS2"),
|
||||
new sst.Secret("ZEN_MODELS3"),
|
||||
new sst.Secret("ZEN_MODELS4"),
|
||||
new sst.Secret("ZEN_MODELS5"),
|
||||
new sst.Secret("ZEN_MODELS6"),
|
||||
]
|
||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||
@@ -117,6 +119,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
|
||||
////////////////
|
||||
|
||||
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_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
@@ -135,6 +138,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
path: "packages/console/app",
|
||||
link: [
|
||||
bucket,
|
||||
bucketNew,
|
||||
database,
|
||||
AUTH_API_URL,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { domain } from "./stage"
|
||||
|
||||
new sst.cloudflare.StaticSite("Desktop", {
|
||||
domain: "desktop." + domain,
|
||||
path: "packages/desktop",
|
||||
build: {
|
||||
command: "bun turbo build",
|
||||
output: "./dist",
|
||||
},
|
||||
})
|
||||
141
install
141
install
@@ -7,7 +7,51 @@ RED='\033[0;31m'
|
||||
ORANGE='\033[38;5;214m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
OpenCode Installer
|
||||
|
||||
Usage: install.sh [options]
|
||||
|
||||
Options:
|
||||
-h, --help Display this help message
|
||||
-v, --version <version> Install a specific version (e.g., 1.0.180)
|
||||
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
|
||||
|
||||
Examples:
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
|
||||
EOF
|
||||
}
|
||||
|
||||
requested_version=${VERSION:-}
|
||||
no_modify_path=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
-v|--version)
|
||||
if [[ -n "${2:-}" ]]; then
|
||||
requested_version="$2"
|
||||
shift 2
|
||||
else
|
||||
echo -e "${RED}Error: --version requires a version argument${NC}"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--no-modify-path)
|
||||
no_modify_path=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo -e "${ORANGE}Warning: Unknown option '$1'${NC}" >&2
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
raw_os=$(uname -s)
|
||||
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
||||
@@ -111,8 +155,18 @@ if [ -z "$requested_version" ]; then
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Strip leading 'v' if present
|
||||
requested_version="${requested_version#v}"
|
||||
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
|
||||
specific_version=$requested_version
|
||||
|
||||
# Verify the release exists before downloading
|
||||
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/sst/opencode/releases/tag/v${requested_version}")
|
||||
if [ "$http_status" = "404" ]; then
|
||||
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
|
||||
echo -e "${MUTED}Available releases: https://github.com/sst/opencode/releases${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
print_message() {
|
||||
@@ -240,22 +294,23 @@ download_with_progress() {
|
||||
|
||||
download_and_install() {
|
||||
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
|
||||
# Fallback to standard curl on Windows or if custom progress fails
|
||||
curl -# -L -o "$filename" "$url"
|
||||
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
|
||||
# Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
|
||||
curl -# -L -o "$tmp_dir/$filename" "$url"
|
||||
fi
|
||||
|
||||
if [ "$os" = "linux" ]; then
|
||||
tar -xzf "$filename"
|
||||
tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
|
||||
else
|
||||
unzip -q "$filename"
|
||||
unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
|
||||
fi
|
||||
|
||||
mv opencode "$INSTALL_DIR"
|
||||
mv "$tmp_dir/opencode" "$INSTALL_DIR"
|
||||
chmod 755 "${INSTALL_DIR}/opencode"
|
||||
cd .. && rm -rf opencodetmp
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
check_version
|
||||
@@ -303,42 +358,42 @@ case $current_shell in
|
||||
;;
|
||||
esac
|
||||
|
||||
config_file=""
|
||||
for file in $config_files; do
|
||||
if [[ -f $file ]]; then
|
||||
config_file=$file
|
||||
break
|
||||
if [[ "$no_modify_path" != "true" ]]; then
|
||||
config_file=""
|
||||
for file in $config_files; do
|
||||
if [[ -f $file ]]; then
|
||||
config_file=$file
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z $config_file ]]; then
|
||||
print_message warning "No config file found for $current_shell. You may need to manually add to PATH:"
|
||||
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
||||
elif [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
|
||||
case $current_shell in
|
||||
fish)
|
||||
add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
|
||||
;;
|
||||
zsh)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
bash)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
ash)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
sh)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
*)
|
||||
export PATH=$INSTALL_DIR:$PATH
|
||||
print_message warning "Manually add the directory to $config_file (or similar):"
|
||||
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z $config_file ]]; then
|
||||
print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
|
||||
case $current_shell in
|
||||
fish)
|
||||
add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
|
||||
;;
|
||||
zsh)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
bash)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
ash)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
sh)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
*)
|
||||
export PATH=$INSTALL_DIR:$PATH
|
||||
print_message warning "Manually add the directory to $config_file (or similar):"
|
||||
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"nodeModules": "sha256-3GaqUwomnIUW8MqUi1jDVPHQ/C5Z+D9wMR//tAGxvSQ="
|
||||
"nodeModules": "sha256-d10pu1tpwBbZ4YZqG0WcaK6W1+K2+Q0wdPjuI1rVIpY="
|
||||
}
|
||||
|
||||
14
package.json
14
package.json
@@ -4,7 +4,7 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.3",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
@@ -20,7 +20,8 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@types/bun": "1.3.3",
|
||||
"@types/bun": "1.3.4",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -30,7 +31,8 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/precision-diffs": "0.6.1",
|
||||
"@pierre/diffs": "1.0.2",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"ai": "5.0.97",
|
||||
@@ -38,10 +40,13 @@
|
||||
"hono-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "17.0.1",
|
||||
"marked-shiki": "1.2.1",
|
||||
"typescript": "5.8.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"shiki": "3.20.0",
|
||||
"solid-list": "0.3.0",
|
||||
"tailwindcss": "4.1.11",
|
||||
"virtua": "0.42.3",
|
||||
@@ -54,6 +59,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"husky": "9.1.7",
|
||||
"prettier": "3.6.2",
|
||||
@@ -62,6 +68,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"typescript": "catalog:"
|
||||
@@ -78,7 +85,6 @@
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"sharp",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"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.)
|
||||
@@ -14,7 +14,7 @@
|
||||
<meta property="og:image" content="/social-share.png" />
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
</head>
|
||||
<body class="antialiased overscroll-none select-none text-12-regular">
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<script>
|
||||
;(function () {
|
||||
const savedTheme = localStorage.getItem("theme") || "oc-1"
|
||||
@@ -22,7 +22,7 @@
|
||||
})()
|
||||
</script>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
<div id="root" class="flex flex-col h-screen"></div>
|
||||
<script src="/src/entry.tsx" type="module"></script>
|
||||
</body>
|
||||
</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.203",
|
||||
"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": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"virtua": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
17
packages/app/public/_headers
Normal file
17
packages/app/public/_headers
Normal file
@@ -0,0 +1,17 @@
|
||||
/assets/*.js
|
||||
Content-Type: application/javascript
|
||||
|
||||
/assets/*.mjs
|
||||
Content-Type: application/javascript
|
||||
|
||||
/assets/*.css
|
||||
Content-Type: text/css
|
||||
|
||||
/*.js
|
||||
Content-Type: application/javascript
|
||||
|
||||
/*.mjs
|
||||
Content-Type: application/javascript
|
||||
|
||||
/*.css
|
||||
Content-Type: text/css
|
||||
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 window.location.origin
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
383
packages/app/src/components/dialog-connect-provider.tsx
Normal file
383
packages/app/src/components/dialog-connect-provider.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
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 { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { DialogSelectModel } from "./dialog-select-model"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string }) {
|
||||
const dialog = useDialog()
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
||||
const methods = createMemo(
|
||||
() =>
|
||||
globalSync.data.provider_auth[props.provider] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
},
|
||||
],
|
||||
)
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||
|
||||
async function selectMethod(index: number) {
|
||||
const method = methods()[index]
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.methodIndex = index
|
||||
draft.authorization = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
}),
|
||||
)
|
||||
|
||||
if (method.type === "oauth") {
|
||||
setStore("state", "pending")
|
||||
const start = Date.now()
|
||||
await globalSDK.client.provider.oauth
|
||||
.authorize(
|
||||
{
|
||||
providerID: props.provider,
|
||||
method: index,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => {
|
||||
const elapsed = Date.now() - start
|
||||
const delay = 1000 - elapsed
|
||||
|
||||
if (delay > 0) {
|
||||
setTimeout(() => {
|
||||
setStore("state", "complete")
|
||||
setStore("authorization", x.data!)
|
||||
}, delay)
|
||||
return
|
||||
}
|
||||
setStore("state", "complete")
|
||||
setStore("authorization", x.data!)
|
||||
})
|
||||
.catch((e) => {
|
||||
setStore("state", "error")
|
||||
setStore("error", String(e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let listRef: ListRef | undefined
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
||||
return
|
||||
}
|
||||
if (e.key === "Escape") return
|
||||
listRef?.onKeyDown(e)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (methods().length === 1) {
|
||||
selectMethod(0)
|
||||
}
|
||||
document.addEventListener("keydown", handleKey)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKey)
|
||||
})
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
await globalSDK.client.global.dispose()
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: `${provider().name} connected`,
|
||||
description: `${provider().name} models are now available to use.`,
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (methods().length === 1) {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
return
|
||||
}
|
||||
if (store.authorization) {
|
||||
setStore("authorization", undefined)
|
||||
setStore("methodIndex", undefined)
|
||||
return
|
||||
}
|
||||
if (store.methodIndex) {
|
||||
setStore("methodIndex", undefined)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={<IconButton tabIndex={-1} icon="arrow-left" variant="ghost" onClick={goBack} />}>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">
|
||||
<Switch>
|
||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||
Login with Claude Pro/Max
|
||||
</Match>
|
||||
<Match when={true}>Connect {provider().name}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<Switch>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
|
||||
<div class="">
|
||||
<List
|
||||
ref={(ref) => {
|
||||
listRef = ref
|
||||
}}
|
||||
items={methods}
|
||||
key={(m) => m?.label}
|
||||
onSelect={async (method, index) => {
|
||||
if (!method) return
|
||||
selectMethod(index)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<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-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||
</div>
|
||||
<span>{i.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>Authorization in progress...</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>Authorization failed: {store.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "api"}>
|
||||
{iife(() => {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: "",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const apiKey = formData.get("apiKey") as string
|
||||
|
||||
if (!apiKey?.trim()) {
|
||||
setFormStore("error", "API key is required")
|
||||
return
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await globalSDK.client.auth.set({
|
||||
providerID: props.provider,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: apiKey,
|
||||
},
|
||||
})
|
||||
await complete()
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<Switch>
|
||||
<Match when={provider().id === "opencode"}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-14-regular text-text-base">
|
||||
OpenCode Zen gives you access to a curated set of reliable optimized models for coding
|
||||
agents.
|
||||
</div>
|
||||
<div class="text-14-regular text-text-base">
|
||||
With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.
|
||||
</div>
|
||||
<div class="text-14-regular text-text-base">
|
||||
Visit{" "}
|
||||
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
||||
opencode.ai/zen
|
||||
</Link>{" "}
|
||||
to collect your API key.
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
Enter your {provider().name} API key to connect your account and use {provider().name} models
|
||||
in OpenCode.
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label={`${provider().name} API key`}
|
||||
placeholder="API key"
|
||||
name="apiKey"
|
||||
value={formStore.value}
|
||||
onChange={setFormStore.bind(null, "value")}
|
||||
validationState={formStore.error ? "invalid" : undefined}
|
||||
error={formStore.error}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={store.authorization?.method === "code"}>
|
||||
{iife(() => {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: "",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
||||
platform.openLink(store.authorization.url)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const code = formData.get("code") as string
|
||||
|
||||
if (!code?.trim()) {
|
||||
setFormStore("error", "Authorization code is required")
|
||||
return
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const { error } = await globalSDK.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
code,
|
||||
})
|
||||
if (!error) {
|
||||
await complete()
|
||||
return
|
||||
}
|
||||
setFormStore("error", "Invalid authorization code")
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
Visit <Link href={store.authorization!.url}>this link</Link> to collect your authorization
|
||||
code to connect your account and use {provider().name} models in OpenCode.
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label={`${method()?.label} authorization code`}
|
||||
placeholder="Authorization code"
|
||||
name="code"
|
||||
value={formStore.value}
|
||||
onChange={setFormStore.bind(null, "value")}
|
||||
validationState={formStore.error ? "invalid" : undefined}
|
||||
error={formStore.error}
|
||||
/>
|
||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Match>
|
||||
<Match when={store.authorization?.method === "auto"}>
|
||||
{iife(() => {
|
||||
const code = createMemo(() => {
|
||||
const instructions = store.authorization?.instructions
|
||||
if (instructions?.includes(":")) {
|
||||
return instructions?.split(":")[1]?.trim()
|
||||
}
|
||||
return instructions
|
||||
})
|
||||
|
||||
onMount(async () => {
|
||||
const result = await globalSDK.client.provider.oauth.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
})
|
||||
if (result.error) {
|
||||
// TODO: show error
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
await complete()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
Visit <Link href={store.authorization!.url}>this link</Link> and enter the code below to
|
||||
connect your account and use {provider().name} models in OpenCode.
|
||||
</div>
|
||||
<TextField label="Confirmation code" class="font-mono" value={code()} readOnly copyable />
|
||||
<div class="text-14-regular text-text-base flex items-center gap-4">
|
||||
<Spinner />
|
||||
<span>Waiting for authorization...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
57
packages/app/src/components/dialog-manage-models.tsx
Normal file
57
packages/app/src/components/dialog-manage-models.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
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 = () => {
|
||||
const local = useLocal()
|
||||
return (
|
||||
<Dialog title="Manage models" description="Customize which models appear in the model selector.">
|
||||
<List
|
||||
search={{ placeholder: "Search models", autofocus: true }}
|
||||
emptyMessage="No model results"
|
||||
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
||||
items={local.model.list()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
groupBy={(x) => x.provider.name}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const aProvider = a.items[0].provider.id
|
||||
const bProvider = b.items[0].provider.id
|
||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
const visible = local.model.visible({
|
||||
modelID: x.id,
|
||||
providerID: x.provider.id,
|
||||
})
|
||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center justify-between gap-x-3">
|
||||
<span>{i.name}</span>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={
|
||||
!!local.model.visible({
|
||||
modelID: i.id,
|
||||
providerID: i.provider.id,
|
||||
})
|
||||
}
|
||||
onChange={(checked) => {
|
||||
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
48
packages/app/src/components/dialog-select-file.tsx
Normal file
48
packages/app/src/components/dialog-select-file.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
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 { createMemo } from "solid-js"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
|
||||
export function DialogSelectFile() {
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
return (
|
||||
<Dialog title="Select file">
|
||||
<List
|
||||
search={{ placeholder: "Search files", autofocus: true }}
|
||||
emptyMessage="No files found"
|
||||
items={local.file.searchFiles}
|
||||
key={(x) => x}
|
||||
onSelect={(path) => {
|
||||
if (path) {
|
||||
tabs().open("file://" + path)
|
||||
}
|
||||
dialog.close()
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
91
packages/app/src/components/dialog-select-mcp.tsx
Normal file
91
packages/app/src/components/dialog-select-mcp.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Component, createMemo, createSignal, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
|
||||
export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
.map(([name, status]) => ({ name, status: status.status }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const toggle = async (name: string) => {
|
||||
if (loading()) return
|
||||
setLoading(name)
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
} else {
|
||||
await sdk.client.mcp.connect({ name })
|
||||
}
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
setLoading(null)
|
||||
}
|
||||
|
||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||
const totalCount = createMemo(() => items().length)
|
||||
|
||||
return (
|
||||
<Dialog title="MCPs" description={`${enabledCount()} of ${totalCount()} enabled`}>
|
||||
<List
|
||||
search={{ placeholder: "Search", autofocus: true }}
|
||||
emptyMessage="No MCPs configured"
|
||||
key={(x) => x?.name ?? ""}
|
||||
items={items}
|
||||
filterKeys={["name", "status"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
onSelect={(x) => {
|
||||
if (x) toggle(x.name)
|
||||
}}
|
||||
>
|
||||
{(i) => {
|
||||
const mcpStatus = () => sync.data.mcp[i.name]
|
||||
const status = () => mcpStatus()?.status
|
||||
const error = () => {
|
||||
const s = mcpStatus()
|
||||
return s?.status === "failed" ? s.error : undefined
|
||||
}
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between gap-x-3">
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate">{i.name}</span>
|
||||
<Show when={status() === "connected"}>
|
||||
<span class="text-11-regular text-text-weaker">connected</span>
|
||||
</Show>
|
||||
<Show when={status() === "failed"}>
|
||||
<span class="text-11-regular text-text-weaker">failed</span>
|
||||
</Show>
|
||||
<Show when={status() === "needs_auth"}>
|
||||
<span class="text-11-regular text-text-weaker">needs auth</span>
|
||||
</Show>
|
||||
<Show when={status() === "disabled"}>
|
||||
<span class="text-11-regular text-text-weaker">disabled</span>
|
||||
</Show>
|
||||
<Show when={loading() === i.name}>
|
||||
<span class="text-11-regular text-text-weak">...</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={error()}>
|
||||
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
110
packages/app/src/components/dialog-select-model-unpaid.tsx
Normal file
110
packages/app/src/components/dialog-select-model-unpaid.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
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 { Tag } from "@opencode-ai/ui/tag"
|
||||
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 { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
export const DialogSelectModelUnpaid: Component = () => {
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
|
||||
let listRef: ListRef | undefined
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") return
|
||||
listRef?.onKeyDown(e)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKey)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKey)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog title="Select model">
|
||||
<div class="flex flex-col gap-3 px-2.5">
|
||||
<div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
|
||||
<List
|
||||
ref={(ref) => (listRef = ref)}
|
||||
items={local.model.list}
|
||||
current={local.model.current()}
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
onSelect={(x) => {
|
||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
dialog.close()
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2.5">
|
||||
<span>{i.name}</span>
|
||||
<Tag>Free</Tag>
|
||||
<Show when={i.latest}>
|
||||
<Tag>Latest</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
<div class="px-1.5 pb-1.5">
|
||||
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
|
||||
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
|
||||
<div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
|
||||
<div class="w-full">
|
||||
<List
|
||||
class="w-full px-0"
|
||||
key={(x) => x?.id}
|
||||
items={providers.popular}
|
||||
activeIcon="plus-small"
|
||||
sortBy={(a, b) => {
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
return a.name.localeCompare(b.name)
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-3">
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
||||
<span>{i.name}</span>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<Tag>Recommended</Tag>
|
||||
</Show>
|
||||
<Show when={i.id === "anthropic"}>
|
||||
<div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
||||
icon="dot-grid"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}}
|
||||
>
|
||||
View all providers
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
83
packages/app/src/components/dialog-select-model.tsx
Normal file
83
packages/app/src/components/dialog-select-model.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogManageModels } from "./dialog-manage-models"
|
||||
|
||||
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
|
||||
const models = createMemo(() =>
|
||||
local.model
|
||||
.list()
|
||||
.filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
|
||||
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Select model"
|
||||
action={
|
||||
<Button
|
||||
class="h-7 -my-1 text-14-medium"
|
||||
icon="plus-small"
|
||||
tabIndex={-1}
|
||||
onClick={() => dialog.show(() => <DialogSelectProvider />)}
|
||||
>
|
||||
Connect provider
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<List
|
||||
search={{ placeholder: "Search models", autofocus: true }}
|
||||
emptyMessage="No model results"
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
items={models}
|
||||
current={local.model.current()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
groupBy={(x) => x.provider.name}
|
||||
sortGroupsBy={(a, b) => {
|
||||
if (a.category === "Recent" && b.category !== "Recent") return -1
|
||||
if (b.category === "Recent" && a.category !== "Recent") return 1
|
||||
const aProvider = a.items[0].provider.id
|
||||
const bProvider = b.items[0].provider.id
|
||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
dialog.close()
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-3">
|
||||
<span>{i.name}</span>
|
||||
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
||||
<Tag>Free</Tag>
|
||||
</Show>
|
||||
<Show when={i.latest}>
|
||||
<Tag>Latest</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
||||
onClick={() => dialog.show(() => <DialogManageModels />)}
|
||||
>
|
||||
Manage models
|
||||
</Button>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
54
packages/app/src/components/dialog-select-provider.tsx
Normal file
54
packages/app/src/components/dialog-select-provider.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Component, Show } from "solid-js"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
|
||||
export const DialogSelectProvider: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
|
||||
return (
|
||||
<Dialog title="Connect provider">
|
||||
<List
|
||||
search={{ placeholder: "Search providers", autofocus: true }}
|
||||
activeIcon="plus-small"
|
||||
key={(x) => x?.id}
|
||||
items={providers.all}
|
||||
filterKeys={["id", "name"]}
|
||||
groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
|
||||
sortBy={(a, b) => {
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
return a.name.localeCompare(b.name)
|
||||
}}
|
||||
sortGroupsBy={(a, b) => {
|
||||
if (a.category === "Popular" && b.category !== "Popular") return -1
|
||||
if (b.category === "Popular" && a.category !== "Popular") return 1
|
||||
return 0
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
||||
<span>{i.name}</span>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<Tag>Recommended</Tag>
|
||||
</Show>
|
||||
<Show when={i.id === "anthropic"}>
|
||||
<div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useLocal, type LocalFile } from "@/context/local"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
|
||||
import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
||||
export default function FileTree(props: {
|
||||
@@ -57,14 +57,14 @@ export default function FileTree(props: {
|
||||
"text-text-muted/40": p.node.ignored,
|
||||
"text-text-muted/80": !p.node.ignored,
|
||||
// "!text-text": local.file.active()?.path === p.node.path,
|
||||
"!text-primary": local.file.changed(p.node.path),
|
||||
// "!text-primary": local.file.changed(p.node.path),
|
||||
}}
|
||||
>
|
||||
{p.node.name}
|
||||
</span>
|
||||
<Show when={local.file.changed(p.node.path)}>
|
||||
<span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" />
|
||||
</Show>
|
||||
{/* <Show when={local.file.changed(p.node.path)}> */}
|
||||
{/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
|
||||
{/* </Show> */}
|
||||
</Dynamic>
|
||||
)
|
||||
|
||||
211
packages/app/src/components/header.tsx
Normal file
211
packages/app/src/components/header.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
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">
|
||||
<Show when={currentSession()?.summary?.files}>
|
||||
<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
|
||||
name={layout.review.opened() ? "layout-right" : "layout-left"}
|
||||
size="small"
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
|
||||
size="small"
|
||||
class="hidden group-hover/review-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
|
||||
size="small"
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
1177
packages/app/src/components/prompt-input.tsx
Normal file
1177
packages/app/src/components/prompt-input.tsx
Normal file
File diff suppressed because it is too large
Load Diff
63
packages/app/src/components/session-context-usage.tsx
Normal file
63
packages/app/src/components/session-context-usage.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
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
|
||||
value={
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex gap-3">
|
||||
<span class="opacity-70 text-right flex-1">Tokens</span>
|
||||
<span class="text-left flex-1">{ctx().tokens}</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<span class="opacity-70 text-right flex-1">Usage</span>
|
||||
<span class="text-left flex-1">{ctx().percentage ?? 0}%</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<span class="opacity-70 text-right flex-1">Cost</span>
|
||||
<span class="text-left flex-1">{cost()}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
|
||||
{/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
40
packages/app/src/components/session-lsp-indicator.tsx
Normal file
40
packages/app/src/components/session-lsp-indicator.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
|
||||
export function SessionLspIndicator() {
|
||||
const sync = useSync()
|
||||
|
||||
const lspStats = createMemo(() => {
|
||||
const lsp = sync.data.lsp ?? []
|
||||
const connected = lsp.filter((s) => s.status === "connected").length
|
||||
const hasError = lsp.some((s) => s.status === "error")
|
||||
const total = lsp.length
|
||||
return { connected, hasError, total }
|
||||
})
|
||||
|
||||
const tooltipContent = createMemo(() => {
|
||||
const lsp = sync.data.lsp ?? []
|
||||
if (lsp.length === 0) return "No LSP servers"
|
||||
return lsp.map((s) => s.name).join(", ")
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={lspStats().total > 0}>
|
||||
<Tooltip placement="top" value={tooltipContent()}>
|
||||
<div class="flex items-center gap-1 px-2 cursor-default select-none">
|
||||
<Icon
|
||||
name="code"
|
||||
size="small"
|
||||
classList={{
|
||||
"text-icon-critical-base": lspStats().hasError,
|
||||
"text-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
|
||||
}}
|
||||
/>
|
||||
<span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
36
packages/app/src/components/session-mcp-indicator.tsx
Normal file
36
packages/app/src/components/session-mcp-indicator.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||
|
||||
export function SessionMcpIndicator() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
|
||||
const mcpStats = createMemo(() => {
|
||||
const mcp = sync.data.mcp ?? {}
|
||||
const entries = Object.entries(mcp)
|
||||
const enabled = entries.filter(([, status]) => status.status === "connected").length
|
||||
const failed = entries.some(([, status]) => status.status === "failed")
|
||||
const total = entries.length
|
||||
return { enabled, failed, total }
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={mcpStats().total > 0}>
|
||||
<Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
|
||||
<Icon
|
||||
name="mcp"
|
||||
size="small"
|
||||
classList={{
|
||||
"text-icon-critical-base": mcpStats().failed,
|
||||
"text-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
|
||||
}}
|
||||
/>
|
||||
<span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
|
||||
</Button>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
14
packages/app/src/components/status-bar.tsx
Normal file
14
packages/app/src/components/status-bar.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Show, type ParentProps } from "solid-js"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
export function StatusBar(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
return (
|
||||
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
|
||||
<Show when={platform.version}>
|
||||
<span class="text-12-regular text-text-weak">v{platform.version}</span>
|
||||
</Show>
|
||||
<div class="flex items-center">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||
import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
import { LocalPTY } from "@/context/session"
|
||||
import { LocalPTY } from "@/context/terminal"
|
||||
import { usePrefersDark } from "@solid-primitives/media"
|
||||
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
@@ -21,6 +22,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
let serializeAddon: SerializeAddon
|
||||
let fitAddon: FitAddon
|
||||
let handleResize: () => void
|
||||
const prefersDark = usePrefersDark()
|
||||
|
||||
onMount(async () => {
|
||||
ghostty = await Ghostty.load()
|
||||
@@ -29,12 +31,19 @@ export const Terminal = (props: TerminalProps) => {
|
||||
term = new Term({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: "TX-02, monospace",
|
||||
fontFamily: "IBM Plex Mono, monospace",
|
||||
allowTransparency: true,
|
||||
theme: {
|
||||
background: "#191515",
|
||||
foreground: "#d4d4d4",
|
||||
},
|
||||
theme: prefersDark()
|
||||
? {
|
||||
background: "#191515",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
}
|
||||
: {
|
||||
background: "#fcfcfc",
|
||||
foreground: "#211e1e",
|
||||
cursor: "#211e1e",
|
||||
},
|
||||
scrollback: 10_000,
|
||||
ghostty,
|
||||
})
|
||||
@@ -139,6 +148,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
<div
|
||||
ref={container}
|
||||
data-component="terminal"
|
||||
data-prevent-autofocus
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
"size-full px-6 py-3 font-mono": true,
|
||||
243
packages/app/src/context/command.tsx
Normal file
243
packages/app/src/context/command.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
export type KeybindConfig = string
|
||||
|
||||
export interface Keybind {
|
||||
key: string
|
||||
ctrl: boolean
|
||||
meta: boolean
|
||||
shift: boolean
|
||||
alt: boolean
|
||||
}
|
||||
|
||||
export interface CommandOption {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
category?: string
|
||||
keybind?: KeybindConfig
|
||||
slash?: string
|
||||
suggested?: boolean
|
||||
disabled?: boolean
|
||||
onSelect?: (source?: "palette" | "keybind" | "slash") => void
|
||||
}
|
||||
|
||||
export function parseKeybind(config: string): Keybind[] {
|
||||
if (!config || config === "none") return []
|
||||
|
||||
return config.split(",").map((combo) => {
|
||||
const parts = combo.trim().toLowerCase().split("+")
|
||||
const keybind: Keybind = {
|
||||
key: "",
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
switch (part) {
|
||||
case "ctrl":
|
||||
case "control":
|
||||
keybind.ctrl = true
|
||||
break
|
||||
case "meta":
|
||||
case "cmd":
|
||||
case "command":
|
||||
keybind.meta = true
|
||||
break
|
||||
case "mod":
|
||||
if (IS_MAC) keybind.meta = true
|
||||
else keybind.ctrl = true
|
||||
break
|
||||
case "alt":
|
||||
case "option":
|
||||
keybind.alt = true
|
||||
break
|
||||
case "shift":
|
||||
keybind.shift = true
|
||||
break
|
||||
default:
|
||||
keybind.key = part
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return keybind
|
||||
})
|
||||
}
|
||||
|
||||
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
|
||||
const eventKey = event.key.toLowerCase()
|
||||
|
||||
for (const kb of keybinds) {
|
||||
const keyMatch = kb.key === eventKey
|
||||
const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
|
||||
const metaMatch = kb.meta === (event.metaKey || false)
|
||||
const shiftMatch = kb.shift === (event.shiftKey || false)
|
||||
const altMatch = kb.alt === (event.altKey || false)
|
||||
|
||||
if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function formatKeybind(config: string): string {
|
||||
if (!config || config === "none") return ""
|
||||
|
||||
const keybinds = parseKeybind(config)
|
||||
if (keybinds.length === 0) return ""
|
||||
|
||||
const kb = keybinds[0]
|
||||
const parts: string[] = []
|
||||
|
||||
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
|
||||
if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
|
||||
if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
|
||||
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
|
||||
|
||||
if (kb.key) {
|
||||
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
|
||||
parts.push(displayKey)
|
||||
}
|
||||
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
function DialogCommand(props: { options: CommandOption[] }) {
|
||||
const dialog = useDialog()
|
||||
|
||||
return (
|
||||
<Dialog title="Commands">
|
||||
<List
|
||||
search={{ placeholder: "Search commands", autofocus: true }}
|
||||
emptyMessage="No commands found"
|
||||
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
|
||||
key={(x) => x?.id}
|
||||
filterKeys={["title", "description", "category"]}
|
||||
groupBy={(x) => x.category ?? ""}
|
||||
onSelect={(option) => {
|
||||
if (option) {
|
||||
dialog.close()
|
||||
option.onSelect?.("palette")
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(option) => (
|
||||
<div class="w-full flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
|
||||
<Show when={option.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{option.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={option.keybind}>
|
||||
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
name: "Command",
|
||||
init: () => {
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() => {
|
||||
const all = registrations().flatMap((x) => x())
|
||||
const suggested = all.filter((x) => x.suggested && !x.disabled)
|
||||
return [
|
||||
...suggested.map((x) => ({
|
||||
...x,
|
||||
id: "suggested." + x.id,
|
||||
category: "Suggested",
|
||||
})),
|
||||
...all,
|
||||
]
|
||||
})
|
||||
|
||||
const suspended = () => suspendCount() > 0
|
||||
|
||||
const showPalette = () => {
|
||||
if (!dialog.active) {
|
||||
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (suspended()) return
|
||||
|
||||
const paletteKeybinds = parseKeybind("mod+shift+p")
|
||||
if (matchKeybind(paletteKeybinds, event)) {
|
||||
event.preventDefault()
|
||||
showPalette()
|
||||
return
|
||||
}
|
||||
|
||||
for (const option of options()) {
|
||||
if (option.disabled) continue
|
||||
if (!option.keybind) continue
|
||||
|
||||
const keybinds = parseKeybind(option.keybind)
|
||||
if (matchKeybind(keybinds, event)) {
|
||||
event.preventDefault()
|
||||
option.onSelect?.("keybind")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
return {
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
setRegistrations((arr) => [results, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||
})
|
||||
},
|
||||
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
||||
for (const option of options()) {
|
||||
if (option.id === id || option.id === "suggested." + id) {
|
||||
option.onSelect?.(source)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
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,
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||
},
|
||||
suspended,
|
||||
get options() {
|
||||
return options()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,30 +1,32 @@
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||
name: "GlobalSDK",
|
||||
init: (props: { url: string }) => {
|
||||
const abort = new AbortController()
|
||||
const sdk = createOpencodeClient({
|
||||
const eventSdk = createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: abort.signal,
|
||||
// signal: AbortSignal.timeout(1000 * 60 * 10),
|
||||
})
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
}>()
|
||||
|
||||
sdk.global.event().then(async (events) => {
|
||||
eventSdk.global.event().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
// console.log("event", event)
|
||||
emitter.emit(event.directory ?? "global", event.payload)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
const platform = usePlatform()
|
||||
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 }
|
||||
402
packages/app/src/context/global-sync.tsx
Normal file
402
packages/app/src/context/global-sync.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import {
|
||||
type Message,
|
||||
type Agent,
|
||||
type Session,
|
||||
type Part,
|
||||
type Config,
|
||||
type Path,
|
||||
type Project,
|
||||
type FileDiff,
|
||||
type Todo,
|
||||
type SessionStatus,
|
||||
type ProviderListResponse,
|
||||
type ProviderAuthResponse,
|
||||
type Command,
|
||||
type McpStatus,
|
||||
type LspStatus,
|
||||
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[]
|
||||
}
|
||||
mcp: {
|
||||
[name: string]: McpStatus
|
||||
}
|
||||
lsp: LspStatus[]
|
||||
limit: number
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
}
|
||||
|
||||
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: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
})
|
||||
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) => {
|
||||
const data = x.data!
|
||||
setStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
})
|
||||
}),
|
||||
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!)),
|
||||
mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
|
||||
lsp: () => sdk.lsp.status().then((x) => setStore("lsp", 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, reconcile(event.properties.diff, { key: "file" }))
|
||||
break
|
||||
case "todo.updated":
|
||||
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos))
|
||||
break
|
||||
case "session.status": {
|
||||
setStore("session_status", event.properties.sessionID, reconcile(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)
|
||||
.catch(() => undefined)
|
||||
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) => {
|
||||
const data = x.data!
|
||||
setGlobalStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
})
|
||||
}),
|
||||
),
|
||||
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
|
||||
}
|
||||
260
packages/app/src/context/layout.tsx
Normal file
260
packages/app/src/context/layout.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { batch, createMemo, onMount } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||
|
||||
export function getAvatarColors(key?: string) {
|
||||
if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
|
||||
return {
|
||||
background: `var(--avatar-background-${key})`,
|
||||
foreground: `var(--avatar-text-${key})`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
background: "var(--surface-info-base)",
|
||||
foreground: "var(--text-base)",
|
||||
}
|
||||
}
|
||||
|
||||
type SessionTabs = {
|
||||
active?: string
|
||||
all: string[]
|
||||
}
|
||||
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
|
||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||
name: "Layout",
|
||||
init: () => {
|
||||
const globalSdk = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"layout.v3",
|
||||
createStore({
|
||||
projects: [] as { worktree: string; expanded: boolean }[],
|
||||
sidebar: {
|
||||
opened: false,
|
||||
width: 280,
|
||||
},
|
||||
terminal: {
|
||||
opened: false,
|
||||
height: 280,
|
||||
},
|
||||
review: {
|
||||
opened: true,
|
||||
},
|
||||
session: {
|
||||
width: 600,
|
||||
},
|
||||
sessionTabs: {} as Record<string, SessionTabs>,
|
||||
}),
|
||||
)
|
||||
|
||||
const usedColors = new Set<AvatarColorKey>()
|
||||
|
||||
function pickAvailableColor(): AvatarColorKey {
|
||||
const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
|
||||
if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
|
||||
return available[Math.floor(Math.random() * available.length)]
|
||||
}
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
|
||||
return [
|
||||
{
|
||||
...project,
|
||||
...(metadata ?? {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function colorize(project: LocalProject) {
|
||||
if (project.icon?.color) return project
|
||||
const color = pickAvailableColor()
|
||||
usedColors.add(color)
|
||||
project.icon = { ...project.icon, color }
|
||||
if (project.id) {
|
||||
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
const enriched = createMemo(() => store.projects.flatMap(enrich))
|
||||
const list = createMemo(() => enriched().flatMap(colorize))
|
||||
|
||||
onMount(() => {
|
||||
Promise.all(
|
||||
store.projects.map((project) => {
|
||||
return globalSync.project.loadSessions(project.worktree)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
projects: {
|
||||
list,
|
||||
open(directory: string) {
|
||||
if (store.projects.find((x) => x.worktree === directory)) {
|
||||
return
|
||||
}
|
||||
globalSync.project.loadSessions(directory)
|
||||
setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
|
||||
},
|
||||
close(directory: string) {
|
||||
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
|
||||
},
|
||||
expand(directory: string) {
|
||||
const index = store.projects.findIndex((x) => x.worktree === directory)
|
||||
if (index !== -1) setStore("projects", index, "expanded", true)
|
||||
},
|
||||
collapse(directory: string) {
|
||||
const index = store.projects.findIndex((x) => x.worktree === directory)
|
||||
if (index !== -1) setStore("projects", index, "expanded", false)
|
||||
},
|
||||
move(directory: string, toIndex: number) {
|
||||
setStore("projects", (projects) => {
|
||||
const fromIndex = projects.findIndex((x) => x.worktree === directory)
|
||||
if (fromIndex === -1 || fromIndex === toIndex) return projects
|
||||
const result = [...projects]
|
||||
const [item] = result.splice(fromIndex, 1)
|
||||
result.splice(toIndex, 0, item)
|
||||
return result
|
||||
})
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
opened: createMemo(() => store.sidebar.opened),
|
||||
open() {
|
||||
setStore("sidebar", "opened", true)
|
||||
},
|
||||
close() {
|
||||
setStore("sidebar", "opened", false)
|
||||
},
|
||||
toggle() {
|
||||
setStore("sidebar", "opened", (x) => !x)
|
||||
},
|
||||
width: createMemo(() => store.sidebar.width),
|
||||
resize(width: number) {
|
||||
setStore("sidebar", "width", width)
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
opened: createMemo(() => store.terminal.opened),
|
||||
open() {
|
||||
setStore("terminal", "opened", true)
|
||||
},
|
||||
close() {
|
||||
setStore("terminal", "opened", false)
|
||||
},
|
||||
toggle() {
|
||||
setStore("terminal", "opened", (x) => !x)
|
||||
},
|
||||
height: createMemo(() => store.terminal.height),
|
||||
resize(height: number) {
|
||||
setStore("terminal", "height", height)
|
||||
},
|
||||
},
|
||||
review: {
|
||||
opened: createMemo(() => store.review?.opened ?? true),
|
||||
open() {
|
||||
setStore("review", "opened", true)
|
||||
},
|
||||
close() {
|
||||
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) {
|
||||
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
|
||||
return {
|
||||
tabs,
|
||||
active: createMemo(() => tabs().active),
|
||||
all: createMemo(() => tabs().all),
|
||||
setActive(tab: string | undefined) {
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all: [], active: tab })
|
||||
} else {
|
||||
setStore("sessionTabs", sessionKey, "active", tab)
|
||||
}
|
||||
},
|
||||
setAll(all: string[]) {
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all, active: undefined })
|
||||
} else {
|
||||
setStore("sessionTabs", sessionKey, "all", all)
|
||||
}
|
||||
},
|
||||
async open(tab: string) {
|
||||
const current = store.sessionTabs[sessionKey] ?? { all: [] }
|
||||
if (tab !== "review") {
|
||||
if (!current.all.includes(tab)) {
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
|
||||
} else {
|
||||
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
|
||||
setStore("sessionTabs", sessionKey, "active", tab)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!store.sessionTabs[sessionKey]) {
|
||||
setStore("sessionTabs", sessionKey, { all: [], active: tab })
|
||||
} else {
|
||||
setStore("sessionTabs", sessionKey, "active", tab)
|
||||
}
|
||||
},
|
||||
close(tab: string) {
|
||||
const current = store.sessionTabs[sessionKey]
|
||||
if (!current) return
|
||||
batch(() => {
|
||||
setStore(
|
||||
"sessionTabs",
|
||||
sessionKey,
|
||||
"all",
|
||||
current.all.filter((x) => x !== tab),
|
||||
)
|
||||
if (current.active === tab) {
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
const previous = current.all[Math.max(0, index - 1)]
|
||||
setStore("sessionTabs", sessionKey, "active", previous)
|
||||
}
|
||||
})
|
||||
},
|
||||
move(tab: string, to: number) {
|
||||
const current = store.sessionTabs[sessionKey]
|
||||
if (!current) return
|
||||
const index = current.all.findIndex((f) => f === tab)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"sessionTabs",
|
||||
sessionKey,
|
||||
"all",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,12 +1,15 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { uniqueBy } from "remeda"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { DateTime } from "luxon"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
|
||||
export type LocalFile = FileNode &
|
||||
Partial<{
|
||||
@@ -59,26 +62,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically update model when agent changes
|
||||
createEffect(() => {
|
||||
const value = agent.current()
|
||||
if (value.model) {
|
||||
if (isModelValid(value.model))
|
||||
model.set({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
// else
|
||||
// toast.show({
|
||||
// type: "warning",
|
||||
// message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
|
||||
// duration: 3000,
|
||||
// })
|
||||
}
|
||||
})
|
||||
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||
const [store, setStore] = createStore<{
|
||||
current: string
|
||||
}>({
|
||||
@@ -108,30 +93,62 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})()
|
||||
|
||||
const model = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
const [store, setStore, _, modelReady] = persisted(
|
||||
"model.v1",
|
||||
createStore<{
|
||||
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
|
||||
recent: ModelKey[]
|
||||
}>({
|
||||
user: [],
|
||||
recent: [],
|
||||
}),
|
||||
)
|
||||
|
||||
const [ephemeral, setEphemeral] = createStore<{
|
||||
model: Record<string, ModelKey>
|
||||
recent: ModelKey[]
|
||||
}>({
|
||||
model: {},
|
||||
recent: [],
|
||||
})
|
||||
|
||||
const value = localStorage.getItem("model")
|
||||
setStore("recent", JSON.parse(value ?? "[]"))
|
||||
createEffect(() => {
|
||||
localStorage.setItem("model", JSON.stringify(store.recent))
|
||||
})
|
||||
|
||||
const list = createMemo(() =>
|
||||
const available = createMemo(() =>
|
||||
providers.connected().flatMap((p) =>
|
||||
Object.values(p.models).map((m) => ({
|
||||
...m,
|
||||
name: m.name.replace("(latest)", "").trim(),
|
||||
provider: p,
|
||||
latest: m.name.includes("(latest)"),
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
const latest = createMemo(() =>
|
||||
pipe(
|
||||
available(),
|
||||
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
|
||||
groupBy((x) => x.provider.id),
|
||||
mapValues((models) =>
|
||||
pipe(
|
||||
models,
|
||||
groupBy((x) => x.family),
|
||||
values(),
|
||||
(groups) =>
|
||||
groups.flatMap((g) => {
|
||||
const first = firstBy(g, [(x) => x.release_date, "desc"])
|
||||
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
|
||||
}),
|
||||
),
|
||||
),
|
||||
values(),
|
||||
flat(),
|
||||
),
|
||||
)
|
||||
|
||||
const list = createMemo(() =>
|
||||
available().map((m) => ({
|
||||
...m,
|
||||
name: m.name.replace("(latest)", "").trim(),
|
||||
latest: m.name.includes("(latest)"),
|
||||
})),
|
||||
)
|
||||
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const fallbackModel = createMemo(() => {
|
||||
@@ -163,10 +180,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
throw new Error("No default model found")
|
||||
})
|
||||
|
||||
const currentModel = createMemo(() => {
|
||||
const current = createMemo(() => {
|
||||
const a = agent.current()
|
||||
const key = getFirstValidModel(
|
||||
() => store.model[a.name],
|
||||
() => ephemeral.model[a.name],
|
||||
() => a.model,
|
||||
fallbackModel,
|
||||
)!
|
||||
@@ -177,10 +194,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const cycle = (direction: 1 | -1) => {
|
||||
const recentList = recent()
|
||||
const current = currentModel()
|
||||
if (!current) return
|
||||
const currentModel = current()
|
||||
if (!currentModel) return
|
||||
|
||||
const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id)
|
||||
const index = recentList.findIndex(
|
||||
(x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
|
||||
)
|
||||
if (index === -1) return
|
||||
|
||||
let next = index + direction
|
||||
@@ -196,14 +215,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
}
|
||||
|
||||
function updateVisibility(model: ModelKey, visibility: "show" | "hide") {
|
||||
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
||||
if (index >= 0) {
|
||||
setStore("user", index, { visibility })
|
||||
} else {
|
||||
setStore("user", store.user.length, { ...model, visibility })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
current: currentModel,
|
||||
ready: modelReady,
|
||||
current,
|
||||
recent,
|
||||
list,
|
||||
cycle,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model ?? fallbackModel())
|
||||
setEphemeral("model", agent.current().name, model ?? fallbackModel())
|
||||
if (model) updateVisibility(model, "show")
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
@@ -211,6 +241,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
})
|
||||
},
|
||||
visible(model: ModelKey) {
|
||||
const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
||||
return (
|
||||
user?.visibility !== "hide" &&
|
||||
(latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) ||
|
||||
user?.visibility === "show")
|
||||
)
|
||||
},
|
||||
setVisibility(model: ModelKey, visible: boolean) {
|
||||
updateVisibility(model, visible ? "show" : "hide")
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -218,11 +259,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const [store, setStore] = createStore<{
|
||||
node: Record<string, LocalFile>
|
||||
}>({
|
||||
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||
node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||
})
|
||||
|
||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||
// const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
// const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||
|
||||
// createEffect((prev: FileStatus[]) => {
|
||||
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
|
||||
@@ -250,16 +291,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
// return sync.data.changes
|
||||
// }, sync.data.changes)
|
||||
|
||||
const changed = (path: string) => {
|
||||
const node = store.node[path]
|
||||
if (node?.status) return true
|
||||
const set = changeset()
|
||||
if (set.has(path)) return true
|
||||
for (const p of set) {
|
||||
if (p.startsWith(path ? path + "/" : "")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
// const changed = (path: string) => {
|
||||
// const node = store.node[path]
|
||||
// if (node?.status) return true
|
||||
// const set = changeset()
|
||||
// if (set.has(path)) return true
|
||||
// for (const p of set) {
|
||||
// if (p.startsWith(path ? path + "/" : "")) return true
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
|
||||
// const resetNode = (path: string) => {
|
||||
// setStore("node", path, {
|
||||
@@ -278,16 +319,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const load = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
await sdk.client.file.read({ path: relativePath }).then((x) => {
|
||||
setStore(
|
||||
"node",
|
||||
relativePath,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.content = x.data
|
||||
}),
|
||||
)
|
||||
})
|
||||
await sdk.client.file
|
||||
.read({ path: relativePath })
|
||||
.then((x) => {
|
||||
if (!store.node[relativePath]) return
|
||||
setStore(
|
||||
"node",
|
||||
relativePath,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.content = x.data
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Failed to load file",
|
||||
description: e.message,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const fetch = async (path: string) => {
|
||||
@@ -301,7 +352,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const init = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
if (!store.node[relativePath]) await fetch(path)
|
||||
if (store.node[relativePath].loaded) return
|
||||
if (store.node[relativePath]?.loaded) return
|
||||
return load(relativePath)
|
||||
}
|
||||
|
||||
@@ -321,7 +372,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
context.addActive()
|
||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -349,7 +400,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
case "file.watcher.updated":
|
||||
const relativePath = relative(event.properties.file)
|
||||
if (relativePath.startsWith(".git/")) return
|
||||
load(relativePath)
|
||||
if (store.node[relativePath]) load(relativePath)
|
||||
break
|
||||
}
|
||||
})
|
||||
@@ -367,7 +418,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
init,
|
||||
expand(path: string) {
|
||||
setStore("node", path, "expanded", true)
|
||||
if (store.node[path].loaded) return
|
||||
if (store.node[path]?.loaded) return
|
||||
setStore("node", path, "loaded", true)
|
||||
list(path)
|
||||
},
|
||||
@@ -407,8 +458,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setChangeIndex(path: string, index: number | undefined) {
|
||||
setStore("node", path, "selectedChange", index)
|
||||
},
|
||||
changes,
|
||||
changed,
|
||||
// changes,
|
||||
// changed,
|
||||
children(path: string) {
|
||||
return Object.values(store.node).filter(
|
||||
(x) =>
|
||||
127
packages/app/src/context/notification.tsx
Normal file
127
packages/app/src/context/notification.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { makeAudioPlayer } from "@solid-primitives/audio"
|
||||
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
session?: string
|
||||
metadata?: any
|
||||
time: number
|
||||
viewed: boolean
|
||||
}
|
||||
|
||||
type TurnCompleteNotification = NotificationBase & {
|
||||
type: "turn-complete"
|
||||
}
|
||||
|
||||
type ErrorNotification = NotificationBase & {
|
||||
type: "error"
|
||||
error: EventSessionError["properties"]["error"]
|
||||
}
|
||||
|
||||
export type Notification = TurnCompleteNotification | ErrorNotification
|
||||
|
||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
init: () => {
|
||||
let idlePlayer: ReturnType<typeof makeAudioPlayer> | undefined
|
||||
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 globalSync = useGlobalSync()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"notification.v1",
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
|
||||
globalSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const event = e.details
|
||||
const base = {
|
||||
directory,
|
||||
time: Date.now(),
|
||||
viewed: false,
|
||||
}
|
||||
switch (event.type) {
|
||||
case "session.idle": {
|
||||
const sessionID = event.properties.sessionID
|
||||
const [syncStore] = globalSync.child(directory)
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
const isChild = match.found && syncStore.session[match.index].parentID
|
||||
if (isChild) break
|
||||
try {
|
||||
idlePlayer?.play()
|
||||
} catch {}
|
||||
setStore("list", store.list.length, {
|
||||
...base,
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.error": {
|
||||
const sessionID = event.properties.sessionID
|
||||
if (sessionID) {
|
||||
const [syncStore] = globalSync.child(directory)
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
const isChild = match.found && syncStore.session[match.index].parentID
|
||||
if (isChild) break
|
||||
}
|
||||
try {
|
||||
errorPlayer?.play()
|
||||
} catch {}
|
||||
setStore("list", store.list.length, {
|
||||
...base,
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
error: "error" in event.properties ? event.properties.error : undefined,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
session: {
|
||||
all(session: string) {
|
||||
return store.list.filter((n) => n.session === session)
|
||||
},
|
||||
unseen(session: string) {
|
||||
return store.list.filter((n) => n.session === session && !n.viewed)
|
||||
},
|
||||
markViewed(session: string) {
|
||||
setStore("list", (n) => n.session === session, "viewed", true)
|
||||
},
|
||||
},
|
||||
project: {
|
||||
all(directory: string) {
|
||||
return store.list.filter((n) => n.directory === directory)
|
||||
},
|
||||
unseen(directory: string) {
|
||||
return store.list.filter((n) => n.directory === directory && !n.viewed)
|
||||
},
|
||||
markViewed(directory: string) {
|
||||
setStore("list", (n) => n.directory === directory, "viewed", true)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,9 +1,19 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
|
||||
export type Platform = {
|
||||
/** Platform discriminator */
|
||||
platform: "web" | "tauri"
|
||||
|
||||
/** App version */
|
||||
version?: string
|
||||
|
||||
/** Open a URL in the default browser */
|
||||
openLink(url: string): void
|
||||
|
||||
/** Restart the app */
|
||||
restart(): Promise<void>
|
||||
|
||||
/** Open native directory picker dialog (Tauri only) */
|
||||
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
||||
|
||||
@@ -13,8 +23,17 @@ export type Platform = {
|
||||
/** Save file picker dialog (Tauri only) */
|
||||
saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>
|
||||
|
||||
/** Open a URL in the default browser */
|
||||
openLink(url: string): void
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
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({
|
||||
111
packages/app/src/context/prompt.tsx
Normal file
111
packages/app/src/context/prompt.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { TextSelection } from "./local"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
}
|
||||
|
||||
export interface ImageAttachmentPart {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
const partA = promptA[i]
|
||||
const partB = promptB[i]
|
||||
if (partA.type !== partB.type) return false
|
||||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
||||
return false
|
||||
}
|
||||
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
||||
return false
|
||||
}
|
||||
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cloneSelection(selection?: TextSelection) {
|
||||
if (!selection) return undefined
|
||||
return { ...selection }
|
||||
}
|
||||
|
||||
function clonePart(part: ContentPart): ContentPart {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "image") return { ...part }
|
||||
return {
|
||||
...part,
|
||||
selection: cloneSelection(part.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: Prompt): Prompt {
|
||||
return prompt.map(clonePart)
|
||||
}
|
||||
|
||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||
name: "Prompt",
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
name(),
|
||||
createStore<{
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
}>({
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
cursor: undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
ready,
|
||||
current: createMemo(() => store.prompt),
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
const next = clonePrompt(prompt)
|
||||
batch(() => {
|
||||
setStore("prompt", next)
|
||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
batch(() => {
|
||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||
setStore("cursor", 0)
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,18 +1,20 @@
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
init: (props: { directory: string }) => {
|
||||
const platform = usePlatform()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const abort = new AbortController()
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
signal: abort.signal,
|
||||
signal: AbortSignal.timeout(1000 * 60 * 10),
|
||||
fetch: platform.fetch,
|
||||
directory: props.directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
@@ -23,10 +25,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
|
||||
},
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
import { produce } from "solid-js/store"
|
||||
import { createMemo } from "solid-js"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
@@ -30,12 +32,40 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
if (match.found) return store.session[match.index]
|
||||
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) {
|
||||
const [session, messages, todo, diff] = await Promise.all([
|
||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
||||
sdk.client.session.messages({ sessionID, limit: 100 }),
|
||||
sdk.client.session.todo({ sessionID }),
|
||||
sdk.client.session.diff({ sessionID }),
|
||||
retry(() => sdk.client.session.get({ sessionID })),
|
||||
retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
|
||||
retry(() => sdk.client.session.todo({ sessionID })),
|
||||
retry(() => sdk.client.session.diff({ sessionID })),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
@@ -65,6 +95,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
},
|
||||
more: createMemo(() => store.session.length >= store.limit),
|
||||
archive: async (sessionID: string) => {
|
||||
await sdk.client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
absolute,
|
||||
get directory() {
|
||||
105
packages/app/src/context/terminal.tsx
Normal file
105
packages/app/src/context/terminal.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
export type LocalPTY = {
|
||||
id: string
|
||||
title: string
|
||||
rows?: number
|
||||
cols?: number
|
||||
buffer?: string
|
||||
scrollY?: number
|
||||
}
|
||||
|
||||
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
|
||||
name: "Terminal",
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const params = useParams()
|
||||
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
name(),
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
}>({
|
||||
all: [],
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
ready,
|
||||
all: createMemo(() => Object.values(store.all)),
|
||||
active: createMemo(() => store.active),
|
||||
new() {
|
||||
sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
|
||||
const id = pty.data?.id
|
||||
if (!id) return
|
||||
setStore("all", [
|
||||
...store.all,
|
||||
{
|
||||
id,
|
||||
title: pty.data?.title ?? "Terminal",
|
||||
},
|
||||
])
|
||||
setStore("active", id)
|
||||
})
|
||||
},
|
||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
||||
sdk.client.pty.update({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||
})
|
||||
},
|
||||
async clone(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
const clone = await sdk.client.pty.create({
|
||||
title: pty.title,
|
||||
})
|
||||
if (!clone.data) return
|
||||
setStore("all", index, {
|
||||
...pty,
|
||||
...clone.data,
|
||||
})
|
||||
if (store.active === pty.id) {
|
||||
setStore("active", clone.data.id)
|
||||
}
|
||||
},
|
||||
open(id: string) {
|
||||
setStore("active", id)
|
||||
},
|
||||
async close(id: string) {
|
||||
batch(() => {
|
||||
setStore(
|
||||
"all",
|
||||
store.all.filter((x) => x.id !== id),
|
||||
)
|
||||
if (store.active === id) {
|
||||
const index = store.all.findIndex((f) => f.id === id)
|
||||
const previous = store.all[Math.max(0, index - 1)]
|
||||
setStore("active", previous?.id)
|
||||
}
|
||||
})
|
||||
await sdk.client.pty.remove({ ptyID: id })
|
||||
},
|
||||
move(id: string, to: number) {
|
||||
const index = store.all.findIndex((f) => f.id === id)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"all",
|
||||
produce((all) => {
|
||||
all.splice(to, 0, all.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import { render } from "solid-js/web"
|
||||
import { App } from "@/app"
|
||||
import { Platform, PlatformProvider } from "@/context/platform"
|
||||
import pkg from "../package.json"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
@@ -12,9 +13,13 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
version: pkg.version,
|
||||
openLink(url: string) {
|
||||
window.open(url, "_blank")
|
||||
},
|
||||
restart: async () => {
|
||||
window.location.reload()
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
@@ -6,8 +6,8 @@ import { createMemo } from "solid-js"
|
||||
export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
|
||||
|
||||
export function useProviders() {
|
||||
const params = useParams()
|
||||
const globalSync = useGlobalSync()
|
||||
const params = useParams()
|
||||
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
|
||||
const providers = createMemo(() => {
|
||||
if (currentDirectory()) {
|
||||
160
packages/app/src/pages/error.tsx
Normal file
160
packages/app/src/pages/error.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Component, Show } 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 flex-col items-center gap-2">
|
||||
<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>
|
||||
<Show when={platform.version}>
|
||||
<p class="text-xs text-text-weak">Version: {platform.version}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { For, Match, Show, Switch } from "solid-js"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -14,6 +14,7 @@ export default function Home() {
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
|
||||
function openProject(directory: string) {
|
||||
layout.projects.open(directory)
|
||||
@@ -61,7 +62,7 @@ export default function Home() {
|
||||
class="text-14-mono text-left justify-between px-3"
|
||||
onClick={() => openProject(project.worktree)}
|
||||
>
|
||||
{project.worktree}
|
||||
{project.worktree.replace(homedir(), "~")}
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
||||
</div>
|
||||
885
packages/app/src/pages/layout.tsx
Normal file
885
packages/app/src/pages/layout.tsx
Normal file
@@ -0,0 +1,885 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
ParentProps,
|
||||
Show,
|
||||
Switch,
|
||||
untrack,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
createSortable,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { showToast, Toast } from "@opencode-ai/ui/toast"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { Header } from "@/components/header"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const [store, setStore] = createStore({
|
||||
lastSession: {} as { [directory: string]: string },
|
||||
activeDraggable: undefined as string | undefined,
|
||||
mobileSidebarOpen: false,
|
||||
mobileProjectsExpanded: {} as Record<string, boolean>,
|
||||
})
|
||||
|
||||
const mobileSidebar = {
|
||||
open: () => store.mobileSidebarOpen,
|
||||
show: () => setStore("mobileSidebarOpen", true),
|
||||
hide: () => setStore("mobileSidebarOpen", false),
|
||||
toggle: () => setStore("mobileSidebarOpen", (x) => !x),
|
||||
}
|
||||
|
||||
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 globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const notification = useNotification()
|
||||
const navigate = useNavigate()
|
||||
const providers = useProviders()
|
||||
const dialog = useDialog()
|
||||
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 sortSessions(a: Session, b: Session) {
|
||||
const now = Date.now()
|
||||
const oneMinuteAgo = now - 60 * 1000
|
||||
const aUpdated = a.time.updated ?? a.time.created
|
||||
const bUpdated = b.time.updated ?? b.time.created
|
||||
const aRecent = aUpdated > oneMinuteAgo
|
||||
const bRecent = bUpdated > oneMinuteAgo
|
||||
if (aRecent && bRecent) return a.id.localeCompare(b.id)
|
||||
if (aRecent && !bRecent) return -1
|
||||
if (!aRecent && bRecent) return 1
|
||||
return bUpdated - aUpdated
|
||||
}
|
||||
|
||||
function scrollToSession(sessionId: string) {
|
||||
if (!scrollContainerRef) return
|
||||
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}
|
||||
}
|
||||
|
||||
function projectSessions(directory: string) {
|
||||
if (!directory) return []
|
||||
const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions)
|
||||
return (sessions ?? []).filter((s) => !s.parentID)
|
||||
}
|
||||
|
||||
const currentSessions = createMemo(() => {
|
||||
if (!params.dir) return []
|
||||
const directory = base64Decode(params.dir)
|
||||
return projectSessions(directory)
|
||||
})
|
||||
|
||||
function navigateSessionByOffset(offset: number) {
|
||||
const projects = layout.projects.list()
|
||||
if (projects.length === 0) return
|
||||
|
||||
const currentDirectory = params.dir ? base64Decode(params.dir) : undefined
|
||||
const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1
|
||||
|
||||
if (projectIndex === -1) {
|
||||
const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
|
||||
if (targetProject) navigateToProject(targetProject.worktree)
|
||||
return
|
||||
}
|
||||
|
||||
const sessions = currentSessions()
|
||||
const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
|
||||
|
||||
let targetIndex: number
|
||||
if (sessionIndex === -1) {
|
||||
targetIndex = offset > 0 ? 0 : sessions.length - 1
|
||||
} else {
|
||||
targetIndex = sessionIndex + offset
|
||||
}
|
||||
|
||||
if (targetIndex >= 0 && targetIndex < sessions.length) {
|
||||
const session = sessions[targetIndex]
|
||||
navigateToSession(session)
|
||||
queueMicrotask(() => scrollToSession(session.id))
|
||||
return
|
||||
}
|
||||
|
||||
const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1)
|
||||
const nextProject = projects[nextProjectIndex]
|
||||
if (!nextProject) return
|
||||
|
||||
const nextProjectSessions = projectSessions(nextProject.worktree)
|
||||
if (nextProjectSessions.length === 0) {
|
||||
navigateToProject(nextProject.worktree)
|
||||
return
|
||||
}
|
||||
|
||||
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
|
||||
navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
|
||||
queueMicrotask(() => scrollToSession(targetSession.id))
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
const [store, setStore] = globalSync.child(session.directory)
|
||||
const sessions = store.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||
|
||||
await globalSDK.client.session.update({
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
time: { archived: Date.now() },
|
||||
})
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
if (session.id === params.id) {
|
||||
if (nextSession) {
|
||||
navigate(`/${params.dir}/session/${nextSession.id}`)
|
||||
} else {
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
id: "sidebar.toggle",
|
||||
title: "Toggle sidebar",
|
||||
category: "View",
|
||||
keybind: "mod+b",
|
||||
onSelect: () => layout.sidebar.toggle(),
|
||||
},
|
||||
...(platform.openDirectoryPickerDialog
|
||||
? [
|
||||
{
|
||||
id: "project.open",
|
||||
title: "Open project",
|
||||
category: "Project",
|
||||
keybind: "mod+o",
|
||||
onSelect: () => chooseProject(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "provider.connect",
|
||||
title: "Connect provider",
|
||||
category: "Provider",
|
||||
onSelect: () => connectProvider(),
|
||||
},
|
||||
{
|
||||
id: "session.previous",
|
||||
title: "Previous session",
|
||||
category: "Session",
|
||||
keybind: "alt+arrowup",
|
||||
onSelect: () => navigateSessionByOffset(-1),
|
||||
},
|
||||
{
|
||||
id: "session.next",
|
||||
title: "Next session",
|
||||
category: "Session",
|
||||
keybind: "alt+arrowdown",
|
||||
onSelect: () => navigateSessionByOffset(1),
|
||||
},
|
||||
{
|
||||
id: "session.archive",
|
||||
title: "Archive session",
|
||||
category: "Session",
|
||||
keybind: "mod+shift+backspace",
|
||||
disabled: !params.dir || !params.id,
|
||||
onSelect: () => {
|
||||
const session = currentSessions().find((s) => s.id === params.id)
|
||||
if (session) archiveSession(session)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
function connectProvider() {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
|
||||
function navigateToProject(directory: string | undefined) {
|
||||
if (!directory) return
|
||||
const lastSession = store.lastSession[directory]
|
||||
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
|
||||
mobileSidebar.hide()
|
||||
}
|
||||
|
||||
function navigateToSession(session: Session | undefined) {
|
||||
if (!session) return
|
||||
navigate(`/${params.dir}/session/${session?.id}`)
|
||||
mobileSidebar.hide()
|
||||
}
|
||||
|
||||
function openProject(directory: string, navigate = true) {
|
||||
layout.projects.open(directory)
|
||||
if (navigate) navigateToProject(directory)
|
||||
}
|
||||
|
||||
function closeProject(directory: string) {
|
||||
const index = layout.projects.list().findIndex((x) => x.worktree === directory)
|
||||
const next = layout.projects.list()[index + 1]
|
||||
layout.projects.close(directory)
|
||||
if (next) navigateToProject(next.worktree)
|
||||
else navigate("/")
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: "Open project",
|
||||
multiple: true,
|
||||
})
|
||||
if (Array.isArray(result)) {
|
||||
for (const directory of result) {
|
||||
openProject(directory, false)
|
||||
}
|
||||
navigateToProject(result[0])
|
||||
} else if (result) {
|
||||
openProject(result)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!params.dir || !params.id) return
|
||||
const directory = base64Decode(params.dir)
|
||||
const id = params.id
|
||||
setStore("lastSession", directory, id)
|
||||
notification.session.markViewed(id)
|
||||
untrack(() => layout.projects.expand(directory))
|
||||
requestAnimationFrame(() => scrollToSession(id))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (isLargeViewport()) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
function handleDragStart(event: unknown) {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
setStore("activeDraggable", id)
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const projects = layout.projects.list()
|
||||
const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
|
||||
const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
|
||||
if (fromIndex !== toIndex && toIndex !== -1) {
|
||||
layout.projects.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
setStore("activeDraggable", undefined)
|
||||
}
|
||||
|
||||
const ProjectAvatar = (props: {
|
||||
project: LocalProject
|
||||
class?: string
|
||||
expandable?: boolean
|
||||
notify?: boolean
|
||||
}): JSX.Element => {
|
||||
const notification = useNotification()
|
||||
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
|
||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
|
||||
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
return (
|
||||
<div class="relative size-5 shrink-0 rounded-sm">
|
||||
<Avatar
|
||||
fallback={name()}
|
||||
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
|
||||
{...getAvatarColors(props.project.icon?.color)}
|
||||
class={`size-full ${props.class ?? ""}`}
|
||||
style={
|
||||
notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
|
||||
}
|
||||
/>
|
||||
<Show when={props.expandable}>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
size="normal"
|
||||
class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={notifications().length > 0 && props.notify}>
|
||||
<div
|
||||
classList={{
|
||||
"absolute -top-0.5 -right-0.5 size-1.5 rounded-full": true,
|
||||
"bg-icon-critical-base": hasError(),
|
||||
"bg-text-interactive-base": !hasError(),
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const current = createMemo(() => base64Decode(params.dir ?? ""))
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={layout.sidebar.opened()}>
|
||||
<Button
|
||||
as={"div"}
|
||||
variant="ghost"
|
||||
data-active
|
||||
class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-3 p-0 text-left min-w-0 grow">
|
||||
<ProjectAvatar project={props.project} />
|
||||
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="flex items-center justify-center p-0 aspect-square border-none rounded-lg"
|
||||
data-selected={props.project.worktree === current()}
|
||||
onClick={() => navigateToProject(props.project.worktree)}
|
||||
>
|
||||
<ProjectAvatar project={props.project} notify />
|
||||
</Button>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
const SessionItem = (props: {
|
||||
session: Session
|
||||
slug: string
|
||||
project: LocalProject
|
||||
mobile?: boolean
|
||||
}): JSX.Element => {
|
||||
const notification = useNotification()
|
||||
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
||||
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||
const isWorking = createMemo(() => {
|
||||
if (props.session.id === params.id) return false
|
||||
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
|
||||
return status?.type === "busy" || status?.type === "retry"
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
|
||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
|
||||
style={{ "padding-left": "16px" }}
|
||||
>
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
||||
<A
|
||||
href={`${props.slug}/session/${props.session.id}`}
|
||||
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
|
||||
>
|
||||
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
||||
<span
|
||||
classList={{
|
||||
"text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
|
||||
"animate-pulse": isWorking(),
|
||||
}}
|
||||
>
|
||||
{props.session.title}
|
||||
</span>
|
||||
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
||||
<Switch>
|
||||
<Match when={isWorking()}>
|
||||
<Spinner class="size-2.5 mr-0.5" />
|
||||
</Match>
|
||||
<Match when={hasError()}>
|
||||
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={notifications().length > 0}>
|
||||
<div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
||||
{Math.abs(updated().diffNow().as("seconds")) < 60
|
||||
? "Now"
|
||||
: updated()
|
||||
.toRelative({
|
||||
style: "short",
|
||||
unit: ["days", "hours", "minutes"],
|
||||
})
|
||||
?.replace(" ago", "")
|
||||
?.replace(/ days?/, "d")
|
||||
?.replace(" min.", "m")
|
||||
?.replace(" hr.", "h")}
|
||||
</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.session.summary?.files}>
|
||||
<div class="flex justify-between items-center self-stretch">
|
||||
<span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span>
|
||||
<Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</A>
|
||||
</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">
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Archive session</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
||||
const sortable = createSortable(props.project.worktree)
|
||||
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
|
||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const [store, setProjectStore] = globalSync.child(props.project.worktree)
|
||||
const sessions = createMemo(() => store.session.toSorted(sortSessions))
|
||||
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
|
||||
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 (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<Switch>
|
||||
<Match when={showExpanded()}>
|
||||
<Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
|
||||
<Button
|
||||
as={"div"}
|
||||
variant="ghost"
|
||||
class="group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg"
|
||||
>
|
||||
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
|
||||
<ProjectAvatar
|
||||
project={props.project}
|
||||
class="group-hover/session:hidden"
|
||||
expandable
|
||||
notify={!isExpanded()}
|
||||
/>
|
||||
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
||||
</Collapsible.Trigger>
|
||||
<div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible">
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
|
||||
<DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
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>
|
||||
}
|
||||
>
|
||||
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Button>
|
||||
<Collapsible.Content>
|
||||
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
|
||||
<For each={rootSessions()}>
|
||||
{(session) => (
|
||||
<SessionItem session={session} slug={slug()} project={props.project} mobile={props.mobile} />
|
||||
)}
|
||||
</For>
|
||||
<Show when={rootSessions().length === 0}>
|
||||
<div
|
||||
class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
|
||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
|
||||
>
|
||||
<div class="flex items-center self-stretch w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
|
||||
<A
|
||||
href={`${slug()}/session`}
|
||||
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
|
||||
>
|
||||
<div class="flex items-center self-stretch gap-6 justify-between">
|
||||
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
||||
New session
|
||||
</span>
|
||||
</div>
|
||||
</A>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Tooltip placement="right" value={props.project.worktree}>
|
||||
<ProjectVisual project={props.project} />
|
||||
</Tooltip>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ProjectDragOverlay = (): JSX.Element => {
|
||||
const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable))
|
||||
return (
|
||||
<Show when={project()}>
|
||||
{(p) => (
|
||||
<div class="bg-background-base rounded-md">
|
||||
<ProjectVisual project={p()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
|
||||
const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
|
||||
return (
|
||||
<>
|
||||
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
|
||||
<Show when={!sidebarProps.mobile}>
|
||||
<Tooltip
|
||||
class="shrink-0"
|
||||
placement="right"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Toggle sidebar</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
|
||||
</div>
|
||||
}
|
||||
inactive={expanded()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
|
||||
onClick={layout.sidebar.toggle}
|
||||
>
|
||||
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
|
||||
size="small"
|
||||
class="group-hover/sidebar-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
|
||||
size="small"
|
||||
class="hidden group-hover/sidebar-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
|
||||
size="small"
|
||||
class="hidden group-active/sidebar-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
|
||||
Toggle sidebar
|
||||
</div>
|
||||
</Show>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragXAxis />
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (!sidebarProps.mobile) scrollContainerRef = el
|
||||
}}
|
||||
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
|
||||
<For each={layout.projects.list()}>
|
||||
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<ProjectDragOverlay />
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
|
||||
<Switch>
|
||||
<Match when={!providers.paid().length && expanded()}>
|
||||
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
|
||||
<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={expanded()}>
|
||||
<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}
|
||||
>
|
||||
Connect provider
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
|
||||
<Button
|
||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||
variant="ghost"
|
||||
size="large"
|
||||
icon="plus"
|
||||
onClick={connectProvider}
|
||||
>
|
||||
<Show when={expanded()}>Connect provider</Show>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={platform.openDirectoryPickerDialog}>
|
||||
<Tooltip
|
||||
placement="right"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Open project</span>
|
||||
<Show when={!sidebarProps.mobile}>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
inactive={expanded()}
|
||||
>
|
||||
<Button
|
||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||
variant="ghost"
|
||||
size="large"
|
||||
icon="folder-add-left"
|
||||
onClick={chooseProject}
|
||||
>
|
||||
<Show when={expanded()}>Open project</Show>
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
|
||||
</div>
|
||||
<Toast.Region />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
943
packages/app/src/pages/session.tsx
Normal file
943
packages/app/src/pages/session.tsx
Normal file
@@ -0,0 +1,943 @@
|
||||
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 { createStore } from "solid-js/store"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { DateTime } from "luxon"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||
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 { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
createSortable,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { Terminal } from "@/components/terminal"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { StatusBar } from "@/components/status-bar"
|
||||
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
||||
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
||||
|
||||
export default function Page() {
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const dialog = useDialog()
|
||||
const codeComponent = useCodeComponent()
|
||||
const command = useCommand()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
)
|
||||
const visibleUserMessages = createMemo(() => {
|
||||
const revert = revertMessageID()
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
})
|
||||
const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
|
||||
|
||||
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(() => {
|
||||
if (!store.messageId) return lastUserMessage()
|
||||
// If the stored message is no longer visible (e.g., was reverted), fall back to last visible
|
||||
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
|
||||
return found ?? lastUserMessage()
|
||||
})
|
||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||
setStore("messageId", message?.id)
|
||||
}
|
||||
|
||||
function navigateMessageByOffset(offset: number) {
|
||||
const msgs = visibleUserMessages()
|
||||
if (msgs.length === 0) return
|
||||
|
||||
const current = activeMessage()
|
||||
const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
|
||||
|
||||
let targetIndex: number
|
||||
if (currentIndex === -1) {
|
||||
targetIndex = offset > 0 ? 0 : msgs.length - 1
|
||||
} else {
|
||||
targetIndex = currentIndex + offset
|
||||
}
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= msgs.length) return
|
||||
|
||||
setActiveMessage(msgs[targetIndex])
|
||||
}
|
||||
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
|
||||
let inputRef!: HTMLDivElement
|
||||
|
||||
createEffect(() => {
|
||||
if (!params.id) return
|
||||
sync.session.sync(params.id)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (layout.terminal.opened()) {
|
||||
if (terminal.all().length === 0) {
|
||||
terminal.new()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => visibleUserMessages().at(-1)?.id,
|
||||
(lastId, prevLastId) => {
|
||||
if (lastId && prevLastId && lastId > prevLastId) {
|
||||
setStore("messageId", undefined)
|
||||
}
|
||||
},
|
||||
{ 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 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(() => [
|
||||
{
|
||||
id: "session.new",
|
||||
title: "New session",
|
||||
description: "Create a new session",
|
||||
category: "Session",
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
},
|
||||
{
|
||||
id: "file.open",
|
||||
title: "Open file",
|
||||
description: "Search and open a file",
|
||||
category: "File",
|
||||
keybind: "mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile />),
|
||||
},
|
||||
// {
|
||||
// id: "theme.toggle",
|
||||
// title: "Toggle theme",
|
||||
// description: "Switch between themes",
|
||||
// category: "View",
|
||||
// keybind: "ctrl+t",
|
||||
// slash: "theme",
|
||||
// onSelect: () => {
|
||||
// const currentTheme = localStorage.getItem("theme") ?? "oc-1"
|
||||
// const themes = ["oc-1", "oc-2-paper"]
|
||||
// const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
|
||||
// localStorage.setItem("theme", nextTheme)
|
||||
// document.documentElement.setAttribute("data-theme", nextTheme)
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: "terminal.toggle",
|
||||
title: "Toggle terminal",
|
||||
description: "Show or hide the terminal",
|
||||
category: "View",
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => layout.terminal.toggle(),
|
||||
},
|
||||
{
|
||||
id: "review.toggle",
|
||||
title: "Toggle review",
|
||||
description: "Show or hide the review panel",
|
||||
category: "View",
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => layout.review.toggle(),
|
||||
},
|
||||
{
|
||||
id: "terminal.new",
|
||||
title: "New terminal",
|
||||
description: "Create a new terminal tab",
|
||||
category: "Terminal",
|
||||
keybind: "ctrl+shift+`",
|
||||
onSelect: () => terminal.new(),
|
||||
},
|
||||
{
|
||||
id: "steps.toggle",
|
||||
title: "Toggle steps",
|
||||
description: "Show or hide the steps",
|
||||
category: "View",
|
||||
keybind: "mod+e",
|
||||
slash: "steps",
|
||||
disabled: !params.id,
|
||||
onSelect: () => setStore("stepsExpanded", (x) => !x),
|
||||
},
|
||||
{
|
||||
id: "message.previous",
|
||||
title: "Previous message",
|
||||
description: "Go to the previous user message",
|
||||
category: "Session",
|
||||
keybind: "mod+arrowup",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
},
|
||||
{
|
||||
id: "message.next",
|
||||
title: "Next message",
|
||||
description: "Go to the next user message",
|
||||
category: "Session",
|
||||
keybind: "mod+arrowdown",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
},
|
||||
{
|
||||
id: "model.choose",
|
||||
title: "Choose model",
|
||||
description: "Select a different model",
|
||||
category: "Model",
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: () => dialog.show(() => <DialogSelectModel />),
|
||||
},
|
||||
{
|
||||
id: "mcp.toggle",
|
||||
title: "Toggle MCPs",
|
||||
description: "Toggle MCPs",
|
||||
category: "MCP",
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: () => dialog.show(() => <DialogSelectMcp />),
|
||||
},
|
||||
{
|
||||
id: "agent.cycle",
|
||||
title: "Cycle agent",
|
||||
description: "Switch to the next agent",
|
||||
category: "Agent",
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
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",
|
||||
title: "Undo",
|
||||
description: "Undo the last message",
|
||||
category: "Session",
|
||||
slash: "undo",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (status()?.type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
const revert = info()?.revert?.messageID
|
||||
// Find the last user message that's not already reverted
|
||||
const message = userMessages().findLast((x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
// Restore the prompt from the reverted message
|
||||
const parts = sync.data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts)
|
||||
prompt.set(restored)
|
||||
}
|
||||
// Navigate to the message before the reverted one (which will be the new last visible message)
|
||||
const priorMessage = userMessages().findLast((x) => x.id < message.id)
|
||||
setActiveMessage(priorMessage)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.redo",
|
||||
title: "Redo",
|
||||
description: "Redo the last undone message",
|
||||
category: "Session",
|
||||
slash: "redo",
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!nextMessage) {
|
||||
// Full unrevert - restore all messages and navigate to last
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
// Navigate to the last message (the one that was at the revert point)
|
||||
const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
|
||||
setActiveMessage(lastMsg)
|
||||
return
|
||||
}
|
||||
// Partial redo - move forward to next message
|
||||
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
// Navigate to the message before the new revert point
|
||||
const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
|
||||
setActiveMessage(priorMsg)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement as HTMLElement | undefined
|
||||
if (activeElement) {
|
||||
const isProtected = activeElement.closest("[data-prevent-autofocus]")
|
||||
const isInput = /^(INPUT|TEXTAREA|SELECT)$/.test(activeElement.tagName) || activeElement.isContentEditable
|
||||
if (isProtected || isInput) return
|
||||
}
|
||||
if (dialog.active) return
|
||||
|
||||
if (activeElement === inputRef) {
|
||||
if (event.key === "Escape") inputRef?.blur()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
const resetClickTimer = () => {
|
||||
if (!store.clickTimer) return
|
||||
clearTimeout(store.clickTimer)
|
||||
setStore("clickTimer", undefined)
|
||||
}
|
||||
|
||||
const startClickTimer = () => {
|
||||
const newClickTimer = setTimeout(() => {
|
||||
setStore("clickTimer", undefined)
|
||||
}, 300)
|
||||
setStore("clickTimer", newClickTimer as unknown as number)
|
||||
}
|
||||
|
||||
const handleTabClick = async (tab: string) => {
|
||||
if (store.clickTimer) {
|
||||
resetClickTimer()
|
||||
} else {
|
||||
if (tab.startsWith("file://")) {
|
||||
local.file.open(tab.replace("file://", ""))
|
||||
}
|
||||
startClickTimer()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
setStore("activeDraggable", id)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentTabs = tabs().all()
|
||||
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
|
||||
const toIndex = currentTabs?.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex && toIndex !== undefined) {
|
||||
tabs().move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setStore("activeDraggable", undefined)
|
||||
}
|
||||
|
||||
const handleTerminalDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
setStore("activeTerminalDraggable", id)
|
||||
}
|
||||
|
||||
const handleTerminalDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const terminals = terminal.all()
|
||||
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
|
||||
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
|
||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||
terminal.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTerminalDragEnd = () => {
|
||||
setStore("activeTerminalDraggable", undefined)
|
||||
}
|
||||
|
||||
const SortableTerminalTab = (props: { terminal: LocalPTY }): JSX.Element => {
|
||||
const sortable = createSortable(props.terminal.id)
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||
<div class="relative h-full">
|
||||
<Tabs.Trigger
|
||||
value={props.terminal.id}
|
||||
closeButton={
|
||||
terminal.all().length > 1 && (
|
||||
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{props.terminal.title}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon
|
||||
node={props.file}
|
||||
classList={{
|
||||
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
|
||||
"grayscale-0": props.active,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
classList={{
|
||||
"text-14-medium": true,
|
||||
"text-primary": !!props.file.status?.status,
|
||||
italic: !props.file.pinned,
|
||||
}}
|
||||
>
|
||||
{props.file.name}
|
||||
</span>
|
||||
<span class="hidden opacity-70">
|
||||
<Switch>
|
||||
<Match when={props.file.status?.status === "modified"}>
|
||||
<span class="text-primary">M</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "added"}>
|
||||
<span class="text-success">A</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "deleted"}>
|
||||
<span class="text-error">D</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SortableTab = (props: {
|
||||
tab: string
|
||||
onTabClick: (tab: string) => void
|
||||
onTabClose: (tab: string) => void
|
||||
}): JSX.Element => {
|
||||
const sortable = createSortable(props.tab)
|
||||
const [file] = createResource(
|
||||
() => props.tab,
|
||||
async (tab) => {
|
||||
if (tab.startsWith("file://")) {
|
||||
return local.file.node(tab.replace("file://", ""))
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||
<div class="relative h-full">
|
||||
<Tabs.Trigger
|
||||
value={props.tab}
|
||||
closeButton={
|
||||
<Tooltip value="Close tab" placement="bottom">
|
||||
<IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />
|
||||
</Tooltip>
|
||||
}
|
||||
hideCloseButton
|
||||
onClick={() => props.onTabClick(props.tab)}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={file()}>{(f) => <FileVisual file={f()} />}</Match>
|
||||
</Switch>
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0))
|
||||
|
||||
const mobileWorking = createMemo(() => status().type !== "idle")
|
||||
const mobileAutoScroll = createAutoScroll({
|
||||
working: mobileWorking,
|
||||
onUserInteracted: () => setStore("userInteracted", true),
|
||||
})
|
||||
|
||||
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 (
|
||||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||
<div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
|
||||
<Switch>
|
||||
<Match when={!params.id}>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<NewSessionView />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={diffs().length > 0}>
|
||||
<Tabs class="flex-1 min-h-0 flex flex-col pb-28">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
|
||||
Session
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
|
||||
{diffs().length} Files Changed
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="session" class="flex-1 !overflow-hidden">
|
||||
<MobileTurns />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content forceMount value="review" class="flex-1 !overflow-hidden hidden data-[selected]:block">
|
||||
<div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
|
||||
<SessionReview
|
||||
diffs={diffs()}
|
||||
classes={{
|
||||
root: "pb-32",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<MobileTurns />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
<div class="absolute inset-x-0 bottom-4 flex flex-col justify-center items-center z-50 px-4">
|
||||
<div class="w-full">
|
||||
<PromptInput
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<Show when={layout.terminal.opened()}>
|
||||
<div
|
||||
class="hidden md:flex relative w-full flex-col shrink-0 border-t border-border-weak-base"
|
||||
style={{ height: `${layout.terminal.height()}px` }}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="vertical"
|
||||
size={layout.terminal.height()}
|
||||
min={100}
|
||||
max={window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={layout.terminal.resize}
|
||||
onCollapse={layout.terminal.close}
|
||||
/>
|
||||
<DragDropProvider
|
||||
onDragStart={handleTerminalDragStart}
|
||||
onDragEnd={handleTerminalDragEnd}
|
||||
onDragOver={handleTerminalDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
|
||||
<Tabs.List class="h-10">
|
||||
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
||||
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-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} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<For each={terminal.all()}>
|
||||
{(pty) => (
|
||||
<Tabs.Content value={pty.id}>
|
||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeTerminalDraggable}>
|
||||
{(draggedId) => {
|
||||
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
||||
return (
|
||||
<Show when={pty()}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{t().title}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
</Show>
|
||||
<StatusBar>
|
||||
<SessionLspIndicator />
|
||||
<SessionMcpIndicator />
|
||||
</StatusBar>
|
||||
</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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user