Compare commits
641
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2311bbf81 | ||
|
|
0cf0294787 | ||
|
|
3c41e4e8f1 | ||
|
|
66bc046503 | ||
|
|
6e68ea034c | ||
|
|
c51fa7cb24 | ||
|
|
a4c67515c9 | ||
|
|
1d2d710fce | ||
|
|
2fd97377f6 | ||
|
|
47ebb2973f | ||
|
|
49d7ccd1db | ||
|
|
c996f3d847 | ||
|
|
70881b2937 | ||
|
|
d8753cda02 | ||
|
|
2685de2a33 | ||
|
|
ddb1ec294e | ||
|
|
fbd9677932 | ||
|
|
814e513db7 | ||
|
|
c600114db9 | ||
|
|
038cff4a93 | ||
|
|
741cb9c0ef | ||
|
|
38e5adc491 | ||
|
|
389a5fc017 | ||
|
|
d60393835c | ||
|
|
e6ba241045 | ||
|
|
cd2c160cf6 | ||
|
|
0f34634c52 | ||
|
|
a5a569f892 | ||
|
|
afc1825cf5 | ||
|
|
6b4c433e14 | ||
|
|
797d8425e0 | ||
|
|
260eef2d66 | ||
|
|
93f1e1afb8 | ||
|
|
6acd16dde4 | ||
|
|
6647b1e22f | ||
|
|
b8872d9d20 | ||
|
|
78940d5b7e | ||
|
|
b84a1f714b | ||
|
|
07c008fe3d | ||
|
|
dad9c917d2 | ||
|
|
2aaea71eb3 | ||
|
|
db8d83b53d | ||
|
|
963f407062 | ||
|
|
4f1ef93910 | ||
|
|
05eee679a3 | ||
|
|
154c52c4d9 | ||
|
|
680db7b9e4 | ||
|
|
7aa1dbe873 | ||
|
|
76186d19f3 | ||
|
|
7760b33956 | ||
|
|
99794c25b0 | ||
|
|
351ddeed91 | ||
|
|
dccb8875ad | ||
|
|
5f2be55e54 | ||
|
|
8b35d56a48 | ||
|
|
9be944a2d2 | ||
|
|
5138f9250e | ||
|
|
e503654252 | ||
|
|
8ebc601ea2 | ||
|
|
7a3ff5b98f | ||
|
|
3b03324578 | ||
|
|
35fff0ca70 | ||
|
|
dc8586371c | ||
|
|
41f9a58c27 | ||
|
|
01237c5325 | ||
|
|
6e7fc30f94 | ||
|
|
03733b0505 | ||
|
|
d1a4295a32 | ||
|
|
6341ed506c | ||
|
|
ed745df375 | ||
|
|
80db008419 | ||
|
|
4039670a24 | ||
|
|
d59357c89b | ||
|
|
3331b0600a | ||
|
|
c131dd0829 | ||
|
|
1c25f1fae0 | ||
|
|
2da71e0a50 | ||
|
|
87978b1c17 | ||
|
|
63d2b21b8f | ||
|
|
9a1dc1ffe4 | ||
|
|
c93e7621be | ||
|
|
e842205550 | ||
|
|
b2aa387376 | ||
|
|
34aecda47c | ||
|
|
b419b0ec55 | ||
|
|
538ac208e1 | ||
|
|
16957fd107 | ||
|
|
7f3a0b8e5c | ||
|
|
d4a2652eda | ||
|
|
7a4bfbe56d | ||
|
|
31e2c8b5e9 | ||
|
|
eab23738a8 | ||
|
|
93845db462 | ||
|
|
65bc72098b | ||
|
|
b5546dce80 | ||
|
|
3807364e73 | ||
|
|
3a1cfa6c73 | ||
|
|
a2857bba83 | ||
|
|
97a0fd1d54 | ||
|
|
87f9ebd17c | ||
|
|
e7422ee782 | ||
|
|
dc3e731afe | ||
|
|
de50e7f9b9 | ||
|
|
f3db966317 | ||
|
|
decc616c80 | ||
|
|
e0450e74fb | ||
|
|
094af4dc7d | ||
|
|
2dc14718aa | ||
|
|
b97d20f252 | ||
|
|
fcfcdd95e9 | ||
|
|
c42bd492ea | ||
|
|
840fe030ab | ||
|
|
a7c4f83ca2 | ||
|
|
ed70a07201 | ||
|
|
18a5eb205f | ||
|
|
5249f04ea0 | ||
|
|
6e3ead198e | ||
|
|
4309d439f5 | ||
|
|
2ec6a21cc0 | ||
|
|
ebf5ad25c5 | ||
|
|
8aa34ab9f3 | ||
|
|
50ef866a02 | ||
|
|
3650fefe2d | ||
|
|
22091c29f1 | ||
|
|
e7e89dc5a6 | ||
|
|
34e9392bb4 | ||
|
|
05c3bc27ff | ||
|
|
b1a6333d17 | ||
|
|
5c9d619620 | ||
|
|
dfb9caa2a9 | ||
|
|
57a2b5f444 | ||
|
|
977c9a3e2c | ||
|
|
db84ee17f4 | ||
|
|
0b1f6a7d2d | ||
|
|
a6d225558c | ||
|
|
2434965b7f | ||
|
|
d4cf78bceb | ||
|
|
ed4ce67cdc | ||
|
|
94dca309e9 | ||
|
|
52e4dd110b | ||
|
|
1e74560796 | ||
|
|
48f2419d9d | ||
|
|
b9ef09a0f4 | ||
|
|
eb81994a18 | ||
|
|
a3819e088c | ||
|
|
324ae9c471 | ||
|
|
7349626757 | ||
|
|
76c25ef286 | ||
|
|
c8b3b31d27 | ||
|
|
81fef60266 | ||
|
|
3fe5d91372 | ||
|
|
7adb6e495a | ||
|
|
2039c6936f | ||
|
|
a02fefe9dc | ||
|
|
cb0e05db26 | ||
|
|
b9cdcaa9db | ||
|
|
94453eb1bd | ||
|
|
8f629db988 | ||
|
|
585378cba0 | ||
|
|
8cd8393339 | ||
|
|
b184b2fb73 | ||
|
|
c88c2da9be | ||
|
|
9b04081ae0 | ||
|
|
7d2d87fa2c | ||
|
|
787f37b382 | ||
|
|
8fa1af851c | ||
|
|
73bc3e704e | ||
|
|
8d2feed30e | ||
|
|
2d8d4e5dee | ||
|
|
b3784588ae | ||
|
|
104d52bc38 | ||
|
|
dff1fe2d28 | ||
|
|
72ab4260ee | ||
|
|
9e9b4a0555 | ||
|
|
e53192889c | ||
|
|
23bbfb3d15 | ||
|
|
37da005a01 | ||
|
|
8b708242f1 | ||
|
|
339d2dcb98 | ||
|
|
bbc8678164 | ||
|
|
a1d54475fe | ||
|
|
55c601d13a | ||
|
|
9115fac4c4 | ||
|
|
cfcb2c1fd8 | ||
|
|
221fc62135 | ||
|
|
faaef45384 | ||
|
|
2d18d80ac3 | ||
|
|
e0e07c5d48 | ||
|
|
281f9e6236 | ||
|
|
f88903a901 | ||
|
|
ad425a6a6a | ||
|
|
e635d37027 | ||
|
|
97081484d5 | ||
|
|
e451504496 | ||
|
|
53211c5d37 | ||
|
|
98b6817e20 | ||
|
|
f54d5377a4 | ||
|
|
a576fdb5e4 | ||
|
|
ae53f876f1 | ||
|
|
a7beba5aa9 | ||
|
|
e9ef72c20f | ||
|
|
fa1ac7bc95 | ||
|
|
c82ab649e2 | ||
|
|
abc7eed92b | ||
|
|
1670d220da | ||
|
|
ddc4e34731 | ||
|
|
af99d83709 | ||
|
|
ed0c0d90be | ||
|
|
e1dd9c4ccb | ||
|
|
4657fa823f | ||
|
|
1d589c7ac7 | ||
|
|
6b5a0fb261 | ||
|
|
6d93a7bf55 | ||
|
|
4ca7ab6be8 | ||
|
|
713d996b9f | ||
|
|
aa16610021 | ||
|
|
d98568fe7e | ||
|
|
1da3550c4d | ||
|
|
0c48e6a116 | ||
|
|
ef266b2c74 | ||
|
|
0a1cdc7a58 | ||
|
|
2dec956a17 | ||
|
|
ef8388f0ee | ||
|
|
e5c5b5e872 | ||
|
|
a1c9a1b8c5 | ||
|
|
76b012139a | ||
|
|
02e5a19242 | ||
|
|
af967648cb | ||
|
|
504a668a26 | ||
|
|
5efb1c7b2d | ||
|
|
fd973d242e | ||
|
|
c3d8672753 | ||
|
|
fe8ef041f6 | ||
|
|
c841de947e | ||
|
|
825dfd48b1 | ||
|
|
923d114ffa | ||
|
|
b157fd10a7 | ||
|
|
67ebe68160 | ||
|
|
7b63c14154 | ||
|
|
cdc11cde2e | ||
|
|
9721223b7e | ||
|
|
35a626e711 | ||
|
|
bb7b0ff221 | ||
|
|
68b4038196 | ||
|
|
3109214900 | ||
|
|
86ccc3409b | ||
|
|
a89089c88f | ||
|
|
e617c5d689 | ||
|
|
31983ca5ff | ||
|
|
59e3b7409f | ||
|
|
b7ce46f7a1 | ||
|
|
82b8d8fa5d | ||
|
|
77c837eb1a | ||
|
|
db77cc9845 | ||
|
|
68043edae6 | ||
|
|
337681dbbf | ||
|
|
66afc034d1 | ||
|
|
11ab8de59f | ||
|
|
5f074edc3a | ||
|
|
56b5cdf883 | ||
|
|
fb0e1e4d8d | ||
|
|
b745b1593f | ||
|
|
7376c3f8e7 | ||
|
|
831e9bce51 | ||
|
|
5de73abd82 | ||
|
|
3adbbc1b23 | ||
|
|
c6c29b3dcf | ||
|
|
a687d7c15f | ||
|
|
0c6da69f39 | ||
|
|
c4930eb6b2 | ||
|
|
a24549fce7 | ||
|
|
c0f9b13630 | ||
|
|
98fd53fd5f | ||
|
|
5b02a3029e | ||
|
|
94e851c2a2 | ||
|
|
1658a3ff59 | ||
|
|
9c8bc64138 | ||
|
|
80f704ebbf | ||
|
|
4dae6d1fcf | ||
|
|
5d2cab39da | ||
|
|
6963f96d4b | ||
|
|
05a9e7ce7a | ||
|
|
896d18ab3f | ||
|
|
893888536a | ||
|
|
c6221fc8b3 | ||
|
|
ae67f43ff0 | ||
|
|
76880dce0d | ||
|
|
aafffb5b4b | ||
|
|
a71c9e3f2e | ||
|
|
0156f03e0e | ||
|
|
e0bb96a9f9 | ||
|
|
82e5d6d458 | ||
|
|
a4411c21b6 | ||
|
|
9d61370ac4 | ||
|
|
f3febd6e39 | ||
|
|
f12d55bf1e | ||
|
|
0c19b71f42 | ||
|
|
70fa66397e | ||
|
|
6e8cd3174c | ||
|
|
5bfffbe083 | ||
|
|
29d8557d41 | ||
|
|
ffd20b4477 | ||
|
|
2abaa46e23 | ||
|
|
0cbbb20d22 | ||
|
|
81c5e7b9ed | ||
|
|
ddf4897eaa | ||
|
|
040939fb72 | ||
|
|
f89b83a6d7 | ||
|
|
82a876da4d | ||
|
|
69a15ae9c1 | ||
|
|
18c8e5f451 | ||
|
|
ba3a1cfa0b | ||
|
|
d8563160f7 | ||
|
|
4a9ff9412e | ||
|
|
d6db6ff198 | ||
|
|
79c263494f | ||
|
|
1b5bf32ce5 | ||
|
|
2e972b3fdc | ||
|
|
d70e9fb01e | ||
|
|
fc082a0f14 | ||
|
|
953e4e9446 | ||
|
|
7ea0d37ee3 | ||
|
|
e35d97f9d7 | ||
|
|
2c0d9a46cb | ||
|
|
2fe7a7f2d3 | ||
|
|
8a2f4ddf70 | ||
|
|
7a94d7a2c5 | ||
|
|
de28fafb47 | ||
|
|
9d485dd307 | ||
|
|
613813ac12 | ||
|
|
7617f59441 | ||
|
|
7aecb43e84 | ||
|
|
21eba5f987 | ||
|
|
c523ca4127 | ||
|
|
685f3ea324 | ||
|
|
4667d57e3c | ||
|
|
e6b9988fa4 | ||
|
|
3c02d5d338 | ||
|
|
bfb9787361 | ||
|
|
1bcc72c477 | ||
|
|
4385fa4dd7 | ||
|
|
2b054bec95 | ||
|
|
2cdc88d295 | ||
|
|
f8fb08b3b4 | ||
|
|
ed06de5e30 | ||
|
|
52b99622ad | ||
|
|
a15397cd89 | ||
|
|
da394439a1 | ||
|
|
390b0a79b3 | ||
|
|
b2f45d574f | ||
|
|
1e2ef07c97 | ||
|
|
664e6bf2d0 | ||
|
|
160c8ab7cc | ||
|
|
1626341a4a | ||
|
|
61ddd1716d | ||
|
|
053a10e515 | ||
|
|
e1c1b1340b | ||
|
|
7a5fbdf67c | ||
|
|
9afc451020 | ||
|
|
f4fdf0eb03 | ||
|
|
505068d5a6 | ||
|
|
2e10ffac6b | ||
|
|
4abaa052db | ||
|
|
1bcf8d8806 | ||
|
|
25c68c8061 | ||
|
|
b0e4408ecf | ||
|
|
8416db03ef | ||
|
|
d5b47d9128 | ||
|
|
634559760a | ||
|
|
155ba794cf | ||
|
|
f1ab427f0e | ||
|
|
2333af6ed3 | ||
|
|
54588b4570 | ||
|
|
26e7043718 | ||
|
|
dd569c927a | ||
|
|
cf38884778 | ||
|
|
2946a6d9a7 | ||
|
|
3522c460e3 | ||
|
|
b6a264819e | ||
|
|
46c7a41d5f | ||
|
|
7cc4b24ac2 | ||
|
|
281ce4c0c3 | ||
|
|
f59d274d0f | ||
|
|
8886c78dce | ||
|
|
d9f0f58277 | ||
|
|
effa7b45cf | ||
|
|
b307075063 | ||
|
|
aaf9a5d434 | ||
|
|
e9c2f1f3f3 | ||
|
|
7469cba7cf | ||
|
|
5420702f69 | ||
|
|
583751ecae | ||
|
|
d0a1b5ef96 | ||
|
|
42f2bc7199 | ||
|
|
603dae562a | ||
|
|
650bd76370 | ||
|
|
8aa3520683 | ||
|
|
5b5b8c57d9 | ||
|
|
f057b22e20 | ||
|
|
388d40e41f | ||
|
|
f397c92ddf | ||
|
|
6f9bea4e1f | ||
|
|
5c49b4cbfc | ||
|
|
b746e831e2 | ||
|
|
2178deef91 | ||
|
|
b1d2fb5319 | ||
|
|
2284a4e6df | ||
|
|
ad852d9186 | ||
|
|
8a9b4245b4 | ||
|
|
76ac1ccb6b | ||
|
|
e71bc8c0b0 | ||
|
|
a5301e2ab7 | ||
|
|
8eac72341f | ||
|
|
bd139b4bd6 | ||
|
|
508578bf17 | ||
|
|
607d8aafb7 | ||
|
|
5843eca7d6 | ||
|
|
ff3b68bd36 | ||
|
|
474b6fd3d1 | ||
|
|
6145b197f3 | ||
|
|
918eff9233 | ||
|
|
987e444828 | ||
|
|
99633cb299 | ||
|
|
f822331eb8 | ||
|
|
0f053769db | ||
|
|
ceeaf494c4 | ||
|
|
126d887e57 | ||
|
|
e5cfc24d6b | ||
|
|
7f8d659737 | ||
|
|
4b061653f2 | ||
|
|
eeed89f985 | ||
|
|
8ab533b616 | ||
|
|
09a399d8d6 | ||
|
|
b75575884a | ||
|
|
5688c9fd61 | ||
|
|
08a075df61 | ||
|
|
a2e8737114 | ||
|
|
776a394b02 | ||
|
|
5788b33fdf | ||
|
|
0f270c3da4 | ||
|
|
376019e347 | ||
|
|
44b773a6f6 | ||
|
|
df97774f7f | ||
|
|
eeff62a912 | ||
|
|
3fc6c42f5f | ||
|
|
967d8238be | ||
|
|
bff7518a24 | ||
|
|
8eab677094 | ||
|
|
db57e7023a | ||
|
|
ede4e467db | ||
|
|
aa1c560e5e | ||
|
|
3aca9e5fa5 | ||
|
|
9e96d83164 | ||
|
|
4275907df6 | ||
|
|
6097d6af86 | ||
|
|
09d2febe27 | ||
|
|
2c5c1ecb5e | ||
|
|
99e2112807 | ||
|
|
4b6575999d | ||
|
|
1a9ee3080c | ||
|
|
f4d61be8bd | ||
|
|
8b40e38cd7 | ||
|
|
7396d495ee | ||
|
|
f9b5ce180a | ||
|
|
12ee9d51c3 | ||
|
|
2730e0c9cd | ||
|
|
d6c81d6e14 | ||
|
|
e8ac0b663b | ||
|
|
2806f240ea | ||
|
|
9898fbe8ef | ||
|
|
1bd8e61719 | ||
|
|
b6c07cb1b8 | ||
|
|
83f23817ce | ||
|
|
23b1d7c755 | ||
|
|
ef033db9c2 | ||
|
|
e30d5d8e34 | ||
|
|
698cfb57a1 | ||
|
|
27e72f2652 | ||
|
|
10eed6ee7e | ||
|
|
59b87f60f7 | ||
|
|
d10089a0bf | ||
|
|
ae7286c031 | ||
|
|
52048c327d | ||
|
|
4e1a9b6216 | ||
|
|
1995be3599 | ||
|
|
86b9b7b15a | ||
|
|
a90f2b9723 | ||
|
|
c73a17f8af | ||
|
|
48898fda07 | ||
|
|
c573732ddb | ||
|
|
ab2a6c45a3 | ||
|
|
66563fb974 | ||
|
|
fbece0dc4d | ||
|
|
1d9e181da0 | ||
|
|
c81721e9fc | ||
|
|
a94899ed36 | ||
|
|
b18d22498c | ||
|
|
c75584a31b | ||
|
|
b474f65547 | ||
|
|
c352999b41 | ||
|
|
f4cd708ca0 | ||
|
|
c20f2731ab | ||
|
|
01ca1a384a | ||
|
|
f330dadd89 | ||
|
|
43e92b4932 | ||
|
|
83397ebde2 | ||
|
|
fde74a72bb | ||
|
|
10ee8e5b3d | ||
|
|
96d3f1fe7c | ||
|
|
1a2b656c4d | ||
|
|
161e9287a8 | ||
|
|
968543af39 | ||
|
|
5af35117db | ||
|
|
eab177f5e7 | ||
|
|
279dc04b3c | ||
|
|
cbc5903aa1 | ||
|
|
81c3c63895 | ||
|
|
b76bd4141d | ||
|
|
794fe8f381 | ||
|
|
a4eebf9f08 | ||
|
|
680a63e3de | ||
|
|
3a54ab68d1 | ||
|
|
44fd0eee64 | ||
|
|
ac371d2987 | ||
|
|
a7baa5ce18 | ||
|
|
b129f809b9 | ||
|
|
92c0ab51e2 | ||
|
|
b25418e68b | ||
|
|
046e351140 | ||
|
|
b9029afa22 | ||
|
|
b229aeec0b | ||
|
|
c9140c6bab | ||
|
|
38551bda38 | ||
|
|
cd16d31510 | ||
|
|
54ba1af5d6 | ||
|
|
fe3144ce5b | ||
|
|
a1c0bae3af | ||
|
|
85f8655dfd | ||
|
|
9b6c9f64f7 | ||
|
|
1aae1c795d | ||
|
|
526c723e62 | ||
|
|
6011200128 | ||
|
|
740fcd243c | ||
|
|
e4d8a117c4 | ||
|
|
8c4a816cf6 | ||
|
|
5605fc3f38 | ||
|
|
009b096004 | ||
|
|
64f898601b | ||
|
|
224e5466c1 | ||
|
|
87b5b34280 | ||
|
|
855fd07d22 | ||
|
|
f9be2bab3a | ||
|
|
25f1643e8e | ||
|
|
e015bea462 | ||
|
|
7dc55ac3ca | ||
|
|
cd8ecf9722 | ||
|
|
eb021a5f92 | ||
|
|
7f5e30834f | ||
|
|
750a936ae1 | ||
|
|
8dfef670b3 | ||
|
|
1b1b73b5b3 | ||
|
|
6baee0791f | ||
|
|
291b65977c | ||
|
|
90f232d7f1 | ||
|
|
af214d35cb | ||
|
|
3f0afd7cf6 | ||
|
|
0545c5da2d | ||
|
|
4a32fa6f02 | ||
|
|
29c99ed4ab | ||
|
|
753abbe164 | ||
|
|
8e01f6cc13 | ||
|
|
33c0b125cb | ||
|
|
dab2e54df8 | ||
|
|
60db171b44 | ||
|
|
c6e9a5c800 | ||
|
|
2c16b9fa61 | ||
|
|
240ad31edd | ||
|
|
a97631f769 | ||
|
|
dbaac79039 | ||
|
|
a05915ddc8 | ||
|
|
eebbd73346 | ||
|
|
d4c981495a | ||
|
|
653c206688 | ||
|
|
580f46b589 | ||
|
|
986d12fd20 | ||
|
|
d04a72a4ad | ||
|
|
5fd873a35a | ||
|
|
a9fbd786b3 | ||
|
|
abde984b3e | ||
|
|
a95aa037a3 | ||
|
|
11a92b24c2 | ||
|
|
f9c10c62d8 | ||
|
|
6339f39871 | ||
|
|
68b09b30a1 | ||
|
|
92ade2a320 | ||
|
|
cb1a1fb26c | ||
|
|
af5ebabd03 | ||
|
|
fe2626a4ea | ||
|
|
45447e3336 | ||
|
|
7a3e82ec5d | ||
|
|
345f4801e8 | ||
|
|
ac4b8d62e3 | ||
|
|
236ce7a8c0 | ||
|
|
8bdc0c8f79 | ||
|
|
04650f01fe | ||
|
|
02d4594abf | ||
|
|
c1894b4e3d | ||
|
|
2062247e72 | ||
|
|
8785bec29c | ||
|
|
d4b7f75ce3 | ||
|
|
4f73d58031 | ||
|
|
b906f2de88 | ||
|
|
4035afe5c8 | ||
|
|
8fe0715928 | ||
|
|
cb8af962cd | ||
|
|
c333ffa38b | ||
|
|
3456f4ed80 | ||
|
|
2536e9f45b | ||
|
|
9188bc542c | ||
|
|
cbaba10994 | ||
|
|
85d3604309 | ||
|
|
507ba644cf | ||
|
|
3d6f62746a | ||
|
|
2f48c8c05f | ||
|
|
4828fd1eac | ||
|
|
10375263ef | ||
|
|
ae00001aa0 | ||
|
|
f53ebafbab | ||
|
|
23ebc50da9 | ||
|
|
673c6f97b7 | ||
|
|
ec46f71258 | ||
|
|
8865e524cb | ||
|
|
36bb02ae45 | ||
|
|
5072331f04 | ||
|
|
9d48fd4bbd | ||
|
|
bf66390557 | ||
|
|
184643f0db | ||
|
|
1bce898ca7 | ||
|
|
8c895570c6 | ||
|
|
6dc4e5ac93 | ||
|
|
d3922f0965 |
@@ -0,0 +1,72 @@
|
|||||||
|
name: Docs Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 */12 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
LOOKBACK_HOURS: 4
|
||||||
|
|
||||||
|
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="${{ env.LOOKBACK_HOURS }} hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$COMMITS" ]; then
|
||||||
|
echo "No commits in the last ${{ env.LOOKBACK_HOURS }} 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 ${{ env.LOOKBACK_HOURS }} 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.
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Run opencode
|
- name: Run opencode
|
||||||
uses: sst/opencode/github@latest
|
uses: anomalyco/opencode/github@latest
|
||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
OPENCODE_PERMISSION: '{"bash": "deny"}'
|
OPENCODE_PERMISSION: '{"bash": "deny"}'
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ jobs:
|
|||||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||||
NPM_CONFIG_PROVENANCE: false
|
NPM_CONFIG_PROVENANCE: false
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-cli
|
||||||
|
path: packages/opencode/dist
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
release: ${{ steps.publish.outputs.release }}
|
release: ${{ steps.publish.outputs.release }}
|
||||||
tag: ${{ steps.publish.outputs.tag }}
|
tag: ${{ steps.publish.outputs.tag }}
|
||||||
@@ -99,6 +105,8 @@ jobs:
|
|||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
- host: blacksmith-4vcpu-ubuntu-2404
|
- host: blacksmith-4vcpu-ubuntu-2404
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
|
- host: blacksmith-4vcpu-ubuntu-2404-arm
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -143,13 +151,12 @@ jobs:
|
|||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: packages/tauri/src-tauri
|
workspaces: packages/desktop/src-tauri
|
||||||
shared-key: ${{ matrix.settings.target }}
|
shared-key: ${{ matrix.settings.target }}
|
||||||
|
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
if: inputs.bump || inputs.version
|
|
||||||
run: |
|
run: |
|
||||||
cd packages/tauri
|
cd packages/desktop
|
||||||
bun ./scripts/prepare.ts
|
bun ./scripts/prepare.ts
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
|
||||||
@@ -159,6 +166,7 @@ jobs:
|
|||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
RUST_TARGET: ${{ matrix.settings.target }}
|
RUST_TARGET: ${{ matrix.settings.target }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
||||||
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
|
||||||
- name: Install tauri-cli from portable appimage branch
|
- name: Install tauri-cli from portable appimage branch
|
||||||
@@ -183,10 +191,10 @@ jobs:
|
|||||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||||
with:
|
with:
|
||||||
projectPath: packages/tauri
|
projectPath: packages/desktop
|
||||||
uploadWorkflowArtifacts: true
|
uploadWorkflowArtifacts: true
|
||||||
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
||||||
args: --target ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json
|
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
|
||||||
updaterJsonPreferNsis: true
|
updaterJsonPreferNsis: true
|
||||||
releaseId: ${{ needs.publish.outputs.release }}
|
releaseId: ${{ needs.publish.outputs.release }}
|
||||||
tagName: ${{ needs.publish.outputs.tag }}
|
tagName: ${{ needs.publish.outputs.tag }}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
|
Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
|
||||||
|
|
||||||
When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage.
|
When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage.
|
||||||
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
|
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simplest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
|
||||||
|
|
||||||
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
|
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
|
||||||
If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: "Auto-close stale issues"
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "30 1 * * *" # Daily at 1:30 AM
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
DAYS_BEFORE_STALE: 90
|
||||||
|
DAYS_BEFORE_CLOSE: 7
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v10
|
||||||
|
with:
|
||||||
|
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
|
||||||
|
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
close-issue-message: |
|
||||||
|
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
|
||||||
|
|
||||||
|
Feel free to reopen if you still need this!
|
||||||
|
stale-issue-message: |
|
||||||
|
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
|
||||||
|
|
||||||
|
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
|
||||||
|
remove-stale-when-updated: true
|
||||||
|
exempt-issue-labels: "pinned,security,feature-request,on-hold"
|
||||||
|
start-date: "2025-12-27"
|
||||||
@@ -5,8 +5,11 @@ on:
|
|||||||
- cron: "0 12 * * *" # Run daily at 12:00 UTC
|
- cron: "0 12 * * *" # Run daily at 12:00 UTC
|
||||||
workflow_dispatch: # Allow manual trigger
|
workflow_dispatch: # Allow manual trigger
|
||||||
|
|
||||||
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stats:
|
stats:
|
||||||
|
if: github.repository == 'sst/opencode'
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ name: "sync-zed-extension"
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
# release:
|
release:
|
||||||
# types: [published]
|
types: [published]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
zed:
|
zed:
|
||||||
@@ -31,4 +31,5 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
|
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}
|
||||||
|
ZED_PR_PAT: ${{ secrets.ZED_PR_PAT }}
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ name: test
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches:
|
||||||
- production
|
- dev
|
||||||
pull_request:
|
pull_request:
|
||||||
branches-ignore:
|
|
||||||
- production
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -20,3 +20,7 @@ opencode.json
|
|||||||
a.out
|
a.out
|
||||||
target
|
target
|
||||||
.scripts
|
.scripts
|
||||||
|
|
||||||
|
# Local dev files
|
||||||
|
opencode-dev
|
||||||
|
logs/
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
---
|
---
|
||||||
description: ALWAYS use this when writing docs
|
description: ALWAYS use this when writing docs
|
||||||
|
color: "#38A3EE"
|
||||||
---
|
---
|
||||||
|
|
||||||
You are an expert technical documentation writer
|
You are an expert technical documentation writer
|
||||||
|
|
||||||
You are not verbose
|
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 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
|
The description should be one short line, should not start with "The", should
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
---
|
|
||||||
description: Use this agent when you are asked to commit and push code changes to a git repository.
|
|
||||||
mode: subagent
|
|
||||||
---
|
|
||||||
|
|
||||||
You commit and push to git
|
|
||||||
|
|
||||||
Commit messages should be brief since they are used to generate release notes.
|
|
||||||
|
|
||||||
Messages should say WHY the change was made and not WHAT was changed.
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
mode: primary
|
mode: primary
|
||||||
hidden: true
|
hidden: true
|
||||||
model: opencode/claude-haiku-4-5
|
model: opencode/claude-haiku-4-5
|
||||||
|
color: "#44BA81"
|
||||||
tools:
|
tools:
|
||||||
"*": false
|
"*": false
|
||||||
"github-triage": true
|
"github-triage": true
|
||||||
|
|||||||
@@ -10,7 +10,17 @@
|
|||||||
"options": {},
|
"options": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"mcp": {},
|
"permission": {
|
||||||
|
"bash": {
|
||||||
|
"ls foo": "ask",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"context7": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://mcp.context7.com/mcp",
|
||||||
|
},
|
||||||
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"github-triage": false,
|
"github-triage": false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
name: test-skill
|
||||||
|
description: use this when asked to test skill
|
||||||
|
---
|
||||||
|
|
||||||
|
woah this is a test skill
|
||||||
@@ -1,34 +1,4 @@
|
|||||||
## Debugging
|
|
||||||
|
|
||||||
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
||||||
|
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
|
||||||
## Tool Calling
|
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||||
|
- the default branch in this repo is `dev`
|
||||||
- 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+38
-8
@@ -14,10 +14,10 @@ However, any UI or core product feature must go through a design review with the
|
|||||||
|
|
||||||
If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels:
|
If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels:
|
||||||
|
|
||||||
- [`help wanted`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
|
- [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
|
||||||
- [`good first issue`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
|
- [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
|
||||||
- [`bug`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
|
- [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
|
||||||
- [`perf`](https://github.com/sst/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
|
- [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> PRs that ignore these guardrails will likely be closed.
|
> PRs that ignore these guardrails will likely be closed.
|
||||||
@@ -34,6 +34,36 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
|
|||||||
bun dev
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Running against a different directory
|
||||||
|
|
||||||
|
By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun dev <directory>
|
||||||
|
```
|
||||||
|
|
||||||
|
To run OpenCode in the root of the opencode repo itself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun dev .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building a "localcode"
|
||||||
|
|
||||||
|
To compile a standalone executable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packages/opencode/script/build.ts --single
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packages/opencode/dist/opencode-<platform>/bin/opencode
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
|
||||||
|
|
||||||
- Core pieces:
|
- Core pieces:
|
||||||
- `packages/opencode`: OpenCode core business logic & server.
|
- `packages/opencode`: OpenCode core business logic & server.
|
||||||
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
|
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
|
||||||
@@ -53,12 +83,12 @@ your debugger via that URL. Other methods can result in breakpoints being mapped
|
|||||||
|
|
||||||
Caveats:
|
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
|
- 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.
|
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:
|
Other tips and tricks:
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<p align="center">
|
<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://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://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>
|
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -30,8 +30,8 @@ scoop bucket add extras; scoop install extras/opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install opencode # macOS and Linux
|
brew install opencode # macOS and Linux
|
||||||
paru -S opencode-bin # Arch Linux
|
paru -S opencode-bin # Arch Linux
|
||||||
mise use -g github:sst/opencode # Any OS
|
mise use -g opencode # Any OS
|
||||||
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
|
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
@@ -39,7 +39,7 @@ nix run nixpkgs#opencode # or github:sst/opencode for latest dev branc
|
|||||||
|
|
||||||
### Desktop App (BETA)
|
### 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).
|
OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/anomalyco/opencode/releases) or [opencode.ai/download](https://opencode.ai/download).
|
||||||
|
|
||||||
| Platform | Download |
|
| Platform | Download |
|
||||||
| --------------------- | ------------------------------------- |
|
| --------------------- | ------------------------------------- |
|
||||||
@@ -79,7 +79,7 @@ you can switch between these using the `Tab` key.
|
|||||||
- Asks permission before running bash commands
|
- Asks permission before running bash commands
|
||||||
- Ideal for exploring unfamiliar codebases or planning changes
|
- 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.
|
This is used internally and can be invoked using `@general` in messages.
|
||||||
|
|
||||||
Learn more about [agents](https://opencode.ai/docs/agents).
|
Learn more about [agents](https://opencode.ai/docs/agents).
|
||||||
@@ -98,7 +98,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
|||||||
|
|
||||||
### FAQ
|
### 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:
|
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -11,7 +11,7 @@
|
|||||||
<p align="center">
|
<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://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://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>
|
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -30,8 +30,8 @@ scoop bucket add extras; scoop install extras/opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install opencode # macOS 與 Linux
|
brew install opencode # macOS 與 Linux
|
||||||
paru -S opencode-bin # Arch Linux
|
paru -S opencode-bin # Arch Linux
|
||||||
mise use -g github:sst/opencode # 任何作業系統
|
mise use -g github:anomalyco/opencode # 任何作業系統
|
||||||
nix run nixpkgs#opencode # 或使用 github:sst/opencode 以取得最新開發分支
|
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
@@ -39,7 +39,7 @@ nix run nixpkgs#opencode # 或使用 github:sst/opencode 以取得最
|
|||||||
|
|
||||||
### 桌面應用程式 (BETA)
|
### 桌面應用程式 (BETA)
|
||||||
|
|
||||||
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/sst/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。
|
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。
|
||||||
|
|
||||||
| 平台 | 下載連結 |
|
| 平台 | 下載連結 |
|
||||||
| --------------------- | ------------------------------------- |
|
| --------------------- | ------------------------------------- |
|
||||||
|
|||||||
@@ -176,3 +176,16 @@
|
|||||||
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
|
| 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-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-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) |
|
||||||
|
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
|
||||||
|
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
|
||||||
|
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
|
||||||
|
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
|
||||||
|
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
|
||||||
|
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
|
||||||
|
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
[install]
|
[install]
|
||||||
exact = true
|
exact = true
|
||||||
|
|
||||||
|
[test]
|
||||||
|
root = "./do-not-run-tests-from-root"
|
||||||
|
|
||||||
|
|||||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1766025857,
|
"lastModified": 1767273430,
|
||||||
"narHash": "sha256-Lav5jJazCW4mdg1iHcROpuXqmM94BWJvabLFWaJVJp0=",
|
"narHash": "sha256-kDpoFwQ8GLrPiS3KL+sAwreXrph2KhdXuJzo5+vSLoo=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "def3da69945bbe338c373fddad5a1bb49cf199ce",
|
"rev": "76eec3925eb9bbe193934987d3285473dbcfad50",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"aarch64-darwin"
|
"aarch64-darwin"
|
||||||
"x86_64-darwin"
|
"x86_64-darwin"
|
||||||
];
|
];
|
||||||
lib = nixpkgs.lib;
|
inherit (nixpkgs) lib;
|
||||||
forEachSystem = lib.genAttrs systems;
|
forEachSystem = lib.genAttrs systems;
|
||||||
pkgsFor = system: nixpkgs.legacyPackages.${system};
|
pkgsFor = system: nixpkgs.legacyPackages.${system};
|
||||||
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
|
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
|
||||||
@@ -70,12 +70,12 @@
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
default = mkPackage {
|
default = mkPackage {
|
||||||
version = packageJson.version;
|
inherit (packageJson) version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
scripts = ./nix/scripts;
|
scripts = ./nix/scripts;
|
||||||
target = bunTarget.${system};
|
target = bunTarget.${system};
|
||||||
modelsDev = "${modelsDev.${system}}/dist/_api.json";
|
modelsDev = "${modelsDev.${system}}/dist/_api.json";
|
||||||
mkNodeModules = mkNodeModules;
|
inherit mkNodeModules;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-2
@@ -87,7 +87,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Run opencode
|
- name: Run opencode
|
||||||
uses: sst/opencode/github@latest
|
uses: anomalyco/opencode/github@latest
|
||||||
env:
|
env:
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
with:
|
with:
|
||||||
@@ -98,7 +98,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
|
|||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
This is an early release. If you encounter issues or have feedback, please create an issue at https://github.com/sst/opencode/issues.
|
This is an early release. If you encounter issues or have feedback, please create an issue at https://github.com/anomalyco/opencode/issues.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ runs:
|
|||||||
id: version
|
id: version
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(curl -sf https://api.github.com/repos/sst/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
|
VERSION=$(curl -sf https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
|
||||||
echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT
|
echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache opencode
|
- name: Cache opencode
|
||||||
|
|||||||
+5
-2
@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
|
|||||||
connected = true
|
connected = true
|
||||||
break
|
break
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
await Bun.sleep(300)
|
||||||
} while (retry++ < 30)
|
} while (retry++ < 30)
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
@@ -574,10 +574,13 @@ async function subscribeSessionEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function summarize(response: string) {
|
async function summarize(response: string) {
|
||||||
const payload = useContext().payload as IssueCommentEvent
|
|
||||||
try {
|
try {
|
||||||
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (isScheduleEvent()) {
|
||||||
|
return "Scheduled task changes"
|
||||||
|
}
|
||||||
|
const payload = useContext().payload as IssueCommentEvent
|
||||||
return `Fix issue: ${payload.issue.title}`
|
return `Fix issue: ${payload.issue.title}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,3 +44,12 @@ new sst.cloudflare.x.Astro("Web", {
|
|||||||
VITE_API_URL: api.url.apply((url) => url!),
|
VITE_API_URL: api.url.apply((url) => url!),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
new sst.cloudflare.StaticSite("WebApp", {
|
||||||
|
domain: "app." + domain,
|
||||||
|
path: "packages/app",
|
||||||
|
build: {
|
||||||
|
command: "bun turbo build",
|
||||||
|
output: "./dist",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ const ZEN_MODELS = [
|
|||||||
new sst.Secret("ZEN_MODELS3"),
|
new sst.Secret("ZEN_MODELS3"),
|
||||||
new sst.Secret("ZEN_MODELS4"),
|
new sst.Secret("ZEN_MODELS4"),
|
||||||
new sst.Secret("ZEN_MODELS5"),
|
new sst.Secret("ZEN_MODELS5"),
|
||||||
|
new sst.Secret("ZEN_MODELS6"),
|
||||||
]
|
]
|
||||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||||
@@ -118,6 +119,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
|
|||||||
////////////////
|
////////////////
|
||||||
|
|
||||||
const bucket = new sst.cloudflare.Bucket("ZenData")
|
const bucket = new sst.cloudflare.Bucket("ZenData")
|
||||||
|
const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||||
|
|
||||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||||
@@ -136,6 +138,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
path: "packages/console/app",
|
path: "packages/console/app",
|
||||||
link: [
|
link: [
|
||||||
bucket,
|
bucket,
|
||||||
|
bucketNew,
|
||||||
database,
|
database,
|
||||||
AUTH_API_URL,
|
AUTH_API_URL,
|
||||||
STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET,
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -7,7 +7,51 @@ RED='\033[0;31m'
|
|||||||
ORANGE='\033[38;5;214m'
|
ORANGE='\033[38;5;214m'
|
||||||
NC='\033[0m' # No Color
|
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:-}
|
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)
|
raw_os=$(uname -s)
|
||||||
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
||||||
@@ -103,16 +147,26 @@ INSTALL_DIR=$HOME/.opencode/bin
|
|||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
if [ -z "$requested_version" ]; then
|
if [ -z "$requested_version" ]; then
|
||||||
url="https://github.com/sst/opencode/releases/latest/download/$filename"
|
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
|
||||||
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
|
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
||||||
echo -e "${RED}Failed to fetch version information${NC}"
|
echo -e "${RED}Failed to fetch version information${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
|
# Strip leading 'v' if present
|
||||||
|
requested_version="${requested_version#v}"
|
||||||
|
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
|
||||||
specific_version=$requested_version
|
specific_version=$requested_version
|
||||||
|
|
||||||
|
# Verify the release exists before downloading
|
||||||
|
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/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/anomalyco/opencode/releases${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_message() {
|
print_message() {
|
||||||
@@ -304,42 +358,42 @@ case $current_shell in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
config_file=""
|
if [[ "$no_modify_path" != "true" ]]; then
|
||||||
for file in $config_files; do
|
config_file=""
|
||||||
if [[ -f $file ]]; then
|
for file in $config_files; do
|
||||||
config_file=$file
|
if [[ -f $file ]]; then
|
||||||
break
|
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
|
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
|
fi
|
||||||
|
|
||||||
if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": "sha256-cpXmqJQJeFj3eED/aOb4YLUdkZFV//7u4f0STBxzUhk="
|
"nodeModules": "sha256-uJDhOieOdMQLORyuOWtgtjLoMnNEQPrDcyij9TX0aTw="
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-10
@@ -1,18 +1,26 @@
|
|||||||
{ hash, lib, stdenvNoCC, bun, cacert, curl }:
|
{
|
||||||
|
hash,
|
||||||
|
lib,
|
||||||
|
stdenvNoCC,
|
||||||
|
bun,
|
||||||
|
cacert,
|
||||||
|
curl,
|
||||||
|
}:
|
||||||
args:
|
args:
|
||||||
stdenvNoCC.mkDerivation {
|
stdenvNoCC.mkDerivation {
|
||||||
pname = "opencode-node_modules";
|
pname = "opencode-node_modules";
|
||||||
version = args.version;
|
inherit (args) version src;
|
||||||
src = args.src;
|
|
||||||
|
|
||||||
impureEnvVars =
|
impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
|
||||||
lib.fetchers.proxyImpureEnvVars
|
"GIT_PROXY_COMMAND"
|
||||||
++ [
|
"SOCKS_SERVER"
|
||||||
"GIT_PROXY_COMMAND"
|
];
|
||||||
"SOCKS_SERVER"
|
|
||||||
];
|
|
||||||
|
|
||||||
nativeBuildInputs = [ bun cacert curl ];
|
nativeBuildInputs = [
|
||||||
|
bun
|
||||||
|
cacert
|
||||||
|
curl
|
||||||
|
];
|
||||||
|
|
||||||
dontConfigure = true;
|
dontConfigure = true;
|
||||||
|
|
||||||
|
|||||||
+11
-8
@@ -1,7 +1,13 @@
|
|||||||
{ lib, stdenvNoCC, bun, ripgrep, makeBinaryWrapper }:
|
{
|
||||||
|
lib,
|
||||||
|
stdenvNoCC,
|
||||||
|
bun,
|
||||||
|
ripgrep,
|
||||||
|
makeBinaryWrapper,
|
||||||
|
}:
|
||||||
args:
|
args:
|
||||||
let
|
let
|
||||||
scripts = args.scripts;
|
inherit (args) scripts;
|
||||||
mkModules =
|
mkModules =
|
||||||
attrs:
|
attrs:
|
||||||
args.mkNodeModules (
|
args.mkNodeModules (
|
||||||
@@ -14,13 +20,10 @@ let
|
|||||||
in
|
in
|
||||||
stdenvNoCC.mkDerivation (finalAttrs: {
|
stdenvNoCC.mkDerivation (finalAttrs: {
|
||||||
pname = "opencode";
|
pname = "opencode";
|
||||||
version = args.version;
|
inherit (args) version src;
|
||||||
|
|
||||||
src = args.src;
|
|
||||||
|
|
||||||
node_modules = mkModules {
|
node_modules = mkModules {
|
||||||
version = finalAttrs.version;
|
inherit (finalAttrs) version src;
|
||||||
src = finalAttrs.src;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
@@ -122,7 +125,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
|||||||
It combines a TypeScript/JavaScript core with a Go-based TUI
|
It combines a TypeScript/JavaScript core with a Go-based TUI
|
||||||
to provide an interactive AI coding experience.
|
to provide an interactive AI coding experience.
|
||||||
'';
|
'';
|
||||||
homepage = "https://github.com/sst/opencode";
|
homepage = "https://github.com/anomalyco/opencode";
|
||||||
license = lib.licenses.mit;
|
license = lib.licenses.mit;
|
||||||
platforms = [
|
platforms = [
|
||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
|
|||||||
@@ -31,9 +31,13 @@ for (const [name, wasmPath] of byName) {
|
|||||||
next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
|
next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
|
||||||
|
|
||||||
// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
|
// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
|
||||||
|
const nixStorePrefix = process.env.NIX_STORE || "/nix/store"
|
||||||
next = next.replace(/(\.\/)+/g, "./")
|
next = next.replace(/(\.\/)+/g, "./")
|
||||||
next = next.replace(/(\.\.\/)+\/?(\/nix\/store[^"']+)/g, "/$2")
|
next = next.replace(
|
||||||
next = next.replace(/(["'])\/{2,}(\/nix\/store[^"']+)(["'])/g, "$1/$2$3")
|
new RegExp(`(\\.\\.\\/)+\\/{1,2}(${nixStorePrefix.replace(/^\//, "").replace(/\//g, "\\/")}[^"']+)`, "g"),
|
||||||
next = next.replace(/(["'])\/\/(nix\/store[^"']+)(["'])/g, "$1/$2$3")
|
"/$2",
|
||||||
|
)
|
||||||
|
next = next.replace(new RegExp(`(["'])\\/{2,}(\\/${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
|
||||||
|
next = next.replace(new RegExp(`(["'])\\/\\/(${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
|
||||||
|
|
||||||
if (next !== content) fs.writeFileSync(file, next)
|
if (next !== content) fs.writeFileSync(file, next)
|
||||||
|
|||||||
+9
-4
@@ -10,7 +10,8 @@
|
|||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"hello": "echo 'Hello World!'"
|
"hello": "echo 'Hello World!'",
|
||||||
|
"test": "echo 'do not run tests from root' && exit 1"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.0.0-beta.3",
|
"@pierre/diffs": "1.0.2",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
@@ -40,10 +41,13 @@
|
|||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
|
"marked": "17.0.1",
|
||||||
|
"marked-shiki": "1.2.1",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
|
"shiki": "3.20.0",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"virtua": "0.42.3",
|
"virtua": "0.42.3",
|
||||||
@@ -56,6 +60,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@actions/artifact": "5.0.1",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
@@ -64,13 +69,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.933.0",
|
"@aws-sdk/client-s3": "3.933.0",
|
||||||
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sst/opencode"
|
"url": "https://github.com/anomalyco/opencode"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"prettier": {
|
"prettier": {
|
||||||
@@ -80,7 +86,6 @@
|
|||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"sharp",
|
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
"web-tree-sitter"
|
"web-tree-sitter"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
src/assets/theme.css
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## Debugging
|
||||||
|
|
||||||
|
- To test the opencode app, use the playwrite mcp server, the app is already
|
||||||
|
running at http://localhost:3000
|
||||||
|
- NEVER try to restart the app, or the server process, EVER.
|
||||||
|
|
||||||
|
## SolidJS
|
||||||
|
|
||||||
|
- Always prefer `createStore` over multiple `createSignal` calls
|
||||||
|
|
||||||
|
## Tool Calling
|
||||||
|
|
||||||
|
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||||
@@ -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.)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" style="background-color: var(--background-base)">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>OpenCode</title>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#F8F7F7" />
|
||||||
|
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||||
|
<meta property="og:image" content="/social-share.png" />
|
||||||
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
|
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
||||||
|
<script id="oc-theme-preload-script">
|
||||||
|
;(function () {
|
||||||
|
var themeId = localStorage.getItem("opencode-theme-id")
|
||||||
|
if (!themeId) return
|
||||||
|
|
||||||
|
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||||
|
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
|
var mode = isDark ? "dark" : "light"
|
||||||
|
|
||||||
|
document.documentElement.dataset.theme = themeId
|
||||||
|
document.documentElement.dataset.colorScheme = mode
|
||||||
|
|
||||||
|
if (themeId === "oc-1") return
|
||||||
|
|
||||||
|
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||||
|
if (css) {
|
||||||
|
var style = document.createElement("style")
|
||||||
|
style.id = "oc-theme-preload"
|
||||||
|
style.textContent =
|
||||||
|
":root{color-scheme:" +
|
||||||
|
mode +
|
||||||
|
";--text-mix-blend-mode:" +
|
||||||
|
(isDark ? "plus-lighter" : "multiply") +
|
||||||
|
";" +
|
||||||
|
css +
|
||||||
|
"}"
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root" class="flex flex-col h-screen"></div>
|
||||||
|
<script src="/src/entry.tsx" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "@opencode-ai/app",
|
||||||
|
"version": "1.0.224",
|
||||||
|
"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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import "@/index.css"
|
||||||
|
import { ErrorBoundary, Show, type ParentProps } from "solid-js"
|
||||||
|
import { Router, Route, Navigate } from "@solidjs/router"
|
||||||
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
|
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
||||||
|
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
||||||
|
import { Diff } from "@opencode-ai/ui/diff"
|
||||||
|
import { Code } from "@opencode-ai/ui/code"
|
||||||
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
|
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||||
|
import { PermissionProvider } from "@/context/permission"
|
||||||
|
import { LayoutProvider } from "@/context/layout"
|
||||||
|
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||||
|
import { ServerProvider, useServer } from "@/context/server"
|
||||||
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
|
import { PromptProvider } from "@/context/prompt"
|
||||||
|
import { FileProvider } from "@/context/file"
|
||||||
|
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 defaultServerUrl = 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
|
||||||
|
})
|
||||||
|
|
||||||
|
function ServerKey(props: ParentProps) {
|
||||||
|
const server = useServer()
|
||||||
|
return (
|
||||||
|
<Show when={server.url} keyed>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<MetaProvider>
|
||||||
|
<Font />
|
||||||
|
<ThemeProvider>
|
||||||
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
|
<DialogProvider>
|
||||||
|
<MarkedProvider>
|
||||||
|
<DiffComponentProvider component={Diff}>
|
||||||
|
<CodeComponentProvider component={Code}>
|
||||||
|
<ServerProvider defaultUrl={defaultServerUrl}>
|
||||||
|
<ServerKey>
|
||||||
|
<GlobalSDKProvider>
|
||||||
|
<GlobalSyncProvider>
|
||||||
|
<Router
|
||||||
|
root={(props) => (
|
||||||
|
<PermissionProvider>
|
||||||
|
<LayoutProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<Layout>{props.children}</Layout>
|
||||||
|
</CommandProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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 ?? "new"} keyed>
|
||||||
|
<TerminalProvider>
|
||||||
|
<FileProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<Session />
|
||||||
|
</PromptProvider>
|
||||||
|
</FileProvider>
|
||||||
|
</TerminalProvider>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Router>
|
||||||
|
</GlobalSyncProvider>
|
||||||
|
</GlobalSDKProvider>
|
||||||
|
</ServerKey>
|
||||||
|
</ServerProvider>
|
||||||
|
</CodeComponentProvider>
|
||||||
|
</DiffComponentProvider>
|
||||||
|
</MarkedProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</ThemeProvider>
|
||||||
|
</MetaProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
+23
-21
@@ -1,24 +1,24 @@
|
|||||||
|
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
|
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||||
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { List, ListRef } from "@opencode-ai/ui/list"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|
||||||
import { IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { DialogSelectModel } from "./dialog-select-model"
|
import { DialogSelectModel } from "./dialog-select-model"
|
||||||
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -154,7 +154,9 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
|
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<List
|
<List
|
||||||
ref={(ref) => (listRef = ref)}
|
ref={(ref) => {
|
||||||
|
listRef = ref
|
||||||
|
}}
|
||||||
items={methods}
|
items={methods}
|
||||||
key={(m) => m?.label}
|
key={(m) => m?.label}
|
||||||
onSelect={async (method, index) => {
|
onSelect={async (method, index) => {
|
||||||
@@ -163,7 +165,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-4">
|
<div class="w-full flex items-center gap-x-2">
|
||||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||||
<div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
<div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +177,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "pending"}>
|
<Match when={store.state === "pending"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
<div class="flex items-center gap-x-4">
|
<div class="flex items-center gap-x-2">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span>Authorization in progress...</span>
|
<span>Authorization in progress...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +185,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "error"}>
|
<Match when={store.state === "error"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
<div class="flex items-center gap-x-4">
|
<div class="flex items-center gap-x-2">
|
||||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||||
<span>Authorization failed: {store.error}</span>
|
<span>Authorization failed: {store.error}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
|
|
||||||
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
|
function getFilename(input: string) {
|
||||||
|
const parts = input.split("/")
|
||||||
|
return parts[parts.length - 1] || input
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogEditProject(props: { project: LocalProject }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const globalSDK = useGlobalSDK()
|
||||||
|
|
||||||
|
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||||
|
const defaultName = createMemo(() => props.project.name || folderName())
|
||||||
|
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
name: defaultName(),
|
||||||
|
color: props.project.icon?.color || "pink",
|
||||||
|
iconUrl: props.project.icon?.url || "",
|
||||||
|
saving: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [dragOver, setDragOver] = createSignal(false)
|
||||||
|
|
||||||
|
function handleFileSelect(file: File) {
|
||||||
|
if (!file.type.startsWith("image/")) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => setStore("iconUrl", e.target?.result as string)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
const file = e.dataTransfer?.files[0]
|
||||||
|
if (file) handleFileSelect(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
setDragOver(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (file) handleFileSelect(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearIcon() {
|
||||||
|
setStore("iconUrl", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!props.project.id) return
|
||||||
|
|
||||||
|
setStore("saving", true)
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
await globalSDK.client.project.update({
|
||||||
|
projectID: props.project.id,
|
||||||
|
name,
|
||||||
|
icon: { color: store.color, url: store.iconUrl },
|
||||||
|
})
|
||||||
|
setStore("saving", false)
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title="Edit project">
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<TextField
|
||||||
|
autofocus
|
||||||
|
type="text"
|
||||||
|
label="Name"
|
||||||
|
placeholder={folderName()}
|
||||||
|
value={store.name}
|
||||||
|
onChange={(v) => setStore("name", v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-12-medium text-text-weak">Icon</label>
|
||||||
|
<div class="flex gap-3 items-start">
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
|
||||||
|
classList={{
|
||||||
|
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
|
||||||
|
"border-border-base hover:border-border-strong": !dragOver(),
|
||||||
|
}}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onClick={() => document.getElementById("icon-upload")?.click()}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={store.iconUrl}
|
||||||
|
fallback={
|
||||||
|
<div class="size-full flex items-center justify-center">
|
||||||
|
<Avatar
|
||||||
|
fallback={store.name || defaultName()}
|
||||||
|
{...getAvatarColors(store.color)}
|
||||||
|
class="size-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={store.iconUrl}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-base border border-border-base flex items-center justify-center hover:bg-surface-raised-base-hover"
|
||||||
|
onClick={clearIcon}
|
||||||
|
>
|
||||||
|
<Icon name="close" class="size-3 text-icon-base" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
||||||
|
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak">
|
||||||
|
<span>Click or drag an image</span>
|
||||||
|
<span>Recommended: 128x128px</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!store.iconUrl}>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-12-medium text-text-weak">Color</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<For each={AVATAR_COLOR_KEYS}>
|
||||||
|
{(color) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative size-8 rounded-md transition-all"
|
||||||
|
classList={{
|
||||||
|
"ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base":
|
||||||
|
store.color === color,
|
||||||
|
}}
|
||||||
|
style={{ background: getAvatarColors(color).background }}
|
||||||
|
onClick={() => setStore("color", color)}
|
||||||
|
>
|
||||||
|
<Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
|
||||||
|
{store.saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
+14
-7
@@ -1,16 +1,15 @@
|
|||||||
import { Component } from "solid-js"
|
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import type { Component } from "solid-js"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
|
|
||||||
export const DialogManageModels: Component = () => {
|
export const DialogManageModels: Component = () => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
return (
|
return (
|
||||||
<Dialog title="Manage models" description="Customize which models appear in the model selector.">
|
<Dialog title="Manage models" description="Customize which models appear in the model selector.">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search models", autofocus: true }}
|
search={{ placeholder: "Search models", autofocus: true }}
|
||||||
emptyMessage="No model results"
|
emptyMessage="No model results"
|
||||||
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
||||||
@@ -27,16 +26,24 @@ export const DialogManageModels: Component = () => {
|
|||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
const visible = local.model.visible({ modelID: x.id, providerID: x.provider.id })
|
const visible = local.model.visible({
|
||||||
|
modelID: x.id,
|
||||||
|
providerID: x.provider.id,
|
||||||
|
})
|
||||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center justify-between gap-x-2.5">
|
<div class="w-full flex items-center justify-between gap-x-3">
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
|
checked={
|
||||||
|
!!local.model.visible({
|
||||||
|
modelID: i.id,
|
||||||
|
providerID: i.provider.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
||||||
}}
|
}}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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 { createMemo } from "solid-js"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
|
||||||
|
interface DialogSelectDirectoryProps {
|
||||||
|
title?: string
|
||||||
|
multiple?: boolean
|
||||||
|
onSelect: (result: string | string[] | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
|
const sync = useGlobalSync()
|
||||||
|
const sdk = useGlobalSDK()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const home = createMemo(() => sync.data.path.home)
|
||||||
|
const root = createMemo(() => sync.data.path.home || sync.data.path.directory)
|
||||||
|
|
||||||
|
function join(base: string | undefined, rel: string) {
|
||||||
|
const b = (base ?? "").replace(/[\\/]+$/, "")
|
||||||
|
const r = rel.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "")
|
||||||
|
if (!b) return r
|
||||||
|
if (!r) return b
|
||||||
|
return b + "/" + r
|
||||||
|
}
|
||||||
|
|
||||||
|
function display(rel: string) {
|
||||||
|
const full = join(root(), rel)
|
||||||
|
const h = home()
|
||||||
|
if (!h) return full
|
||||||
|
if (full === h) return "~"
|
||||||
|
if (full.startsWith(h + "/") || full.startsWith(h + "\\")) {
|
||||||
|
return "~" + full.slice(h.length)
|
||||||
|
}
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuery(query: string) {
|
||||||
|
const h = home()
|
||||||
|
|
||||||
|
if (!query) return query
|
||||||
|
if (query.startsWith("~/")) return query.slice(2)
|
||||||
|
|
||||||
|
if (h) {
|
||||||
|
const lc = query.toLowerCase()
|
||||||
|
const hc = h.toLowerCase()
|
||||||
|
if (lc === hc || lc.startsWith(hc + "/") || lc.startsWith(hc + "\\")) {
|
||||||
|
return query.slice(h.length).replace(/^[\\/]+/, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDirs(query: string) {
|
||||||
|
const directory = root()
|
||||||
|
if (!directory) return [] as string[]
|
||||||
|
|
||||||
|
const results = await sdk.client.find
|
||||||
|
.files({ directory, query, type: "directory", limit: 50 })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
|
||||||
|
return results.map((x) => x.replace(/[\\/]+$/, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = async (filter: string) => {
|
||||||
|
const query = normalizeQuery(filter.trim())
|
||||||
|
return fetchDirs(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(rel: string) {
|
||||||
|
const absolute = join(root(), rel)
|
||||||
|
props.onSelect(props.multiple ? [absolute] : absolute)
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={props.title ?? "Open project"}>
|
||||||
|
<List
|
||||||
|
search={{ placeholder: "Search folders", autofocus: true }}
|
||||||
|
emptyMessage="No folders found"
|
||||||
|
items={directories}
|
||||||
|
key={(x) => x}
|
||||||
|
onSelect={(path) => {
|
||||||
|
if (!path) return
|
||||||
|
resolve(path)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(rel) => {
|
||||||
|
const path = display(rel)
|
||||||
|
return (
|
||||||
|
<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: rel, type: "directory" }} class="shrink-0 size-4" />
|
||||||
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
|
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||||
|
{getDirectory(path)}
|
||||||
|
</span>
|
||||||
|
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</List>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
+12
-11
@@ -1,16 +1,16 @@
|
|||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { List } from "@opencode-ai/ui/list"
|
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useFile } from "@/context/file"
|
||||||
|
|
||||||
export function DialogSelectFile() {
|
export function DialogSelectFile() {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
@@ -18,21 +18,22 @@ export function DialogSelectFile() {
|
|||||||
return (
|
return (
|
||||||
<Dialog title="Select file">
|
<Dialog title="Select file">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search files", autofocus: true }}
|
search={{ placeholder: "Search files", autofocus: true }}
|
||||||
emptyMessage="No files found"
|
emptyMessage="No files found"
|
||||||
items={local.file.searchFiles}
|
items={file.searchFiles}
|
||||||
key={(x) => x}
|
key={(x) => x}
|
||||||
onSelect={(path) => {
|
onSelect={(path) => {
|
||||||
if (path) {
|
if (path) {
|
||||||
tabs().open("file://" + path)
|
const value = file.tab(path)
|
||||||
|
tabs().open(value)
|
||||||
|
file.load(path)
|
||||||
}
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular">
|
<div class="flex items-center text-14-regular">
|
||||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+11
-20
@@ -1,15 +1,15 @@
|
|||||||
import { Component, onCleanup, onMount, Show } from "solid-js"
|
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List, ListRef } from "@opencode-ai/ui/list"
|
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
|
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { IconName } from "@opencode-ai/ui/icons/provider"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { type Component, onCleanup, onMount, Show } from "solid-js"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
export const DialogSelectModelUnpaid: Component = () => {
|
export const DialogSelectModelUnpaid: Component = () => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
@@ -64,7 +64,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
<div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
|
<div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<List
|
<List
|
||||||
class="w-full"
|
class="w-full px-0"
|
||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={providers.popular}
|
items={providers.popular}
|
||||||
activeIcon="plus-small"
|
activeIcon="plus-small"
|
||||||
@@ -79,17 +79,8 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-4">
|
<div class="w-full flex items-center gap-x-3">
|
||||||
<ProviderIcon
|
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
||||||
data-slot="list-item-extra-icon"
|
|
||||||
id={i.id as IconName}
|
|
||||||
// TODO: clean this up after we update icon in models.dev
|
|
||||||
classList={{
|
|
||||||
"text-icon-weak-base": true,
|
|
||||||
"size-4 mx-0.5": i.id === "opencode",
|
|
||||||
"size-5": i.id !== "opencode",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<Tag>Recommended</Tag>
|
<Tag>Recommended</Tag>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
|
import { Component, createMemo, createSignal, JSX, 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"
|
||||||
|
|
||||||
|
const ModelList: Component<{
|
||||||
|
provider?: string
|
||||||
|
class?: string
|
||||||
|
onSelect: () => void
|
||||||
|
}> = (props) => {
|
||||||
|
const local = useLocal()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<List
|
||||||
|
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
props.onSelect()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(i) => (
|
||||||
|
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||||
|
<span class="truncate">{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelSelectorPopover: Component<{
|
||||||
|
provider?: string
|
||||||
|
children: JSX.Element
|
||||||
|
}> = (props) => {
|
||||||
|
const [open, setOpen] = createSignal(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
||||||
|
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
|
||||||
|
<Kobalte.Portal>
|
||||||
|
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
|
||||||
|
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
|
||||||
|
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
|
||||||
|
</Kobalte.Content>
|
||||||
|
</Kobalte.Portal>
|
||||||
|
</Kobalte>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ModelList provider={props.provider} onSelect={() => dialog.close()} />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
||||||
|
onClick={() => dialog.show(() => <DialogManageModels />)}
|
||||||
|
>
|
||||||
|
Manage models
|
||||||
|
</Button>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
-12
@@ -15,7 +15,6 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<Dialog title="Connect provider">
|
<Dialog title="Connect provider">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search providers", autofocus: true }}
|
search={{ placeholder: "Search providers", autofocus: true }}
|
||||||
activeIcon="plus-small"
|
activeIcon="plus-small"
|
||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
@@ -38,17 +37,8 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="px-1.25 w-full flex items-center gap-x-4">
|
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||||
<ProviderIcon
|
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
||||||
data-slot="list-item-extra-icon"
|
|
||||||
id={i.id as IconName}
|
|
||||||
// TODO: clean this up after we update icon in models.dev
|
|
||||||
classList={{
|
|
||||||
"text-icon-weak-base": true,
|
|
||||||
"size-4 mx-0.5": i.id === "opencode",
|
|
||||||
"size-5": i.id !== "opencode",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<Tag>Recommended</Tag>
|
<Tag>Recommended</Tag>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
|
||||||
|
type ServerStatus = { healthy: boolean; version?: string }
|
||||||
|
|
||||||
|
async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> {
|
||||||
|
const sdk = createOpencodeClient({
|
||||||
|
baseUrl: url,
|
||||||
|
fetch,
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
})
|
||||||
|
return sdk.global
|
||||||
|
.health()
|
||||||
|
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||||
|
.catch(() => ({ healthy: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogSelectServer() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const server = useServer()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
url: "",
|
||||||
|
adding: false,
|
||||||
|
error: "",
|
||||||
|
status: {} as Record<string, ServerStatus | undefined>,
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = createMemo(() => {
|
||||||
|
const current = server.url
|
||||||
|
const list = server.list
|
||||||
|
if (!current) return list
|
||||||
|
if (!list.includes(current)) return [current, ...list]
|
||||||
|
return [current, ...list.filter((x) => x !== current)]
|
||||||
|
})
|
||||||
|
|
||||||
|
const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
|
||||||
|
|
||||||
|
const sortedItems = createMemo(() => {
|
||||||
|
const list = items()
|
||||||
|
if (!list.length) return list
|
||||||
|
const active = current()
|
||||||
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
|
const rank = (value?: ServerStatus) => {
|
||||||
|
if (value?.healthy === true) return 0
|
||||||
|
if (value?.healthy === false) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return list.slice().sort((a, b) => {
|
||||||
|
if (a === active) return -1
|
||||||
|
if (b === active) return 1
|
||||||
|
const diff = rank(store.status[a]) - rank(store.status[b])
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refreshHealth() {
|
||||||
|
const results: Record<string, ServerStatus> = {}
|
||||||
|
await Promise.all(
|
||||||
|
items().map(async (url) => {
|
||||||
|
results[url] = await checkHealth(url, platform.fetch)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setStore("status", reconcile(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
items()
|
||||||
|
refreshHealth()
|
||||||
|
const interval = setInterval(refreshHealth, 10_000)
|
||||||
|
onCleanup(() => clearInterval(interval))
|
||||||
|
})
|
||||||
|
|
||||||
|
function select(value: string, persist?: boolean) {
|
||||||
|
if (!persist && store.status[value]?.healthy === false) return
|
||||||
|
dialog.close()
|
||||||
|
if (persist) {
|
||||||
|
server.add(value)
|
||||||
|
navigate("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.setActive(value)
|
||||||
|
navigate("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const value = normalizeServerUrl(store.url)
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
setStore("adding", true)
|
||||||
|
setStore("error", "")
|
||||||
|
|
||||||
|
const result = await checkHealth(value, platform.fetch)
|
||||||
|
setStore("adding", false)
|
||||||
|
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("error", "Could not connect to server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("url", "")
|
||||||
|
select(value, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
|
||||||
|
<div class="flex flex-col gap-4 pb-4">
|
||||||
|
<List
|
||||||
|
search={{ placeholder: "Search servers", autofocus: true }}
|
||||||
|
emptyMessage="No servers yet"
|
||||||
|
items={sortedItems}
|
||||||
|
key={(x) => x}
|
||||||
|
current={current()}
|
||||||
|
onSelect={(x) => {
|
||||||
|
if (x) select(x)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(i) => (
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 min-w-0 flex-1"
|
||||||
|
classList={{ "opacity-50": store.status[i]?.healthy === false }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"size-1.5 rounded-full shrink-0": true,
|
||||||
|
"bg-icon-success-base": store.status[i]?.healthy === true,
|
||||||
|
"bg-icon-critical-base": store.status[i]?.healthy === false,
|
||||||
|
"bg-border-weak-base": store.status[i] === undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="truncate">{serverDisplayName(i)}</span>
|
||||||
|
<span class="text-text-weak">{store.status[i]?.version}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<div class="mt-6 px-3 flex flex-col gap-1.5">
|
||||||
|
<div class="px-3">
|
||||||
|
<h3 class="text-14-regular text-text-weak">Add a server</h3>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<div class="flex-1 min-w-0 h-auto">
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
label="Server URL"
|
||||||
|
hideLabel
|
||||||
|
placeholder="http://localhost:4096"
|
||||||
|
value={store.url}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStore("url", v)
|
||||||
|
setStore("error", "")
|
||||||
|
}}
|
||||||
|
validationState={store.error ? "invalid" : "valid"}
|
||||||
|
error={store.error}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
|
||||||
|
{store.adding ? "Checking..." : "Add"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
+5
-5
@@ -2,7 +2,7 @@ import { useLocal, type LocalFile } from "@/context/local"
|
|||||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
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"
|
import { Dynamic } from "solid-js/web"
|
||||||
|
|
||||||
export default function FileTree(props: {
|
export default function FileTree(props: {
|
||||||
@@ -57,14 +57,14 @@ export default function FileTree(props: {
|
|||||||
"text-text-muted/40": p.node.ignored,
|
"text-text-muted/40": p.node.ignored,
|
||||||
"text-text-muted/80": !p.node.ignored,
|
"text-text-muted/80": !p.node.ignored,
|
||||||
// "!text-text": local.file.active()?.path === p.node.path,
|
// "!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}
|
{p.node.name}
|
||||||
</span>
|
</span>
|
||||||
<Show when={local.file.changed(p.node.path)}>
|
{/* <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" />
|
{/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
|
||||||
</Show>
|
{/* </Show> */}
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
)
|
)
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
|||||||
|
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
|
||||||
|
interface SessionContextUsageProps {
|
||||||
|
variant?: "button" | "indicator"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
|
const sync = useSync()
|
||||||
|
const params = useParams()
|
||||||
|
const layout = useLayout()
|
||||||
|
|
||||||
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
|
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) => {
|
||||||
|
if (x.role !== "assistant") return false
|
||||||
|
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
|
||||||
|
return total > 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const openContext = () => {
|
||||||
|
if (!params.id) return
|
||||||
|
layout.review.open()
|
||||||
|
tabs().open("context")
|
||||||
|
tabs().setActive("context")
|
||||||
|
}
|
||||||
|
|
||||||
|
const circle = () => (
|
||||||
|
<div class="p-1">
|
||||||
|
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const tooltipValue = () => (
|
||||||
|
<div>
|
||||||
|
<Show when={context()}>
|
||||||
|
{(ctx) => (
|
||||||
|
<>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-text-invert-strong">{ctx().tokens}</span>
|
||||||
|
<span class="text-text-invert-base">Tokens</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
|
||||||
|
<span class="text-text-invert-base">Usage</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-text-invert-strong">{cost()}</span>
|
||||||
|
<span class="text-text-invert-base">Cost</span>
|
||||||
|
</div>
|
||||||
|
<Show when={variant() === "button"}>
|
||||||
|
<div class="text-11-regular text-text-invert-base mt-1">Click to view context</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={params.id}>
|
||||||
|
<Tooltip value={tooltipValue()} placement="top">
|
||||||
|
<Switch>
|
||||||
|
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<Button type="button" variant="ghost" class="size-6" onClick={openContext}>
|
||||||
|
{circle()}
|
||||||
|
</Button>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
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">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"size-1.5 rounded-full": true,
|
||||||
|
"bg-icon-critical-base": lspStats().hasError,
|
||||||
|
"bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
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 />)}>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"size-1.5 rounded-full": true,
|
||||||
|
"bg-icon-critical-base": mcpStats().failed,
|
||||||
|
"bg-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { SessionHeader } from "./session-header"
|
||||||
|
export { SessionContextTab } from "./session-context-tab"
|
||||||
|
export { SortableTab, FileVisual } from "./session-sortable-tab"
|
||||||
|
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||||
|
export { NewSessionView } from "./session-new-view"
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||||
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
|
import { Code } from "@opencode-ai/ui/code"
|
||||||
|
import { Markdown } from "@opencode-ai/ui/markdown"
|
||||||
|
import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
interface SessionContextTabProps {
|
||||||
|
messages: () => Message[]
|
||||||
|
visibleUserMessages: () => UserMessage[]
|
||||||
|
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||||
|
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionContextTab(props: SessionContextTabProps) {
|
||||||
|
const params = useParams()
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
|
const ctx = createMemo(() => {
|
||||||
|
const last = props.messages().findLast((x) => {
|
||||||
|
if (x.role !== "assistant") return false
|
||||||
|
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
|
||||||
|
return total > 0
|
||||||
|
}) as AssistantMessage
|
||||||
|
if (!last) return
|
||||||
|
|
||||||
|
const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
|
||||||
|
const model = provider?.models[last.modelID]
|
||||||
|
const limit = model?.limit.context
|
||||||
|
|
||||||
|
const input = last.tokens.input
|
||||||
|
const output = last.tokens.output
|
||||||
|
const reasoning = last.tokens.reasoning
|
||||||
|
const cacheRead = last.tokens.cache.read
|
||||||
|
const cacheWrite = last.tokens.cache.write
|
||||||
|
const total = input + output + reasoning + cacheRead + cacheWrite
|
||||||
|
const usage = limit ? Math.round((total / limit) * 100) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: last,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
limit,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
reasoning,
|
||||||
|
cacheRead,
|
||||||
|
cacheWrite,
|
||||||
|
total,
|
||||||
|
usage,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cost = createMemo(() => {
|
||||||
|
const total = props.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 counts = createMemo(() => {
|
||||||
|
const all = props.messages()
|
||||||
|
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
|
||||||
|
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
|
||||||
|
return {
|
||||||
|
all: all.length,
|
||||||
|
user,
|
||||||
|
assistant,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const systemPrompt = createMemo(() => {
|
||||||
|
const msg = props.visibleUserMessages().findLast((m) => !!m.system)
|
||||||
|
const system = msg?.system
|
||||||
|
if (!system) return
|
||||||
|
const trimmed = system.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
return trimmed
|
||||||
|
})
|
||||||
|
|
||||||
|
const number = (value: number | null | undefined) => {
|
||||||
|
if (value === undefined) return "—"
|
||||||
|
if (value === null) return "—"
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = (value: number | null | undefined) => {
|
||||||
|
if (value === undefined) return "—"
|
||||||
|
if (value === null) return "—"
|
||||||
|
return value.toString() + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = (value: number | undefined) => {
|
||||||
|
if (!value) return "—"
|
||||||
|
return DateTime.fromMillis(value).toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerLabel = createMemo(() => {
|
||||||
|
const c = ctx()
|
||||||
|
if (!c) return "—"
|
||||||
|
return c.provider?.name ?? c.message.providerID
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelLabel = createMemo(() => {
|
||||||
|
const c = ctx()
|
||||||
|
if (!c) return "—"
|
||||||
|
if (c.model?.name) return c.model.name
|
||||||
|
return c.message.modelID
|
||||||
|
})
|
||||||
|
|
||||||
|
const breakdown = createMemo(
|
||||||
|
on(
|
||||||
|
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
|
||||||
|
() => {
|
||||||
|
const c = ctx()
|
||||||
|
if (!c) return []
|
||||||
|
const input = c.input
|
||||||
|
if (!input) return []
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
system: systemPrompt()?.length ?? 0,
|
||||||
|
user: 0,
|
||||||
|
assistant: 0,
|
||||||
|
tool: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of props.messages()) {
|
||||||
|
const parts = (sync.data.part[msg.id] ?? []) as Part[]
|
||||||
|
|
||||||
|
if (msg.role === "user") {
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === "text") out.user += part.text.length
|
||||||
|
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
|
||||||
|
if (part.type === "agent") out.user += part.source?.value.length ?? 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role === "assistant") {
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === "text") out.assistant += part.text.length
|
||||||
|
if (part.type === "reasoning") out.assistant += part.text.length
|
||||||
|
if (part.type === "tool") {
|
||||||
|
out.tool += Object.keys(part.state.input).length * 16
|
||||||
|
if (part.state.status === "pending") out.tool += part.state.raw.length
|
||||||
|
if (part.state.status === "completed") out.tool += part.state.output.length
|
||||||
|
if (part.state.status === "error") out.tool += part.state.error.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
||||||
|
const system = estimateTokens(out.system)
|
||||||
|
const user = estimateTokens(out.user)
|
||||||
|
const assistant = estimateTokens(out.assistant)
|
||||||
|
const tool = estimateTokens(out.tool)
|
||||||
|
const estimated = system + user + assistant + tool
|
||||||
|
|
||||||
|
const pct = (tokens: number) => (tokens / input) * 100
|
||||||
|
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
|
||||||
|
|
||||||
|
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "system",
|
||||||
|
label: "System",
|
||||||
|
tokens: tokens.system,
|
||||||
|
width: pct(tokens.system),
|
||||||
|
percent: pctLabel(tokens.system),
|
||||||
|
color: "var(--syntax-info)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
label: "User",
|
||||||
|
tokens: tokens.user,
|
||||||
|
width: pct(tokens.user),
|
||||||
|
percent: pctLabel(tokens.user),
|
||||||
|
color: "var(--syntax-success)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "assistant",
|
||||||
|
label: "Assistant",
|
||||||
|
tokens: tokens.assistant,
|
||||||
|
width: pct(tokens.assistant),
|
||||||
|
percent: pctLabel(tokens.assistant),
|
||||||
|
color: "var(--syntax-property)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tool",
|
||||||
|
label: "Tool Calls",
|
||||||
|
tokens: tokens.tool,
|
||||||
|
width: pct(tokens.tool),
|
||||||
|
percent: pctLabel(tokens.tool),
|
||||||
|
color: "var(--syntax-warning)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "other",
|
||||||
|
label: "Other",
|
||||||
|
tokens: tokens.other,
|
||||||
|
width: pct(tokens.other),
|
||||||
|
percent: pctLabel(tokens.other),
|
||||||
|
color: "var(--syntax-comment)",
|
||||||
|
},
|
||||||
|
].filter((x) => x.tokens > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (estimated <= input) {
|
||||||
|
return build({ system, user, assistant, tool, other: input - estimated })
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = input / estimated
|
||||||
|
const scaled = {
|
||||||
|
system: Math.floor(system * scale),
|
||||||
|
user: Math.floor(user * scale),
|
||||||
|
assistant: Math.floor(assistant * scale),
|
||||||
|
tool: Math.floor(tool * scale),
|
||||||
|
}
|
||||||
|
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
|
||||||
|
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function Stat(statProps: { label: string; value: JSX.Element }) {
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-12-regular text-text-weak">{statProps.label}</div>
|
||||||
|
<div class="text-12-medium text-text-strong">{statProps.value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = createMemo(() => {
|
||||||
|
const c = ctx()
|
||||||
|
const count = counts()
|
||||||
|
return [
|
||||||
|
{ label: "Session", value: props.info()?.title ?? params.id ?? "—" },
|
||||||
|
{ label: "Messages", value: count.all.toLocaleString() },
|
||||||
|
{ label: "Provider", value: providerLabel() },
|
||||||
|
{ label: "Model", value: modelLabel() },
|
||||||
|
{ label: "Context Limit", value: number(c?.limit) },
|
||||||
|
{ label: "Total Tokens", value: number(c?.total) },
|
||||||
|
{ label: "Usage", value: percent(c?.usage) },
|
||||||
|
{ label: "Input Tokens", value: number(c?.input) },
|
||||||
|
{ label: "Output Tokens", value: number(c?.output) },
|
||||||
|
{ label: "Reasoning Tokens", value: number(c?.reasoning) },
|
||||||
|
{ label: "Cache Tokens (read/write)", value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}` },
|
||||||
|
{ label: "User Messages", value: count.user.toLocaleString() },
|
||||||
|
{ label: "Assistant Messages", value: count.assistant.toLocaleString() },
|
||||||
|
{ label: "Total Cost", value: cost() },
|
||||||
|
{ label: "Session Created", value: time(props.info()?.time.created) },
|
||||||
|
{ label: "Last Activity", value: time(c?.message.time.created) },
|
||||||
|
] satisfies { label: string; value: JSX.Element }[]
|
||||||
|
})
|
||||||
|
|
||||||
|
function RawMessageContent(msgProps: { message: Message }) {
|
||||||
|
const file = createMemo(() => {
|
||||||
|
const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
|
||||||
|
const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
|
||||||
|
return {
|
||||||
|
name: `${msgProps.message.role}-${msgProps.message.id}.json`,
|
||||||
|
contents,
|
||||||
|
cacheKey: checksum(contents),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return <Code file={file()} overflow="wrap" class="select-text" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function RawMessage(msgProps: { message: Message }) {
|
||||||
|
return (
|
||||||
|
<Accordion.Item value={msgProps.message.id}>
|
||||||
|
<StickyAccordionHeader>
|
||||||
|
<Accordion.Trigger>
|
||||||
|
<div class="flex items-center justify-between gap-2 w-full">
|
||||||
|
<div class="min-w-0 truncate">
|
||||||
|
{msgProps.message.role} <span class="text-text-base">• {msgProps.message.id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
|
||||||
|
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</StickyAccordionHeader>
|
||||||
|
<Accordion.Content class="bg-background-base">
|
||||||
|
<div class="p-3">
|
||||||
|
<RawMessageContent message={msgProps.message} />
|
||||||
|
</div>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let scroll: HTMLDivElement | undefined
|
||||||
|
let frame: number | undefined
|
||||||
|
let pending: { x: number; y: number } | undefined
|
||||||
|
|
||||||
|
const restoreScroll = () => {
|
||||||
|
const el = scroll
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const s = props.view()?.scroll("context")
|
||||||
|
if (!s) return
|
||||||
|
|
||||||
|
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
||||||
|
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||||
|
pending = {
|
||||||
|
x: event.currentTarget.scrollLeft,
|
||||||
|
y: event.currentTarget.scrollTop,
|
||||||
|
}
|
||||||
|
if (frame !== undefined) return
|
||||||
|
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = undefined
|
||||||
|
|
||||||
|
const next = pending
|
||||||
|
pending = undefined
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
props.view().setScroll("context", next)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.messages().length,
|
||||||
|
() => {
|
||||||
|
requestAnimationFrame(restoreScroll)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (frame === undefined) return
|
||||||
|
cancelAnimationFrame(frame)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="@container h-full overflow-y-auto no-scrollbar pb-10"
|
||||||
|
ref={(el) => {
|
||||||
|
scroll = el
|
||||||
|
restoreScroll()
|
||||||
|
}}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
<div class="px-6 pt-4 flex flex-col gap-10">
|
||||||
|
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||||
|
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={breakdown().length > 0}>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-12-regular text-text-weak">Context Breakdown</div>
|
||||||
|
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
|
||||||
|
<For each={breakdown()}>
|
||||||
|
{(segment) => (
|
||||||
|
<div
|
||||||
|
class="h-full"
|
||||||
|
style={{
|
||||||
|
width: `${segment.width}%`,
|
||||||
|
"background-color": segment.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||||
|
<For each={breakdown()}>
|
||||||
|
{(segment) => (
|
||||||
|
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
||||||
|
<div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
|
||||||
|
<div>{segment.label}</div>
|
||||||
|
<div class="text-text-weaker">{segment.percent}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class="hidden text-11-regular text-text-weaker">
|
||||||
|
Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={systemPrompt()}>
|
||||||
|
{(prompt) => (
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-12-regular text-text-weak">System Prompt</div>
|
||||||
|
<div class="border border-border-base rounded-md bg-surface-base px-3 py-2">
|
||||||
|
<Markdown text={prompt()} class="text-12-regular" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-12-regular text-text-weak">Raw messages</div>
|
||||||
|
<Accordion multiple>
|
||||||
|
<For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { createMemo, createResource, Show } from "solid-js"
|
||||||
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||||
|
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
||||||
|
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
||||||
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
export function SessionHeader() {
|
||||||
|
const globalSDK = useGlobalSDK()
|
||||||
|
const layout = useLayout()
|
||||||
|
const params = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const command = useCommand()
|
||||||
|
const server = useServer()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
|
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
|
||||||
|
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
||||||
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
|
const branch = createMemo(() => sync.data.vcs?.branch)
|
||||||
|
|
||||||
|
function navigateToProject(directory: string) {
|
||||||
|
navigate(`/${base64Encode(directory)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToSession(session: Session | undefined) {
|
||||||
|
if (!session) return
|
||||||
|
navigate(`/${params.dir}/session/${session.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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={layout.mobileSidebar.toggle}
|
||||||
|
>
|
||||||
|
<Icon name="menu" size="small" />
|
||||||
|
</button>
|
||||||
|
<div class="px-4 flex items-center justify-between gap-4 w-full">
|
||||||
|
<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={sync.directory}
|
||||||
|
label={(x) => {
|
||||||
|
const name = getFilename(x)
|
||||||
|
const b = x === sync.directory ? branch() : undefined
|
||||||
|
return b ? `${name}:${b}` : name
|
||||||
|
}}
|
||||||
|
onSelect={(x) => (x ? 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={navigateToSession}
|
||||||
|
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={currentSession()}>
|
||||||
|
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
|
||||||
|
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="hidden md:flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
dialog.show(() => <DialogSelectServer />)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"size-1.5 rounded-full": true,
|
||||||
|
"bg-icon-success-base": server.healthy() === true,
|
||||||
|
"bg-icon-critical-base": server.healthy() === false,
|
||||||
|
"bg-border-weak-base": server.healthy() === undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Icon name="server" size="small" class="text-icon-weak" />
|
||||||
|
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
|
||||||
|
</Button>
|
||||||
|
<SessionLspIndicator />
|
||||||
|
<SessionMcpIndicator />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Show when={currentSession()?.summary?.files}>
|
||||||
|
<TooltipKeybind
|
||||||
|
class="hidden md:block shrink-0"
|
||||||
|
title="Toggle review"
|
||||||
|
keybind={command.keybind("review.toggle")}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
<TooltipKeybind
|
||||||
|
class="hidden md:block shrink-0"
|
||||||
|
title="Toggle terminal"
|
||||||
|
keybind={command.keybind("terminal.toggle")}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
<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: sync.directory })
|
||||||
|
.then((r) => r.data?.share?.url)
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to share session", e)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return shareURL
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
|
||||||
|
})}
|
||||||
|
</Popover>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Show } from "solid-js"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
|
export function NewSessionView() {
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
import { useFile } from "@/context/file"
|
||||||
|
|
||||||
|
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-x-1.5">
|
||||||
|
<FileIcon
|
||||||
|
node={{ path: props.path, type: "file" }}
|
||||||
|
classList={{
|
||||||
|
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
|
||||||
|
"grayscale-0": props.active,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="text-14-medium">{getFilename(props.path)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
|
||||||
|
const file = useFile()
|
||||||
|
const sortable = createSortable(props.tab)
|
||||||
|
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||||
|
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
|
||||||
|
>
|
||||||
|
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { JSX } from "solid-js"
|
||||||
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
|
|
||||||
|
export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element {
|
||||||
|
const terminal = useTerminal()
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||||
|
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { SerializeAddon } from "@/addons/serialize"
|
||||||
|
import { LocalPTY } from "@/context/terminal"
|
||||||
|
import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
|
||||||
|
|
||||||
|
export interface TerminalProps extends ComponentProps<"div"> {
|
||||||
|
pty: LocalPTY
|
||||||
|
onSubmit?: () => void
|
||||||
|
onCleanup?: (pty: LocalPTY) => void
|
||||||
|
onConnectError?: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type TerminalColors = {
|
||||||
|
background: string
|
||||||
|
foreground: string
|
||||||
|
cursor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||||
|
light: {
|
||||||
|
background: "#fcfcfc",
|
||||||
|
foreground: "#211e1e",
|
||||||
|
cursor: "#211e1e",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "#191515",
|
||||||
|
foreground: "#d4d4d4",
|
||||||
|
cursor: "#d4d4d4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Terminal = (props: TerminalProps) => {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const theme = useTheme()
|
||||||
|
let container!: HTMLDivElement
|
||||||
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
|
||||||
|
let ws: WebSocket
|
||||||
|
let term: Term
|
||||||
|
let ghostty: Ghostty
|
||||||
|
let serializeAddon: SerializeAddon
|
||||||
|
let fitAddon: FitAddon
|
||||||
|
let handleResize: () => void
|
||||||
|
|
||||||
|
const getTerminalColors = (): TerminalColors => {
|
||||||
|
const mode = theme.mode()
|
||||||
|
const fallback = DEFAULT_TERMINAL_COLORS[mode]
|
||||||
|
const currentTheme = theme.themes()[theme.themeId()]
|
||||||
|
if (!currentTheme) return fallback
|
||||||
|
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
||||||
|
if (!variant?.seeds) return fallback
|
||||||
|
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||||
|
const text = resolved["text-base"] ?? fallback.foreground
|
||||||
|
const background = resolved["background-stronger"] ?? fallback.background
|
||||||
|
return {
|
||||||
|
background,
|
||||||
|
foreground: text,
|
||||||
|
cursor: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const colors = getTerminalColors()
|
||||||
|
setTerminalColors(colors)
|
||||||
|
if (!term) return
|
||||||
|
const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
|
||||||
|
if (!setOption) return
|
||||||
|
setOption("theme", colors)
|
||||||
|
})
|
||||||
|
|
||||||
|
const focusTerminal = () => term?.focus()
|
||||||
|
const copySelection = () => {
|
||||||
|
if (!term || !term.hasSelection()) return false
|
||||||
|
const selection = term.getSelection()
|
||||||
|
if (!selection) return false
|
||||||
|
const clipboard = navigator.clipboard
|
||||||
|
if (clipboard?.writeText) {
|
||||||
|
clipboard.writeText(selection).catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!document.body) return false
|
||||||
|
const textarea = document.createElement("textarea")
|
||||||
|
textarea.value = selection
|
||||||
|
textarea.setAttribute("readonly", "")
|
||||||
|
textarea.style.position = "fixed"
|
||||||
|
textarea.style.opacity = "0"
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
const copied = document.execCommand("copy")
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
return copied
|
||||||
|
}
|
||||||
|
const handlePointerDown = () => {
|
||||||
|
const activeElement = document.activeElement
|
||||||
|
if (activeElement instanceof HTMLElement && activeElement !== container) {
|
||||||
|
activeElement.blur()
|
||||||
|
}
|
||||||
|
focusTerminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
ghostty = await Ghostty.load()
|
||||||
|
|
||||||
|
ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
||||||
|
term = new Term({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
allowTransparency: true,
|
||||||
|
theme: terminalColors(),
|
||||||
|
scrollback: 10_000,
|
||||||
|
ghostty,
|
||||||
|
})
|
||||||
|
term.attachCustomKeyEventHandler((event) => {
|
||||||
|
const key = event.key.toLowerCase()
|
||||||
|
if (key === "c") {
|
||||||
|
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
|
||||||
|
const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey
|
||||||
|
if ((macCopy || linuxCopy) && copySelection()) {
|
||||||
|
event.preventDefault()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// allow for ctrl-` to toggle terminal in parent
|
||||||
|
if (event.ctrlKey && key === "`") {
|
||||||
|
event.preventDefault()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
fitAddon = new FitAddon()
|
||||||
|
serializeAddon = new SerializeAddon()
|
||||||
|
term.loadAddon(serializeAddon)
|
||||||
|
term.loadAddon(fitAddon)
|
||||||
|
|
||||||
|
term.open(container)
|
||||||
|
container.addEventListener("pointerdown", handlePointerDown)
|
||||||
|
focusTerminal()
|
||||||
|
|
||||||
|
if (local.pty.buffer) {
|
||||||
|
if (local.pty.rows && local.pty.cols) {
|
||||||
|
term.resize(local.pty.cols, local.pty.rows)
|
||||||
|
}
|
||||||
|
term.reset()
|
||||||
|
term.write(local.pty.buffer)
|
||||||
|
if (local.pty.scrollY) {
|
||||||
|
term.scrollToLine(local.pty.scrollY)
|
||||||
|
}
|
||||||
|
fitAddon.fit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fitAddon.observeResize()
|
||||||
|
handleResize = () => fitAddon.fit()
|
||||||
|
window.addEventListener("resize", handleResize)
|
||||||
|
term.onResize(async (size) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
await sdk.client.pty
|
||||||
|
.update({
|
||||||
|
ptyID: local.pty.id,
|
||||||
|
size: {
|
||||||
|
cols: size.cols,
|
||||||
|
rows: size.rows,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
term.onKey((key) => {
|
||||||
|
if (key.key == "Enter") {
|
||||||
|
props.onSubmit?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// term.onScroll((ydisp) => {
|
||||||
|
// console.log("Scroll position:", ydisp)
|
||||||
|
// })
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
console.log("WebSocket connected")
|
||||||
|
sdk.client.pty
|
||||||
|
.update({
|
||||||
|
ptyID: local.pty.id,
|
||||||
|
size: {
|
||||||
|
cols: term.cols,
|
||||||
|
rows: term.rows,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
})
|
||||||
|
ws.addEventListener("message", (event) => {
|
||||||
|
term.write(event.data)
|
||||||
|
})
|
||||||
|
ws.addEventListener("error", (error) => {
|
||||||
|
console.error("WebSocket error:", error)
|
||||||
|
props.onConnectError?.(error)
|
||||||
|
})
|
||||||
|
ws.addEventListener("close", () => {
|
||||||
|
console.log("WebSocket disconnected")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (handleResize) {
|
||||||
|
window.removeEventListener("resize", handleResize)
|
||||||
|
}
|
||||||
|
container.removeEventListener("pointerdown", handlePointerDown)
|
||||||
|
if (serializeAddon && props.onCleanup) {
|
||||||
|
const buffer = serializeAddon.serialize()
|
||||||
|
props.onCleanup({
|
||||||
|
...local.pty,
|
||||||
|
buffer,
|
||||||
|
rows: term.rows,
|
||||||
|
cols: term.cols,
|
||||||
|
scrollY: term.getViewportY(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ws?.close()
|
||||||
|
term?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={container}
|
||||||
|
data-component="terminal"
|
||||||
|
data-prevent-autofocus
|
||||||
|
style={{ "background-color": terminalColors().background }}
|
||||||
|
classList={{
|
||||||
|
...(local.classList ?? {}),
|
||||||
|
"select-text": true,
|
||||||
|
"size-full px-6 py-3 font-mono": true,
|
||||||
|
[local.class ?? ""]: !!local.class,
|
||||||
|
}}
|
||||||
|
{...others}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export interface CommandOption {
|
|||||||
suggested?: boolean
|
suggested?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onSelect?: (source?: "palette" | "keybind" | "slash") => void
|
onSelect?: (source?: "palette" | "keybind" | "slash") => void
|
||||||
|
onHighlight?: () => (() => void) | void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseKeybind(config: string): Keybind[] {
|
export function parseKeybind(config: string): Keybind[] {
|
||||||
@@ -115,23 +116,40 @@ export function formatKeybind(config: string): string {
|
|||||||
|
|
||||||
function DialogCommand(props: { options: CommandOption[] }) {
|
function DialogCommand(props: { options: CommandOption[] }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
let cleanup: (() => void) | void
|
||||||
|
let committed = false
|
||||||
|
|
||||||
|
const handleMove = (option: CommandOption | undefined) => {
|
||||||
|
cleanup?.()
|
||||||
|
cleanup = option?.onHighlight?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (option: CommandOption | undefined) => {
|
||||||
|
if (option) {
|
||||||
|
committed = true
|
||||||
|
cleanup = undefined
|
||||||
|
dialog.close()
|
||||||
|
option.onSelect?.("palette")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (!committed) {
|
||||||
|
cleanup?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title="Commands">
|
<Dialog title="Commands">
|
||||||
<List
|
<List
|
||||||
class="px-2.5"
|
|
||||||
search={{ placeholder: "Search commands", autofocus: true }}
|
search={{ placeholder: "Search commands", autofocus: true }}
|
||||||
emptyMessage="No commands found"
|
emptyMessage="No commands found"
|
||||||
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
|
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
|
||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
filterKeys={["title", "description", "category"]}
|
filterKeys={["title", "description", "category"]}
|
||||||
groupBy={(x) => x.category ?? ""}
|
groupBy={(x) => x.category ?? ""}
|
||||||
onSelect={(option) => {
|
onMove={handleMove}
|
||||||
if (option) {
|
onSelect={handleSelect}
|
||||||
dialog.close()
|
|
||||||
option.onSelect?.("palette")
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{(option) => (
|
{(option) => (
|
||||||
<div class="w-full flex items-center justify-between gap-4">
|
<div class="w-full flex items-center justify-between gap-4">
|
||||||
@@ -226,6 +244,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
keybind(id: string) {
|
||||||
|
const option = options().find((x) => x.id === id || x.id === "suggested." + id)
|
||||||
|
if (!option?.keybind) return ""
|
||||||
|
return formatKeybind(option.keybind)
|
||||||
|
},
|
||||||
show: showPalette,
|
show: showPalette,
|
||||||
keybinds(enabled: boolean) {
|
keybinds(enabled: boolean) {
|
||||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
import { useSDK } from "./sdk"
|
||||||
|
import { useSync } from "./sync"
|
||||||
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
|
export type FileSelection = {
|
||||||
|
startLine: number
|
||||||
|
startChar: number
|
||||||
|
endLine: number
|
||||||
|
endChar: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectedLineRange = {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
side?: "additions" | "deletions"
|
||||||
|
endSide?: "additions" | "deletions"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileViewState = {
|
||||||
|
scrollTop?: number
|
||||||
|
scrollLeft?: number
|
||||||
|
selectedLines?: SelectedLineRange | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileState = {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
loaded?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
error?: string
|
||||||
|
content?: FileContent
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripFileProtocol(input: string) {
|
||||||
|
if (!input.startsWith("file://")) return input
|
||||||
|
return input.slice("file://".length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripQueryAndHash(input: string) {
|
||||||
|
const hashIndex = input.indexOf("#")
|
||||||
|
const queryIndex = input.indexOf("?")
|
||||||
|
|
||||||
|
if (hashIndex !== -1 && queryIndex !== -1) {
|
||||||
|
return input.slice(0, Math.min(hashIndex, queryIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashIndex !== -1) return input.slice(0, hashIndex)
|
||||||
|
if (queryIndex !== -1) return input.slice(0, queryIndex)
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionFromLines(range: SelectedLineRange): FileSelection {
|
||||||
|
const startLine = Math.min(range.start, range.end)
|
||||||
|
const endLine = Math.max(range.start, range.end)
|
||||||
|
return {
|
||||||
|
startLine,
|
||||||
|
endLine,
|
||||||
|
startChar: 0,
|
||||||
|
endChar: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
||||||
|
if (range.start <= range.end) return range
|
||||||
|
|
||||||
|
const startSide = range.side
|
||||||
|
const endSide = range.endSide ?? startSide
|
||||||
|
|
||||||
|
return {
|
||||||
|
...range,
|
||||||
|
start: range.end,
|
||||||
|
end: range.start,
|
||||||
|
side: endSide,
|
||||||
|
endSide: startSide !== endSide ? startSide : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
|
name: "File",
|
||||||
|
init: () => {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const params = useParams()
|
||||||
|
|
||||||
|
const directory = createMemo(() => sync.data.path.directory)
|
||||||
|
|
||||||
|
function normalize(input: string) {
|
||||||
|
const root = directory()
|
||||||
|
const prefix = root.endsWith("/") ? root : root + "/"
|
||||||
|
|
||||||
|
let path = stripQueryAndHash(stripFileProtocol(input))
|
||||||
|
|
||||||
|
if (path.startsWith(prefix)) {
|
||||||
|
path = path.slice(prefix.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith(root)) {
|
||||||
|
path = path.slice(root.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("./")) {
|
||||||
|
path = path.slice(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/")) {
|
||||||
|
path = path.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
function tab(input: string) {
|
||||||
|
const path = normalize(input)
|
||||||
|
return `file://${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathFromTab(tabValue: string) {
|
||||||
|
if (!tabValue.startsWith("file://")) return
|
||||||
|
return normalize(tabValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
|
const [store, setStore] = createStore<{
|
||||||
|
file: Record<string, FileState>
|
||||||
|
}>({
|
||||||
|
file: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
|
||||||
|
|
||||||
|
const [view, setView, _, ready] = persisted(
|
||||||
|
viewKey(),
|
||||||
|
createStore<{
|
||||||
|
file: Record<string, FileViewState>
|
||||||
|
}>({
|
||||||
|
file: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function ensure(path: string) {
|
||||||
|
if (!path) return
|
||||||
|
if (store.file[path]) return
|
||||||
|
setStore("file", path, { path, name: getFilename(path) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(input: string, options?: { force?: boolean }) {
|
||||||
|
const path = normalize(input)
|
||||||
|
if (!path) return Promise.resolve()
|
||||||
|
|
||||||
|
ensure(path)
|
||||||
|
|
||||||
|
const current = store.file[path]
|
||||||
|
if (!options?.force && current?.loaded) return Promise.resolve()
|
||||||
|
|
||||||
|
const pending = inflight.get(path)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
setStore(
|
||||||
|
"file",
|
||||||
|
path,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loading = true
|
||||||
|
draft.error = undefined
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const promise = sdk.client.file
|
||||||
|
.read({ path })
|
||||||
|
.then((x) => {
|
||||||
|
setStore(
|
||||||
|
"file",
|
||||||
|
path,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loaded = true
|
||||||
|
draft.loading = false
|
||||||
|
draft.content = x.data
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setStore(
|
||||||
|
"file",
|
||||||
|
path,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loading = false
|
||||||
|
draft.error = e.message
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Failed to load file",
|
||||||
|
description: e.message,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(path)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflight.set(path, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = sdk.event.listen((e) => {
|
||||||
|
const event = e.details
|
||||||
|
if (event.type !== "file.watcher.updated") return
|
||||||
|
const path = normalize(event.properties.file)
|
||||||
|
if (!path) return
|
||||||
|
if (path.startsWith(".git/")) return
|
||||||
|
if (!store.file[path]) return
|
||||||
|
load(path, { force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = (input: string) => store.file[normalize(input)]
|
||||||
|
|
||||||
|
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
|
||||||
|
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
|
||||||
|
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
|
||||||
|
|
||||||
|
const setScrollTop = (input: string, top: number) => {
|
||||||
|
const path = normalize(input)
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollTop === top) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollTop: top,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setScrollLeft = (input: string, left: number) => {
|
||||||
|
const path = normalize(input)
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollLeft === left) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollLeft: left,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
||||||
|
const path = normalize(input)
|
||||||
|
const next = range ? normalizeSelectedLines(range) : null
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.selectedLines === next) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
selectedLines: next,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => stop())
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
normalize,
|
||||||
|
tab,
|
||||||
|
pathFromTab,
|
||||||
|
get,
|
||||||
|
load,
|
||||||
|
scrollTop,
|
||||||
|
scrollLeft,
|
||||||
|
setScrollTop,
|
||||||
|
setScrollLeft,
|
||||||
|
selectedLines,
|
||||||
|
setSelectedLines,
|
||||||
|
searchFiles: (query: string) =>
|
||||||
|
sdk.client.find.files({ query, dirs: "false" }).then((x) => (x.data ?? []).map(normalize)),
|
||||||
|
searchFilesAndDirectories: (query: string) =>
|
||||||
|
sdk.client.find.files({ query, dirs: "true" }).then((x) => (x.data ?? []).map(normalize)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
+15
-9
@@ -1,34 +1,40 @@
|
|||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
|
import { onCleanup } from "solid-js"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
|
import { useServer } from "./server"
|
||||||
|
|
||||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||||
name: "GlobalSDK",
|
name: "GlobalSDK",
|
||||||
init: (props: { url: string }) => {
|
init: () => {
|
||||||
|
const server = useServer()
|
||||||
|
const abort = new AbortController()
|
||||||
|
|
||||||
const eventSdk = createOpencodeClient({
|
const eventSdk = createOpencodeClient({
|
||||||
baseUrl: props.url,
|
baseUrl: server.url,
|
||||||
// signal: AbortSignal.timeout(1000 * 60 * 10),
|
signal: abort.signal,
|
||||||
})
|
})
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key: string]: Event
|
[key: string]: Event
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
eventSdk.global.event().then(async (events) => {
|
void (async () => {
|
||||||
|
const events = await eventSdk.global.event()
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
// console.log("event", event)
|
|
||||||
emitter.emit(event.directory ?? "global", event.payload)
|
emitter.emit(event.directory ?? "global", event.payload)
|
||||||
}
|
}
|
||||||
})
|
})().catch(() => undefined)
|
||||||
|
|
||||||
|
onCleanup(() => abort.abort())
|
||||||
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: props.url,
|
baseUrl: server.url,
|
||||||
signal: AbortSignal.timeout(1000 * 60 * 10),
|
|
||||||
fetch: platform.fetch,
|
fetch: platform.fetch,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { url: props.url, client: sdk, event: emitter }
|
return { url: server.url, client: sdk, event: emitter }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
+169
-35
@@ -5,8 +5,6 @@ import {
|
|||||||
type Part,
|
type Part,
|
||||||
type Config,
|
type Config,
|
||||||
type Path,
|
type Path,
|
||||||
type File,
|
|
||||||
type FileNode,
|
|
||||||
type Project,
|
type Project,
|
||||||
type FileDiff,
|
type FileDiff,
|
||||||
type Todo,
|
type Todo,
|
||||||
@@ -14,6 +12,10 @@ import {
|
|||||||
type ProviderListResponse,
|
type ProviderListResponse,
|
||||||
type ProviderAuthResponse,
|
type ProviderAuthResponse,
|
||||||
type Command,
|
type Command,
|
||||||
|
type McpStatus,
|
||||||
|
type LspStatus,
|
||||||
|
type VcsInfo,
|
||||||
|
type PermissionRequest,
|
||||||
createOpencodeClient,
|
createOpencodeClient,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
@@ -21,12 +23,12 @@ import { Binary } from "@opencode-ai/util/binary"
|
|||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { ErrorPage, type InitError } from "../pages/error"
|
import { ErrorPage, type InitError } from "../pages/error"
|
||||||
import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
|
import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
ready: boolean
|
status: "loading" | "partial" | "complete"
|
||||||
agent: Agent[]
|
agent: Agent[]
|
||||||
command: Command[]
|
command: Command[]
|
||||||
project: string
|
project: string
|
||||||
@@ -43,6 +45,14 @@ type State = {
|
|||||||
todo: {
|
todo: {
|
||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
}
|
}
|
||||||
|
permission: {
|
||||||
|
[sessionID: string]: PermissionRequest[]
|
||||||
|
}
|
||||||
|
mcp: {
|
||||||
|
[name: string]: McpStatus
|
||||||
|
}
|
||||||
|
lsp: LspStatus[]
|
||||||
|
vcs: VcsInfo | undefined
|
||||||
limit: number
|
limit: number
|
||||||
message: {
|
message: {
|
||||||
[sessionID: string]: Message[]
|
[sessionID: string]: Message[]
|
||||||
@@ -50,8 +60,6 @@ type State = {
|
|||||||
part: {
|
part: {
|
||||||
[messageID: string]: Part[]
|
[messageID: string]: Part[]
|
||||||
}
|
}
|
||||||
node: FileNode[]
|
|
||||||
changes: File[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGlobalSync() {
|
function createGlobalSync() {
|
||||||
@@ -63,39 +71,38 @@ function createGlobalSync() {
|
|||||||
project: Project[]
|
project: Project[]
|
||||||
provider: ProviderListResponse
|
provider: ProviderListResponse
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: ProviderAuthResponse
|
||||||
children: Record<string, State>
|
|
||||||
}>({
|
}>({
|
||||||
ready: false,
|
ready: false,
|
||||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
project: [],
|
project: [],
|
||||||
provider: { all: [], connected: [], default: {} },
|
provider: { all: [], connected: [], default: {} },
|
||||||
provider_auth: {},
|
provider_auth: {},
|
||||||
children: {},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
|
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
|
||||||
function child(directory: string) {
|
function child(directory: string) {
|
||||||
if (!directory) console.error("No directory provided")
|
if (!directory) console.error("No directory provided")
|
||||||
if (!children[directory]) {
|
if (!children[directory]) {
|
||||||
setGlobalStore("children", directory, {
|
children[directory] = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
provider: { all: [], connected: [], default: {} },
|
provider: { all: [], connected: [], default: {} },
|
||||||
config: {},
|
config: {},
|
||||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
ready: false,
|
status: "loading" as const,
|
||||||
agent: [],
|
agent: [],
|
||||||
command: [],
|
command: [],
|
||||||
session: [],
|
session: [],
|
||||||
session_status: {},
|
session_status: {},
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
todo: {},
|
todo: {},
|
||||||
|
permission: {},
|
||||||
|
mcp: {},
|
||||||
|
lsp: [],
|
||||||
|
vcs: undefined,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
part: {},
|
part: {},
|
||||||
node: [],
|
|
||||||
changes: [],
|
|
||||||
})
|
})
|
||||||
children[directory] = createStore(globalStore.children[directory])
|
|
||||||
bootstrapInstance(directory)
|
bootstrapInstance(directory)
|
||||||
}
|
}
|
||||||
return children[directory]
|
return children[directory]
|
||||||
@@ -108,16 +115,17 @@ function createGlobalSync() {
|
|||||||
.then((x) => {
|
.then((x) => {
|
||||||
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
|
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
|
||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
|
.filter((s) => !!s?.id)
|
||||||
|
.filter((s) => !s.time?.archived)
|
||||||
.slice()
|
.slice()
|
||||||
.filter((s) => !s.time.archived)
|
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
// Include up to the limit, plus any updated in the last 4 hours
|
// Include up to the limit, plus any updated in the last 4 hours
|
||||||
const sessions = nonArchived.filter((s, i) => {
|
const sessions = nonArchived.filter((s, i) => {
|
||||||
if (i < store.limit) return true
|
if (i < store.limit) return true
|
||||||
const updated = new Date(s.time.updated).getTime()
|
const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
|
||||||
return updated > fourHoursAgo
|
return updated > fourHoursAgo
|
||||||
})
|
})
|
||||||
setStore("session", sessions)
|
setStore("session", reconcile(sessions, { key: "id" }))
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("Failed to load sessions", err)
|
console.error("Failed to load sessions", err)
|
||||||
@@ -128,26 +136,79 @@ function createGlobalSync() {
|
|||||||
|
|
||||||
async function bootstrapInstance(directory: string) {
|
async function bootstrapInstance(directory: string) {
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const [, setStore] = child(directory)
|
const [store, setStore] = child(directory)
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: globalSDK.url,
|
baseUrl: globalSDK.url,
|
||||||
directory,
|
directory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
const load = {
|
|
||||||
|
const blockingRequests = {
|
||||||
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
||||||
provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
|
provider: () =>
|
||||||
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
|
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"),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}),
|
||||||
agent: () => sdk.app.agents().then((x) => setStore("agent", 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!)),
|
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||||
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
|
|
||||||
node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
|
|
||||||
}
|
}
|
||||||
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
await Promise.all(Object.values(blockingRequests).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
||||||
.then(() => setStore("ready", true))
|
.then(() => {
|
||||||
|
if (store.status !== "complete") setStore("status", "partial")
|
||||||
|
// non-blocking
|
||||||
|
Promise.all([
|
||||||
|
sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||||
|
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||||
|
sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
||||||
|
loadSessions(directory),
|
||||||
|
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
|
||||||
|
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
|
||||||
|
sdk.vcs.get().then((x) => setStore("vcs", x.data)),
|
||||||
|
sdk.permission.list().then((x) => {
|
||||||
|
const grouped: Record<string, PermissionRequest[]> = {}
|
||||||
|
for (const perm of x.data ?? []) {
|
||||||
|
if (!perm?.id || !perm.sessionID) continue
|
||||||
|
const existing = grouped[perm.sessionID]
|
||||||
|
if (existing) {
|
||||||
|
existing.push(perm)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grouped[perm.sessionID] = [perm]
|
||||||
|
}
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(store.permission)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
setStore("permission", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||||
|
setStore(
|
||||||
|
"permission",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
permissions
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
]).then(() => {
|
||||||
|
setStore("status", "complete")
|
||||||
|
})
|
||||||
|
})
|
||||||
.catch((e) => setGlobalStore("error", e))
|
.catch((e) => setGlobalStore("error", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,13 +272,13 @@ function createGlobalSync() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.diff":
|
case "session.diff":
|
||||||
setStore("session_diff", event.properties.sessionID, event.properties.diff)
|
setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
|
||||||
break
|
break
|
||||||
case "todo.updated":
|
case "todo.updated":
|
||||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
|
||||||
break
|
break
|
||||||
case "session.status": {
|
case "session.status": {
|
||||||
setStore("session_status", event.properties.sessionID, event.properties.status)
|
setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
@@ -291,10 +352,72 @@ function createGlobalSync() {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case "vcs.branch.updated": {
|
||||||
|
setStore("vcs", { branch: event.properties.branch })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "permission.asked": {
|
||||||
|
const sessionID = event.properties.sessionID
|
||||||
|
const permissions = store.permission[sessionID]
|
||||||
|
if (!permissions) {
|
||||||
|
setStore("permission", sessionID, [event.properties])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Binary.search(permissions, event.properties.id, (p) => p.id)
|
||||||
|
if (result.found) {
|
||||||
|
setStore("permission", sessionID, result.index, reconcile(event.properties))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore(
|
||||||
|
"permission",
|
||||||
|
sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, event.properties)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "permission.replied": {
|
||||||
|
const permissions = store.permission[event.properties.sessionID]
|
||||||
|
if (!permissions) break
|
||||||
|
const result = Binary.search(permissions, event.properties.requestID, (p) => p.id)
|
||||||
|
if (!result.found) break
|
||||||
|
setStore(
|
||||||
|
"permission",
|
||||||
|
event.properties.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "lsp.updated": {
|
||||||
|
const sdk = createOpencodeClient({
|
||||||
|
baseUrl: globalSDK.url,
|
||||||
|
directory,
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function bootstrap() {
|
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([
|
return Promise.all([
|
||||||
retry(() =>
|
retry(() =>
|
||||||
globalSDK.client.path.get().then((x) => {
|
globalSDK.client.path.get().then((x) => {
|
||||||
@@ -303,15 +426,26 @@ function createGlobalSync() {
|
|||||||
),
|
),
|
||||||
retry(() =>
|
retry(() =>
|
||||||
globalSDK.client.project.list().then(async (x) => {
|
globalSDK.client.project.list().then(async (x) => {
|
||||||
setGlobalStore(
|
const projects = (x.data ?? [])
|
||||||
"project",
|
.filter((p) => !!p?.id)
|
||||||
x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
)
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
setGlobalStore("project", projects)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
retry(() =>
|
retry(() =>
|
||||||
globalSDK.client.provider.list().then((x) => {
|
globalSDK.client.provider.list().then((x) => {
|
||||||
setGlobalStore("provider", x.data ?? {})
|
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(() =>
|
retry(() =>
|
||||||
@@ -3,6 +3,7 @@ import { batch, createMemo, onMount } from "solid-js"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
|
import { useServer } from "./server"
|
||||||
import { Project } from "@opencode-ai/sdk/v2"
|
import { Project } from "@opencode-ai/sdk/v2"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
@@ -22,22 +23,41 @@ export function getAvatarColors(key?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
|
||||||
|
if (a === b) return true
|
||||||
|
if (!a || !b) return false
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
return a.every((x, i) => x === b[i])
|
||||||
|
}
|
||||||
|
|
||||||
type SessionTabs = {
|
type SessionTabs = {
|
||||||
active?: string
|
active?: string
|
||||||
all: string[]
|
all: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionScroll = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionView = {
|
||||||
|
scroll: Record<string, SessionScroll>
|
||||||
|
reviewOpen?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||||
|
|
||||||
|
export type ReviewDiffStyle = "unified" | "split"
|
||||||
|
|
||||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
init: () => {
|
init: () => {
|
||||||
const globalSdk = useGlobalSDK()
|
const globalSdk = useGlobalSDK()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
|
const server = useServer()
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
"layout.v3",
|
"layout.v6",
|
||||||
createStore({
|
createStore({
|
||||||
projects: [] as { worktree: string; expanded: boolean }[],
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
opened: false,
|
opened: false,
|
||||||
width: 280,
|
width: 280,
|
||||||
@@ -46,10 +66,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
opened: false,
|
opened: false,
|
||||||
height: 280,
|
height: 280,
|
||||||
},
|
},
|
||||||
|
review: {
|
||||||
|
opened: true,
|
||||||
|
diffStyle: "split" as ReviewDiffStyle,
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
width: 600,
|
width: 600,
|
||||||
},
|
},
|
||||||
|
mobileSidebar: {
|
||||||
|
opened: false,
|
||||||
|
},
|
||||||
sessionTabs: {} as Record<string, SessionTabs>,
|
sessionTabs: {} as Record<string, SessionTabs>,
|
||||||
|
sessionView: {} as Record<string, SessionView>,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,6 +95,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
{
|
{
|
||||||
...project,
|
...project,
|
||||||
...(metadata ?? {}),
|
...(metadata ?? {}),
|
||||||
|
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -82,12 +111,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
return project
|
return project
|
||||||
}
|
}
|
||||||
|
|
||||||
const enriched = createMemo(() => store.projects.flatMap(enrich))
|
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
|
||||||
const list = createMemo(() => enriched().flatMap(colorize))
|
const list = createMemo(() => enriched().flatMap(colorize))
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
Promise.all(
|
Promise.all(
|
||||||
store.projects.map((project) => {
|
server.projects.list().map((project) => {
|
||||||
return globalSync.project.loadSessions(project.worktree)
|
return globalSync.project.loadSessions(project.worktree)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -98,30 +127,23 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
projects: {
|
projects: {
|
||||||
list,
|
list,
|
||||||
open(directory: string) {
|
open(directory: string) {
|
||||||
if (store.projects.find((x) => x.worktree === directory)) {
|
if (server.projects.list().find((x) => x.worktree === directory)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
globalSync.project.loadSessions(directory)
|
globalSync.project.loadSessions(directory)
|
||||||
setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
|
server.projects.open(directory)
|
||||||
},
|
},
|
||||||
close(directory: string) {
|
close(directory: string) {
|
||||||
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
|
server.projects.close(directory)
|
||||||
},
|
},
|
||||||
expand(directory: string) {
|
expand(directory: string) {
|
||||||
setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: true } : x)))
|
server.projects.expand(directory)
|
||||||
},
|
},
|
||||||
collapse(directory: string) {
|
collapse(directory: string) {
|
||||||
setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: false } : x)))
|
server.projects.collapse(directory)
|
||||||
},
|
},
|
||||||
move(directory: string, toIndex: number) {
|
move(directory: string, toIndex: number) {
|
||||||
setStore("projects", (projects) => {
|
server.projects.move(directory, toIndex)
|
||||||
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: {
|
sidebar: {
|
||||||
@@ -156,16 +178,80 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
setStore("terminal", "height", height)
|
setStore("terminal", "height", height)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
review: {
|
||||||
|
opened: createMemo(() => store.review?.opened ?? true),
|
||||||
|
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
||||||
|
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
||||||
|
if (!store.review) {
|
||||||
|
setStore("review", { opened: true, diffStyle })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("review", "diffStyle", diffStyle)
|
||||||
|
},
|
||||||
|
open() {
|
||||||
|
setStore("review", "opened", true)
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
setStore("review", "opened", false)
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
setStore("review", "opened", (x) => !x)
|
||||||
|
},
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
width: createMemo(() => store.session?.width ?? 600),
|
width: createMemo(() => store.session?.width ?? 600),
|
||||||
resize(width: number) {
|
resize(width: number) {
|
||||||
if (!store.session) {
|
if (!store.session) {
|
||||||
setStore("session", { width })
|
setStore("session", { width })
|
||||||
} else {
|
return
|
||||||
setStore("session", "width", width)
|
|
||||||
}
|
}
|
||||||
|
setStore("session", "width", width)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mobileSidebar: {
|
||||||
|
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
||||||
|
show() {
|
||||||
|
setStore("mobileSidebar", "opened", true)
|
||||||
|
},
|
||||||
|
hide() {
|
||||||
|
setStore("mobileSidebar", "opened", false)
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
setStore("mobileSidebar", "opened", (x) => !x)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
view(sessionKey: string) {
|
||||||
|
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
||||||
|
return {
|
||||||
|
scroll(tab: string) {
|
||||||
|
return s().scroll?.[tab]
|
||||||
|
},
|
||||||
|
setScroll(tab: string, pos: SessionScroll) {
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", sessionKey, { scroll: { [tab]: pos } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = current.scroll?.[tab]
|
||||||
|
if (prev?.x === pos.x && prev?.y === pos.y) return
|
||||||
|
setStore("sessionView", sessionKey, "scroll", tab, pos)
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
open: createMemo(() => s().reviewOpen),
|
||||||
|
setOpen(open: string[]) {
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (same(current.reviewOpen, open)) return
|
||||||
|
setStore("sessionView", sessionKey, "reviewOpen", open)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
tabs(sessionKey: string) {
|
tabs(sessionKey: string) {
|
||||||
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
|
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
|
||||||
return {
|
return {
|
||||||
@@ -188,38 +274,55 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
async open(tab: string) {
|
async open(tab: string) {
|
||||||
const current = store.sessionTabs[sessionKey] ?? { all: [] }
|
const current = store.sessionTabs[sessionKey] ?? { all: [] }
|
||||||
if (tab !== "review") {
|
|
||||||
if (!current.all.includes(tab)) {
|
if (tab === "review") {
|
||||||
if (!store.sessionTabs[sessionKey]) {
|
if (!store.sessionTabs[sessionKey]) {
|
||||||
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
|
setStore("sessionTabs", sessionKey, { all: [], active: tab })
|
||||||
} else {
|
|
||||||
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
|
|
||||||
setStore("sessionTabs", sessionKey, "active", tab)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!store.sessionTabs[sessionKey]) {
|
|
||||||
setStore("sessionTabs", sessionKey, { all: [], active: tab })
|
|
||||||
} else {
|
|
||||||
setStore("sessionTabs", sessionKey, "active", tab)
|
setStore("sessionTabs", sessionKey, "active", tab)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tab === "context") {
|
||||||
|
const all = [tab, ...current.all.filter((x) => x !== tab)]
|
||||||
|
if (!store.sessionTabs[sessionKey]) {
|
||||||
|
setStore("sessionTabs", sessionKey, { all, active: tab })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("sessionTabs", sessionKey, "all", all)
|
||||||
|
setStore("sessionTabs", sessionKey, "active", tab)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current.all.includes(tab)) {
|
||||||
|
if (!store.sessionTabs[sessionKey]) {
|
||||||
|
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
|
||||||
|
setStore("sessionTabs", sessionKey, "active", tab)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.sessionTabs[sessionKey]) {
|
||||||
|
setStore("sessionTabs", sessionKey, { all: current.all, active: tab })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("sessionTabs", sessionKey, "active", tab)
|
||||||
},
|
},
|
||||||
close(tab: string) {
|
close(tab: string) {
|
||||||
const current = store.sessionTabs[sessionKey]
|
const current = store.sessionTabs[sessionKey]
|
||||||
if (!current) return
|
if (!current) return
|
||||||
|
|
||||||
|
const all = current.all.filter((x) => x !== tab)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore(
|
setStore("sessionTabs", sessionKey, "all", all)
|
||||||
"sessionTabs",
|
if (current.active !== tab) return
|
||||||
sessionKey,
|
|
||||||
"all",
|
const index = current.all.findIndex((f) => f === tab)
|
||||||
current.all.filter((x) => x !== tab),
|
const next = all[index - 1] ?? all[0]
|
||||||
)
|
setStore("sessionTabs", sessionKey, "active", next)
|
||||||
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) {
|
move(tab: string, to: number) {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { batch, createEffect, createMemo } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
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 type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
@@ -9,6 +9,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
|||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
|
||||||
export type LocalFile = FileNode &
|
export type LocalFile = FileNode &
|
||||||
Partial<{
|
Partial<{
|
||||||
@@ -61,44 +62,43 @@ 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 agent = (() => {
|
||||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
current: string
|
current?: string
|
||||||
}>({
|
}>({
|
||||||
current: list()[0].name,
|
current: list()[0]?.name,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
current() {
|
current() {
|
||||||
return list().find((x) => x.name === store.current)!
|
const available = list()
|
||||||
|
if (available.length === 0) return undefined
|
||||||
|
return available.find((x) => x.name === store.current) ?? available[0]
|
||||||
},
|
},
|
||||||
set(name: string | undefined) {
|
set(name: string | undefined) {
|
||||||
setStore("current", name ?? list()[0].name)
|
const available = list()
|
||||||
|
if (available.length === 0) {
|
||||||
|
setStore("current", undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name && available.some((x) => x.name === name)) {
|
||||||
|
setStore("current", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("current", available[0].name)
|
||||||
},
|
},
|
||||||
move(direction: 1 | -1) {
|
move(direction: 1 | -1) {
|
||||||
let next = list().findIndex((x) => x.name === store.current) + direction
|
const available = list()
|
||||||
if (next < 0) next = list().length - 1
|
if (available.length === 0) {
|
||||||
if (next >= list().length) next = 0
|
setStore("current", undefined)
|
||||||
const value = list()[next]
|
return
|
||||||
|
}
|
||||||
|
let next = available.findIndex((x) => x.name === store.current) + direction
|
||||||
|
if (next < 0) next = available.length - 1
|
||||||
|
if (next >= available.length) next = 0
|
||||||
|
const value = available[next]
|
||||||
|
if (!value) return
|
||||||
setStore("current", value.name)
|
setStore("current", value.name)
|
||||||
if (value.model)
|
if (value.model)
|
||||||
model.set({
|
model.set({
|
||||||
@@ -115,9 +115,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
createStore<{
|
createStore<{
|
||||||
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
|
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
|
||||||
recent: ModelKey[]
|
recent: ModelKey[]
|
||||||
|
variant?: Record<string, string | undefined>
|
||||||
}>({
|
}>({
|
||||||
user: [],
|
user: [],
|
||||||
recent: [],
|
recent: [],
|
||||||
|
variant: {},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -199,11 +201,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const current = createMemo(() => {
|
const current = createMemo(() => {
|
||||||
const a = agent.current()
|
const a = agent.current()
|
||||||
|
if (!a) return undefined
|
||||||
const key = getFirstValidModel(
|
const key = getFirstValidModel(
|
||||||
() => ephemeral.model[a.name],
|
() => ephemeral.model[a.name],
|
||||||
() => a.model,
|
() => a.model,
|
||||||
fallbackModel,
|
fallbackModel,
|
||||||
)!
|
)
|
||||||
|
if (!key) return undefined
|
||||||
return find(key)
|
return find(key)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -249,7 +253,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
cycle,
|
cycle,
|
||||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setEphemeral("model", agent.current().name, model ?? fallbackModel())
|
const currentAgent = agent.current()
|
||||||
|
if (currentAgent) setEphemeral("model", currentAgent.name, model ?? fallbackModel())
|
||||||
if (model) updateVisibility(model, "show")
|
if (model) updateVisibility(model, "show")
|
||||||
if (options?.recent && model) {
|
if (options?.recent && model) {
|
||||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||||
@@ -269,6 +274,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
setVisibility(model: ModelKey, visible: boolean) {
|
setVisibility(model: ModelKey, visible: boolean) {
|
||||||
updateVisibility(model, visible ? "show" : "hide")
|
updateVisibility(model, visible ? "show" : "hide")
|
||||||
},
|
},
|
||||||
|
variant: {
|
||||||
|
current() {
|
||||||
|
const m = current()
|
||||||
|
if (!m) return undefined
|
||||||
|
const key = `${m.provider.id}/${m.id}`
|
||||||
|
return store.variant?.[key]
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
const m = current()
|
||||||
|
if (!m) return []
|
||||||
|
if (!m.variants) return []
|
||||||
|
return Object.keys(m.variants)
|
||||||
|
},
|
||||||
|
set(value: string | undefined) {
|
||||||
|
const m = current()
|
||||||
|
if (!m) return
|
||||||
|
const key = `${m.provider.id}/${m.id}`
|
||||||
|
if (!store.variant) {
|
||||||
|
setStore("variant", { [key]: value })
|
||||||
|
} else {
|
||||||
|
setStore("variant", key, value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cycle() {
|
||||||
|
const variants = this.list()
|
||||||
|
if (variants.length === 0) return
|
||||||
|
const currentVariant = this.current()
|
||||||
|
if (!currentVariant) {
|
||||||
|
this.set(variants[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const index = variants.indexOf(currentVariant)
|
||||||
|
if (index === -1 || index === variants.length - 1) {
|
||||||
|
this.set(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.set(variants[index + 1])
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -276,11 +320,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
node: Record<string, LocalFile>
|
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 changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
// const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||||
|
|
||||||
// createEffect((prev: FileStatus[]) => {
|
// createEffect((prev: FileStatus[]) => {
|
||||||
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
|
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
|
||||||
@@ -308,16 +352,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
// return sync.data.changes
|
// return sync.data.changes
|
||||||
// }, sync.data.changes)
|
// }, sync.data.changes)
|
||||||
|
|
||||||
const changed = (path: string) => {
|
// const changed = (path: string) => {
|
||||||
const node = store.node[path]
|
// const node = store.node[path]
|
||||||
if (node?.status) return true
|
// if (node?.status) return true
|
||||||
const set = changeset()
|
// const set = changeset()
|
||||||
if (set.has(path)) return true
|
// if (set.has(path)) return true
|
||||||
for (const p of set) {
|
// for (const p of set) {
|
||||||
if (p.startsWith(path ? path + "/" : "")) return true
|
// if (p.startsWith(path ? path + "/" : "")) return true
|
||||||
}
|
// }
|
||||||
return false
|
// return false
|
||||||
}
|
// }
|
||||||
|
|
||||||
// const resetNode = (path: string) => {
|
// const resetNode = (path: string) => {
|
||||||
// setStore("node", path, {
|
// setStore("node", path, {
|
||||||
@@ -336,17 +380,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const load = async (path: string) => {
|
const load = async (path: string) => {
|
||||||
const relativePath = relative(path)
|
const relativePath = relative(path)
|
||||||
await sdk.client.file.read({ path: relativePath }).then((x) => {
|
await sdk.client.file
|
||||||
if (!store.node[relativePath]) return
|
.read({ path: relativePath })
|
||||||
setStore(
|
.then((x) => {
|
||||||
"node",
|
if (!store.node[relativePath]) return
|
||||||
relativePath,
|
setStore(
|
||||||
produce((draft) => {
|
"node",
|
||||||
draft.loaded = true
|
relativePath,
|
||||||
draft.content = x.data
|
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) => {
|
const fetch = async (path: string) => {
|
||||||
@@ -360,7 +413,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const init = async (path: string) => {
|
const init = async (path: string) => {
|
||||||
const relativePath = relative(path)
|
const relativePath = relative(path)
|
||||||
if (!store.node[relativePath]) await fetch(path)
|
if (!store.node[relativePath]) await fetch(path)
|
||||||
if (store.node[relativePath].loaded) return
|
if (store.node[relativePath]?.loaded) return
|
||||||
return load(relativePath)
|
return load(relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,25 +430,28 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
// ]
|
// ]
|
||||||
// })
|
// })
|
||||||
// setStore("active", relativePath)
|
// setStore("active", relativePath)
|
||||||
context.addActive()
|
// context.addActive()
|
||||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||||
if (store.node[relativePath].loaded) return
|
if (store.node[relativePath]?.loaded) return
|
||||||
return load(relativePath)
|
return load(relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = async (path: string) => {
|
const list = async (path: string) => {
|
||||||
return sdk.client.file.list({ path: path + "/" }).then((x) => {
|
return sdk.client.file
|
||||||
setStore(
|
.list({ path: path + "/" })
|
||||||
"node",
|
.then((x) => {
|
||||||
produce((draft) => {
|
setStore(
|
||||||
x.data!.forEach((node) => {
|
"node",
|
||||||
if (node.path in draft) return
|
produce((draft) => {
|
||||||
draft[node.path] = node
|
x.data!.forEach((node) => {
|
||||||
})
|
if (node.path in draft) return
|
||||||
}),
|
draft[node.path] = node
|
||||||
)
|
})
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
|
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
|
||||||
@@ -466,8 +522,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
setChangeIndex(path: string, index: number | undefined) {
|
setChangeIndex(path: string, index: number | undefined) {
|
||||||
setStore("node", path, "selectedChange", index)
|
setStore("node", path, "selectedChange", index)
|
||||||
},
|
},
|
||||||
changes,
|
// changes,
|
||||||
changed,
|
// changed,
|
||||||
children(path: string) {
|
children(path: string) {
|
||||||
return Object.values(store.node).filter(
|
return Object.values(store.node).filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
@@ -482,66 +538,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const context = (() => {
|
|
||||||
const [store, setStore] = createStore<{
|
|
||||||
activeTab: boolean
|
|
||||||
files: string[]
|
|
||||||
activeFile?: string
|
|
||||||
items: (ContextItem & { key: string })[]
|
|
||||||
}>({
|
|
||||||
activeTab: true,
|
|
||||||
files: [],
|
|
||||||
items: [],
|
|
||||||
})
|
|
||||||
const files = createMemo(() => store.files.map((x) => file.node(x)))
|
|
||||||
const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
|
|
||||||
|
|
||||||
return {
|
|
||||||
all() {
|
|
||||||
return store.items
|
|
||||||
},
|
|
||||||
// active() {
|
|
||||||
// return store.activeTab ? file.active() : undefined
|
|
||||||
// },
|
|
||||||
addActive() {
|
|
||||||
setStore("activeTab", true)
|
|
||||||
},
|
|
||||||
removeActive() {
|
|
||||||
setStore("activeTab", false)
|
|
||||||
},
|
|
||||||
add(item: ContextItem) {
|
|
||||||
let key = item.type
|
|
||||||
switch (item.type) {
|
|
||||||
case "file":
|
|
||||||
key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (store.items.find((x) => x.key === key)) return
|
|
||||||
setStore("items", (x) => [...x, { key, ...item }])
|
|
||||||
},
|
|
||||||
remove(key: string) {
|
|
||||||
setStore("items", (x) => x.filter((x) => x.key !== key))
|
|
||||||
},
|
|
||||||
files,
|
|
||||||
openFile(path: string) {
|
|
||||||
file.init(path).then(() => {
|
|
||||||
setStore("files", (x) => [...x, path])
|
|
||||||
setStore("activeFile", path)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
activeFile,
|
|
||||||
setActiveFile(path: string | undefined) {
|
|
||||||
setStore("activeFile", path)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
slug: createMemo(() => base64Encode(sdk.directory)),
|
slug: createMemo(() => base64Encode(sdk.directory)),
|
||||||
model,
|
model,
|
||||||
agent,
|
agent,
|
||||||
file,
|
file,
|
||||||
context,
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
+16
-9
@@ -2,7 +2,9 @@ import { createStore } from "solid-js/store"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||||
import { makeAudioPlayer } from "@solid-primitives/audio"
|
import { makeAudioPlayer } from "@solid-primitives/audio"
|
||||||
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
|
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||||
@@ -43,6 +45,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
|
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
|
const platform = usePlatform()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
"notification.v1",
|
"notification.v1",
|
||||||
@@ -64,8 +67,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.properties.sessionID
|
||||||
const [syncStore] = globalSync.child(directory)
|
const [syncStore] = globalSync.child(directory)
|
||||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||||
const isChild = match.found && syncStore.session[match.index].parentID
|
const session = match.found ? syncStore.session[match.index] : undefined
|
||||||
if (isChild) break
|
if (session?.parentID) break
|
||||||
try {
|
try {
|
||||||
idlePlayer?.play()
|
idlePlayer?.play()
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -74,25 +77,29 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
type: "turn-complete",
|
type: "turn-complete",
|
||||||
session: sessionID,
|
session: sessionID,
|
||||||
})
|
})
|
||||||
|
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||||
|
void platform.notify("Response ready", session?.title ?? sessionID, href)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.error": {
|
case "session.error": {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.properties.sessionID
|
||||||
if (sessionID) {
|
const [syncStore] = globalSync.child(directory)
|
||||||
const [syncStore] = globalSync.child(directory)
|
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
|
||||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
|
||||||
const isChild = match.found && syncStore.session[match.index].parentID
|
if (session?.parentID) break
|
||||||
if (isChild) break
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
errorPlayer?.play()
|
errorPlayer?.play()
|
||||||
} catch {}
|
} catch {}
|
||||||
|
const error = "error" in event.properties ? event.properties.error : undefined
|
||||||
setStore("list", store.list.length, {
|
setStore("list", store.list.length, {
|
||||||
...base,
|
...base,
|
||||||
type: "error",
|
type: "error",
|
||||||
session: sessionID ?? "global",
|
session: sessionID ?? "global",
|
||||||
error: "error" in event.properties ? event.properties.error : undefined,
|
error,
|
||||||
})
|
})
|
||||||
|
const description = session?.title ?? (typeof error === "string" ? error : "An error occurred")
|
||||||
|
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||||
|
void platform.notify("Session error", description, href)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { persisted } from "@/utils/persist"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useGlobalSync } from "./global-sync"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
|
|
||||||
|
type PermissionRespondFn = (input: {
|
||||||
|
sessionID: string
|
||||||
|
permissionID: string
|
||||||
|
response: "once" | "always" | "reject"
|
||||||
|
directory?: string
|
||||||
|
}) => void
|
||||||
|
|
||||||
|
function shouldAutoAccept(perm: PermissionRequest) {
|
||||||
|
return perm.permission === "edit"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||||
|
name: "Permission",
|
||||||
|
init: () => {
|
||||||
|
const params = useParams()
|
||||||
|
const globalSDK = useGlobalSDK()
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
|
|
||||||
|
const permissionsEnabled = createMemo(() => {
|
||||||
|
if (!params.dir || !base64Decode(params.dir)) return false
|
||||||
|
const [store] = globalSync.child(base64Decode(params.dir))
|
||||||
|
return store.config.permission !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
"permission.v3",
|
||||||
|
createStore({
|
||||||
|
autoAcceptEdits: {} as Record<string, boolean>,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const responded = new Set<string>()
|
||||||
|
|
||||||
|
const respond: PermissionRespondFn = (input) => {
|
||||||
|
globalSDK.client.permission.respond(input).catch(() => {
|
||||||
|
responded.delete(input.permissionID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||||
|
if (responded.has(permission.id)) return
|
||||||
|
responded.add(permission.id)
|
||||||
|
respond({
|
||||||
|
sessionID: permission.sessionID,
|
||||||
|
permissionID: permission.id,
|
||||||
|
response: "once",
|
||||||
|
directory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutoAccepting(sessionID: string) {
|
||||||
|
return store.autoAcceptEdits[sessionID] ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = globalSDK.event.listen((e) => {
|
||||||
|
const event = e.details
|
||||||
|
if (event?.type !== "permission.asked") return
|
||||||
|
|
||||||
|
const perm = event.properties
|
||||||
|
if (!isAutoAccepting(perm.sessionID)) return
|
||||||
|
if (!shouldAutoAccept(perm)) return
|
||||||
|
|
||||||
|
respondOnce(perm, e.name)
|
||||||
|
})
|
||||||
|
onCleanup(unsubscribe)
|
||||||
|
|
||||||
|
function enable(sessionID: string, directory: string) {
|
||||||
|
setStore("autoAcceptEdits", sessionID, true)
|
||||||
|
|
||||||
|
globalSDK.client.permission
|
||||||
|
.list({ directory })
|
||||||
|
.then((x) => {
|
||||||
|
for (const perm of x.data ?? []) {
|
||||||
|
if (!perm?.id) continue
|
||||||
|
if (perm.sessionID !== sessionID) continue
|
||||||
|
if (!shouldAutoAccept(perm)) continue
|
||||||
|
respondOnce(perm, directory)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable(sessionID: string) {
|
||||||
|
setStore("autoAcceptEdits", sessionID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
respond,
|
||||||
|
autoResponds(permission: PermissionRequest) {
|
||||||
|
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
|
||||||
|
},
|
||||||
|
isAutoAccepting,
|
||||||
|
toggleAutoAccept(sessionID: string, directory: string) {
|
||||||
|
if (isAutoAccepting(sessionID)) {
|
||||||
|
disable(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enable(sessionID, directory)
|
||||||
|
},
|
||||||
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
|
if (isAutoAccepting(sessionID)) return
|
||||||
|
enable(sessionID, directory)
|
||||||
|
},
|
||||||
|
disableAutoAccept(sessionID: string) {
|
||||||
|
disable(sessionID)
|
||||||
|
},
|
||||||
|
permissionsEnabled,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -5,13 +5,19 @@ export type Platform = {
|
|||||||
/** Platform discriminator */
|
/** Platform discriminator */
|
||||||
platform: "web" | "tauri"
|
platform: "web" | "tauri"
|
||||||
|
|
||||||
|
/** App version */
|
||||||
|
version?: string
|
||||||
|
|
||||||
/** Open a URL in the default browser */
|
/** Open a URL in the default browser */
|
||||||
openLink(url: string): void
|
openLink(url: string): void
|
||||||
|
|
||||||
/** Restart the app */
|
/** Restart the app */
|
||||||
restart(): Promise<void>
|
restart(): Promise<void>
|
||||||
|
|
||||||
/** Open native directory picker dialog (Tauri only) */
|
/** Send a system notification (optional deep link) */
|
||||||
|
notify(title: string, description?: string, href?: string): Promise<void>
|
||||||
|
|
||||||
|
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
||||||
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
||||||
|
|
||||||
/** Open native file picker dialog (Tauri only) */
|
/** Open native file picker dialog (Tauri only) */
|
||||||
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { TextSelection } from "./local"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
interface PartBase {
|
interface PartBase {
|
||||||
@@ -18,7 +18,12 @@ export interface TextPart extends PartBase {
|
|||||||
export interface FileAttachmentPart extends PartBase {
|
export interface FileAttachmentPart extends PartBase {
|
||||||
type: "file"
|
type: "file"
|
||||||
path: string
|
path: string
|
||||||
selection?: TextSelection
|
selection?: FileSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentPart extends PartBase {
|
||||||
|
type: "agent"
|
||||||
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageAttachmentPart {
|
export interface ImageAttachmentPart {
|
||||||
@@ -29,11 +34,27 @@ export interface ImageAttachmentPart {
|
|||||||
dataUrl: string
|
dataUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
|
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||||
export type Prompt = ContentPart[]
|
export type Prompt = ContentPart[]
|
||||||
|
|
||||||
|
export type FileContextItem = {
|
||||||
|
type: "file"
|
||||||
|
path: string
|
||||||
|
selection?: FileSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextItem = FileContextItem
|
||||||
|
|
||||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
|
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
||||||
|
if (!a && !b) return true
|
||||||
|
if (!a || !b) return false
|
||||||
|
return (
|
||||||
|
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||||
if (promptA.length !== promptB.length) return false
|
if (promptA.length !== promptB.length) return false
|
||||||
for (let i = 0; i < promptA.length; i++) {
|
for (let i = 0; i < promptA.length; i++) {
|
||||||
@@ -43,7 +64,13 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
|||||||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
if (partA.type === "file") {
|
||||||
|
const fileA = partA as FileAttachmentPart
|
||||||
|
const fileB = partB as FileAttachmentPart
|
||||||
|
if (fileA.path !== fileB.path) return false
|
||||||
|
if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
|
||||||
|
}
|
||||||
|
if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
|
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
|
||||||
@@ -53,7 +80,7 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneSelection(selection?: TextSelection) {
|
function cloneSelection(selection?: FileSelection) {
|
||||||
if (!selection) return undefined
|
if (!selection) return undefined
|
||||||
return { ...selection }
|
return { ...selection }
|
||||||
}
|
}
|
||||||
@@ -61,6 +88,7 @@ function cloneSelection(selection?: TextSelection) {
|
|||||||
function clonePart(part: ContentPart): ContentPart {
|
function clonePart(part: ContentPart): ContentPart {
|
||||||
if (part.type === "text") return { ...part }
|
if (part.type === "text") return { ...part }
|
||||||
if (part.type === "image") return { ...part }
|
if (part.type === "image") return { ...part }
|
||||||
|
if (part.type === "agent") return { ...part }
|
||||||
return {
|
return {
|
||||||
...part,
|
...part,
|
||||||
selection: cloneSelection(part.selection),
|
selection: cloneSelection(part.selection),
|
||||||
@@ -75,24 +103,57 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
name: "Prompt",
|
name: "Prompt",
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
|
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
name(),
|
name(),
|
||||||
createStore<{
|
createStore<{
|
||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
cursor?: number
|
cursor?: number
|
||||||
|
context: {
|
||||||
|
activeTab: boolean
|
||||||
|
items: (ContextItem & { key: string })[]
|
||||||
|
}
|
||||||
}>({
|
}>({
|
||||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||||
cursor: undefined,
|
cursor: undefined,
|
||||||
|
context: {
|
||||||
|
activeTab: true,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function keyForItem(item: ContextItem) {
|
||||||
|
if (item.type !== "file") return item.type
|
||||||
|
const start = item.selection?.startLine
|
||||||
|
const end = item.selection?.endLine
|
||||||
|
return `${item.type}:${item.path}:${start}:${end}`
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
current: createMemo(() => store.prompt),
|
current: createMemo(() => store.prompt),
|
||||||
cursor: createMemo(() => store.cursor),
|
cursor: createMemo(() => store.cursor),
|
||||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||||
|
context: {
|
||||||
|
activeTab: createMemo(() => store.context.activeTab),
|
||||||
|
items: createMemo(() => store.context.items),
|
||||||
|
addActive() {
|
||||||
|
setStore("context", "activeTab", true)
|
||||||
|
},
|
||||||
|
removeActive() {
|
||||||
|
setStore("context", "activeTab", false)
|
||||||
|
},
|
||||||
|
add(item: ContextItem) {
|
||||||
|
const key = keyForItem(item)
|
||||||
|
if (store.context.items.find((x) => x.key === key)) return
|
||||||
|
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||||
|
},
|
||||||
|
remove(key: string) {
|
||||||
|
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||||
|
},
|
||||||
|
},
|
||||||
set(prompt: Prompt, cursorPosition?: number) {
|
set(prompt: Prompt, cursorPosition?: number) {
|
||||||
const next = clonePrompt(prompt)
|
const next = clonePrompt(prompt)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
@@ -11,7 +11,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: globalSDK.url,
|
baseUrl: globalSDK.url,
|
||||||
signal: AbortSignal.timeout(1000 * 60 * 10),
|
|
||||||
fetch: platform.fetch,
|
fetch: platform.fetch,
|
||||||
directory: props.directory,
|
directory: props.directory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { persisted } from "@/utils/persist"
|
||||||
|
|
||||||
|
type StoredProject = { worktree: string; expanded: boolean }
|
||||||
|
|
||||||
|
export function normalizeServerUrl(input: string) {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
|
||||||
|
const cleaned = withProtocol.replace(/\/+$/, "")
|
||||||
|
return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverDisplayName(url: string) {
|
||||||
|
if (!url) return ""
|
||||||
|
return url
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/\/+$/, "")
|
||||||
|
.split("/")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectsKey(url: string) {
|
||||||
|
if (!url) return ""
|
||||||
|
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
||||||
|
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||||
|
name: "Server",
|
||||||
|
init: (props: { defaultUrl: string }) => {
|
||||||
|
const platform = usePlatform()
|
||||||
|
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
"server.v3",
|
||||||
|
createStore({
|
||||||
|
list: [] as string[],
|
||||||
|
projects: {} as Record<string, StoredProject[]>,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [active, setActiveRaw] = createSignal("")
|
||||||
|
|
||||||
|
function setActive(input: string) {
|
||||||
|
const url = normalizeServerUrl(input)
|
||||||
|
if (!url) return
|
||||||
|
setActiveRaw(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(input: string) {
|
||||||
|
const url = normalizeServerUrl(input)
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
const fallback = normalizeServerUrl(props.defaultUrl)
|
||||||
|
if (fallback && url === fallback) {
|
||||||
|
setActiveRaw(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
if (!store.list.includes(url)) {
|
||||||
|
setStore("list", store.list.length, url)
|
||||||
|
}
|
||||||
|
setActiveRaw(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(input: string) {
|
||||||
|
const url = normalizeServerUrl(input)
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
const list = store.list.filter((x) => x !== url)
|
||||||
|
const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active()
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", list)
|
||||||
|
setActiveRaw(next)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (active()) return
|
||||||
|
const url = normalizeServerUrl(props.defaultUrl)
|
||||||
|
if (!url) return
|
||||||
|
setActiveRaw(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isReady = createMemo(() => ready() && !!active())
|
||||||
|
|
||||||
|
const [healthy, { refetch }] = createResource(
|
||||||
|
() => active() || undefined,
|
||||||
|
async (url) => {
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
const sdk = createOpencodeClient({
|
||||||
|
baseUrl: url,
|
||||||
|
fetch: platform.fetch,
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
})
|
||||||
|
return sdk.global
|
||||||
|
.health()
|
||||||
|
.then((x) => x.data?.healthy === true)
|
||||||
|
.catch(() => false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!active()) return
|
||||||
|
const interval = setInterval(() => refetch(), 10_000)
|
||||||
|
onCleanup(() => clearInterval(interval))
|
||||||
|
})
|
||||||
|
|
||||||
|
const origin = createMemo(() => projectsKey(active()))
|
||||||
|
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||||
|
const isLocal = createMemo(() => origin() === "local")
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: isReady,
|
||||||
|
healthy,
|
||||||
|
isLocal,
|
||||||
|
get url() {
|
||||||
|
return active()
|
||||||
|
},
|
||||||
|
get name() {
|
||||||
|
return serverDisplayName(active())
|
||||||
|
},
|
||||||
|
get list() {
|
||||||
|
return store.list
|
||||||
|
},
|
||||||
|
setActive,
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
projects: {
|
||||||
|
list: projectsList,
|
||||||
|
open(directory: string) {
|
||||||
|
const key = origin()
|
||||||
|
if (!key) return
|
||||||
|
const current = store.projects[key] ?? []
|
||||||
|
if (current.find((x) => x.worktree === directory)) return
|
||||||
|
setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
|
||||||
|
},
|
||||||
|
close(directory: string) {
|
||||||
|
const key = origin()
|
||||||
|
if (!key) return
|
||||||
|
const current = store.projects[key] ?? []
|
||||||
|
setStore(
|
||||||
|
"projects",
|
||||||
|
key,
|
||||||
|
current.filter((x) => x.worktree !== directory),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
expand(directory: string) {
|
||||||
|
const key = origin()
|
||||||
|
if (!key) return
|
||||||
|
const current = store.projects[key] ?? []
|
||||||
|
const index = current.findIndex((x) => x.worktree === directory)
|
||||||
|
if (index !== -1) setStore("projects", key, index, "expanded", true)
|
||||||
|
},
|
||||||
|
collapse(directory: string) {
|
||||||
|
const key = origin()
|
||||||
|
if (!key) return
|
||||||
|
const current = store.projects[key] ?? []
|
||||||
|
const index = current.findIndex((x) => x.worktree === directory)
|
||||||
|
if (index !== -1) setStore("projects", key, index, "expanded", false)
|
||||||
|
},
|
||||||
|
move(directory: string, toIndex: number) {
|
||||||
|
const key = origin()
|
||||||
|
if (!key) return
|
||||||
|
const current = store.projects[key] ?? []
|
||||||
|
const fromIndex = current.findIndex((x) => x.worktree === directory)
|
||||||
|
if (fromIndex === -1 || fromIndex === toIndex) return
|
||||||
|
const result = [...current]
|
||||||
|
const [item] = result.splice(fromIndex, 1)
|
||||||
|
result.splice(toIndex, 0, item)
|
||||||
|
setStore("projects", key, result)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { produce } from "solid-js/store"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { createMemo } from "solid-js"
|
import { produce, reconcile } from "solid-js/store"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
@@ -18,8 +18,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return {
|
return {
|
||||||
data: store,
|
data: store,
|
||||||
set: setStore,
|
set: setStore,
|
||||||
|
get status() {
|
||||||
|
return store.status
|
||||||
|
},
|
||||||
get ready() {
|
get ready() {
|
||||||
return store.ready
|
return store.status !== "loading"
|
||||||
},
|
},
|
||||||
get project() {
|
get project() {
|
||||||
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
|
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
|
||||||
@@ -56,42 +59,75 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||||
messages.splice(result.index, 0, message)
|
messages.splice(result.index, 0, message)
|
||||||
}
|
}
|
||||||
draft.part[input.messageID] = input.parts.slice()
|
draft.part[input.messageID] = input.parts
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
async sync(sessionID: string, _isRetry = false) {
|
async sync(sessionID: string, _isRetry = false) {
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
retry(() => sdk.client.session.get({ sessionID })),
|
retry(() => sdk.client.session.get({ sessionID })),
|
||||||
retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
|
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
|
||||||
retry(() => sdk.client.session.todo({ sessionID })),
|
retry(() => sdk.client.session.todo({ sessionID })),
|
||||||
retry(() => sdk.client.session.diff({ sessionID })),
|
retry(() => sdk.client.session.diff({ sessionID })),
|
||||||
])
|
])
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
batch(() => {
|
||||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
setStore(
|
||||||
if (match.found) draft.session[match.index] = session.data!
|
"session",
|
||||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
produce((draft) => {
|
||||||
draft.todo[sessionID] = todo.data ?? []
|
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||||
draft.message[sessionID] = messages
|
if (match.found) {
|
||||||
.data!.map((x) => x.info)
|
draft[match.index] = session.data!
|
||||||
.slice()
|
return
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
}
|
||||||
for (const message of messages.data!) {
|
draft.splice(match.index, 0, session.data!)
|
||||||
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
}),
|
||||||
}
|
)
|
||||||
draft.session_diff[sessionID] = diff.data ?? []
|
|
||||||
}),
|
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||||
)
|
setStore(
|
||||||
|
"message",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
(messages.data ?? [])
|
||||||
|
.map((x) => x.info)
|
||||||
|
.filter((m) => !!m?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const message of messages.data ?? []) {
|
||||||
|
if (!message?.info?.id) continue
|
||||||
|
setStore(
|
||||||
|
"part",
|
||||||
|
message.info.id,
|
||||||
|
reconcile(
|
||||||
|
message.parts
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||||
|
})
|
||||||
},
|
},
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
setStore("limit", (x) => x + count)
|
setStore("limit", (x) => x + count)
|
||||||
await sdk.client.session.list().then((x) => {
|
await sdk.client.session.list().then((x) => {
|
||||||
const sessions = (x.data ?? [])
|
const sessions = (x.data ?? [])
|
||||||
|
.filter((s) => !!s?.id)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
.slice(0, store.limit)
|
.slice(0, store.limit)
|
||||||
setStore("session", sessions)
|
setStore("session", reconcile(sessions, { key: "id" }))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
more: createMemo(() => store.session.length >= store.limit),
|
more: createMemo(() => store.session.length >= store.limit),
|
||||||
@@ -36,35 +36,49 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
all: createMemo(() => Object.values(store.all)),
|
all: createMemo(() => Object.values(store.all)),
|
||||||
active: createMemo(() => store.active),
|
active: createMemo(() => store.active),
|
||||||
new() {
|
new() {
|
||||||
sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
|
sdk.client.pty
|
||||||
const id = pty.data?.id
|
.create({ title: `Terminal ${store.all.length + 1}` })
|
||||||
if (!id) return
|
.then((pty) => {
|
||||||
setStore("all", [
|
const id = pty.data?.id
|
||||||
...store.all,
|
if (!id) return
|
||||||
{
|
setStore("all", [
|
||||||
id,
|
...store.all,
|
||||||
title: pty.data?.title ?? "Terminal",
|
{
|
||||||
},
|
id,
|
||||||
])
|
title: pty.data?.title ?? "Terminal",
|
||||||
setStore("active", id)
|
},
|
||||||
})
|
])
|
||||||
|
setStore("active", id)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to create terminal", e)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
||||||
sdk.client.pty.update({
|
sdk.client.pty
|
||||||
ptyID: pty.id,
|
.update({
|
||||||
title: pty.title,
|
ptyID: pty.id,
|
||||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
title: pty.title,
|
||||||
})
|
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to update terminal", e)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async clone(id: string) {
|
async clone(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
const pty = store.all[index]
|
const pty = store.all[index]
|
||||||
if (!pty) return
|
if (!pty) return
|
||||||
const clone = await sdk.client.pty.create({
|
const clone = await sdk.client.pty
|
||||||
title: pty.title,
|
.create({
|
||||||
})
|
title: pty.title,
|
||||||
if (!clone.data) return
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to clone terminal", e)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!clone?.data) return
|
||||||
setStore("all", index, {
|
setStore("all", index, {
|
||||||
...pty,
|
...pty,
|
||||||
...clone.data,
|
...clone.data,
|
||||||
@@ -88,7 +102,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
setStore("active", previous?.id)
|
setStore("active", previous?.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await sdk.client.pty.remove({ ptyID: id })
|
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
|
||||||
|
console.error("Failed to close terminal", e)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
move(id: string, to: number) {
|
move(id: string, to: number) {
|
||||||
const index = store.all.findIndex((f) => f.id === id)
|
const index = store.all.findIndex((f) => f.id === id)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// @refresh reload
|
||||||
|
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)) {
|
||||||
|
throw new Error(
|
||||||
|
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform: Platform = {
|
||||||
|
platform: "web",
|
||||||
|
version: pkg.version,
|
||||||
|
openLink(url: string) {
|
||||||
|
window.open(url, "_blank")
|
||||||
|
},
|
||||||
|
restart: async () => {
|
||||||
|
window.location.reload()
|
||||||
|
},
|
||||||
|
notify: async (title, description, href) => {
|
||||||
|
if (!("Notification" in window)) return
|
||||||
|
|
||||||
|
const permission =
|
||||||
|
Notification.permission === "default"
|
||||||
|
? await Notification.requestPermission().catch(() => "denied")
|
||||||
|
: Notification.permission
|
||||||
|
|
||||||
|
if (permission !== "granted") return
|
||||||
|
|
||||||
|
const inView = document.visibilityState === "visible" && document.hasFocus()
|
||||||
|
if (inView) return
|
||||||
|
|
||||||
|
await Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
const notification = new Notification(title, {
|
||||||
|
body: description ?? "",
|
||||||
|
icon: "https://opencode.ai/favicon-96x96.png",
|
||||||
|
})
|
||||||
|
notification.onclick = () => {
|
||||||
|
window.focus()
|
||||||
|
if (href) {
|
||||||
|
window.history.pushState(null, "", href)
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"))
|
||||||
|
}
|
||||||
|
notification.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
() => (
|
||||||
|
<PlatformProvider value={platform}>
|
||||||
|
<App />
|
||||||
|
</PlatformProvider>
|
||||||
|
),
|
||||||
|
root!,
|
||||||
|
)
|
||||||
+10
-2
@@ -1,8 +1,9 @@
|
|||||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { SyncProvider, useSync } from "@/context/sync"
|
import { SyncProvider, useSync } from "@/context/sync"
|
||||||
import { LocalProvider } from "@/context/local"
|
import { LocalProvider } from "@/context/local"
|
||||||
|
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import { DataProvider } from "@opencode-ai/ui/context"
|
import { DataProvider } from "@opencode-ai/ui/context"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
@@ -18,8 +19,15 @@ export default function Layout(props: ParentProps) {
|
|||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
{iife(() => {
|
{iife(() => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const respond = (input: {
|
||||||
|
sessionID: string
|
||||||
|
permissionID: string
|
||||||
|
response: "once" | "always" | "reject"
|
||||||
|
}) => sdk.client.permission.respond(input)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataProvider data={sync.data} directory={directory()}>
|
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
|
||||||
<LocalProvider>{props.children}</LocalProvider>
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
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 { createStore } from "solid-js/store"
|
||||||
|
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 safeJson(value: unknown): string {
|
||||||
|
const seen = new WeakSet<object>()
|
||||||
|
const json = JSON.stringify(
|
||||||
|
value,
|
||||||
|
(_key, val) => {
|
||||||
|
if (typeof val === "bigint") return val.toString()
|
||||||
|
if (typeof val === "object" && val) {
|
||||||
|
if (seen.has(val)) return "[Circular]"
|
||||||
|
seen.add(val)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
return json ?? String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "ProviderAuthError": {
|
||||||
|
const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
|
||||||
|
const message = typeof data.message === "string" ? data.message : safeJson(data.message)
|
||||||
|
return `Provider authentication failed (${providerID}): ${message}`
|
||||||
|
}
|
||||||
|
case "APIError": {
|
||||||
|
const message = typeof data.message === "string" ? data.message : "API error"
|
||||||
|
const lines: string[] = [message]
|
||||||
|
|
||||||
|
if (typeof data.statusCode === "number") {
|
||||||
|
lines.push(`Status: ${data.statusCode}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.isRetryable === "boolean") {
|
||||||
|
lines.push(`Retryable: ${data.isRetryable}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.responseBody === "string" && data.responseBody) {
|
||||||
|
lines.push(`Response body:\n${data.responseBody}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
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": {
|
||||||
|
const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
|
||||||
|
return `Failed to initialize provider "${providerID}". Check credentials and configuration.`
|
||||||
|
}
|
||||||
|
case "ConfigJsonError": {
|
||||||
|
const message = typeof data.message === "string" ? data.message : ""
|
||||||
|
return `Config file at ${data.path} is not valid JSON(C)` + (message ? `: ${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("."),
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
const message = typeof data.message === "string" ? data.message : ""
|
||||||
|
return [`Config file at ${data.path} is invalid` + (message ? `: ${message}` : ""), ...issues].join("\n")
|
||||||
|
}
|
||||||
|
case "UnknownError":
|
||||||
|
return typeof data.message === "string" ? data.message : safeJson(data)
|
||||||
|
default:
|
||||||
|
if (typeof data.message === "string") return data.message
|
||||||
|
return safeJson(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 + `${error.name}\n${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` : ""
|
||||||
|
|
||||||
|
const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
|
||||||
|
const stack = error.stack?.trim()
|
||||||
|
|
||||||
|
if (stack) {
|
||||||
|
const startsWithHeader = stack.startsWith(header)
|
||||||
|
|
||||||
|
if (isDuplicate && startsWithHeader) {
|
||||||
|
const trace = stack.split("\n").slice(1).join("\n").trim()
|
||||||
|
if (trace) {
|
||||||
|
parts.push(indent + trace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDuplicate && !startsWithHeader) {
|
||||||
|
parts.push(indent + stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDuplicate && startsWithHeader) {
|
||||||
|
parts.push(indent + stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDuplicate && !startsWithHeader) {
|
||||||
|
parts.push(indent + `${header}\n${stack}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stack && !isDuplicate) {
|
||||||
|
parts.push(indent + header)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 + safeJson(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatError(error: unknown): string {
|
||||||
|
return formatErrorChain(error, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorPageProps {
|
||||||
|
error: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
checking: false,
|
||||||
|
version: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function checkForUpdates() {
|
||||||
|
if (!platform.checkUpdate) return
|
||||||
|
setStore("checking", true)
|
||||||
|
const result = await platform.checkUpdate()
|
||||||
|
setStore("checking", false)
|
||||||
|
if (result.updateAvailable && result.version) setStore("version", result.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installUpdate() {
|
||||||
|
if (!platform.update || !platform.restart) return
|
||||||
|
await platform.update()
|
||||||
|
await platform.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
label="Error Details"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button size="large" onClick={platform.restart}>
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
<Show when={platform.checkUpdate}>
|
||||||
|
<Show
|
||||||
|
when={store.version}
|
||||||
|
fallback={
|
||||||
|
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
|
||||||
|
{store.checking ? "Checking..." : "Check for updates"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button size="large" onClick={installUpdate}>
|
||||||
|
Update to {store.version}
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user