mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-02 23:04:07 +08:00
Compare commits
990 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b4825e1d | ||
|
|
22d92aa505 | ||
|
|
6357869e81 | ||
|
|
fb59b64b96 | ||
|
|
66352796ff | ||
|
|
b7447dc2d2 | ||
|
|
24be6e6901 | ||
|
|
a16554d445 | ||
|
|
2553137395 | ||
|
|
6b6b81556f | ||
|
|
ff23f67ad5 | ||
|
|
8f0644e35b | ||
|
|
3fdd23df16 | ||
|
|
2c82ee592c | ||
|
|
1ad529db59 | ||
|
|
96866e52ce | ||
|
|
507c975e92 | ||
|
|
3e69d5276b | ||
|
|
289a4d9b18 | ||
|
|
12bf5f641d | ||
|
|
2051e85e96 | ||
|
|
12b86829d9 | ||
|
|
6c9ec54129 | ||
|
|
b7b0cdbd7c | ||
|
|
fd98c3189a | ||
|
|
1278353616 | ||
|
|
638ec7bc50 | ||
|
|
38ae7d60aa | ||
|
|
2d1f9fc321 | ||
|
|
ee0c8132db | ||
|
|
c2208fa1f9 | ||
|
|
bf42d8b011 | ||
|
|
0deb85fa45 | ||
|
|
da19b10703 | ||
|
|
80b17dab44 | ||
|
|
6d2ffa82de | ||
|
|
7998c3b5ce | ||
|
|
13def91e9a | ||
|
|
26a40610dd | ||
|
|
db2fbed691 | ||
|
|
3d4c1425d9 | ||
|
|
10c8b49590 | ||
|
|
500cea5ce7 | ||
|
|
5aafab118f | ||
|
|
01f8d3b05d | ||
|
|
99d6a28249 | ||
|
|
5eaf7ab586 | ||
|
|
e4f754eee7 | ||
|
|
f20ef61bc7 | ||
|
|
5611ef8b28 | ||
|
|
bec796e3c3 | ||
|
|
0bd8b2c72f | ||
|
|
5550ce47e1 | ||
|
|
2d84dadc0c | ||
|
|
45c0578b22 | ||
|
|
1ded535175 | ||
|
|
d957ab849b | ||
|
|
4b2e52c834 | ||
|
|
6867658c0f | ||
|
|
b8620395cb | ||
|
|
90d37c98f8 | ||
|
|
c9a40917c2 | ||
|
|
0aa0e740cd | ||
|
|
bb17d14665 | ||
|
|
cd0b2ae032 | ||
|
|
8e8796507d | ||
|
|
cef5c29583 | ||
|
|
acaed1f270 | ||
|
|
cda0dbc195 | ||
|
|
758425a8e4 | ||
|
|
93446df335 | ||
|
|
adc8b90e0f | ||
|
|
733c9903ec | ||
|
|
7306e20361 | ||
|
|
b4c7042c17 | ||
|
|
6965787b33 | ||
|
|
ce064b8b0e | ||
|
|
0fc546fc6b | ||
|
|
77ac9e5ec2 | ||
|
|
af2c0b3695 | ||
|
|
811b22367d | ||
|
|
933d50e25a | ||
|
|
800bee2722 | ||
|
|
5b4fb96c2e | ||
|
|
1d20bf343d | ||
|
|
79d9bf57f7 | ||
|
|
7b63db6a13 | ||
|
|
0e1565449e | ||
|
|
f9a47fe5a3 | ||
|
|
2bf9d5d4ec | ||
|
|
c18f9ece69 | ||
|
|
4e3c73c4f5 | ||
|
|
8bf2eeccd0 | ||
|
|
6232e0fc58 | ||
|
|
a8b4aed446 | ||
|
|
03de0c406d | ||
|
|
faf8da8743 | ||
|
|
3386908fd6 | ||
|
|
5a8847952a | ||
|
|
87d21ebf2b | ||
|
|
a524fc545c | ||
|
|
4316edaf43 | ||
|
|
d845924e8b | ||
|
|
a29b322bdd | ||
|
|
9723ffa7a6 | ||
|
|
f06cd88773 | ||
|
|
9af92b6914 | ||
|
|
8f64c4b312 | ||
|
|
a32877e908 | ||
|
|
6465c9c44a | ||
|
|
4699739814 | ||
|
|
c1d87c32a2 | ||
|
|
9c5d9be33a | ||
|
|
97d9c851e6 | ||
|
|
76bd702992 | ||
|
|
50c453e577 | ||
|
|
86d5b25d18 | ||
|
|
2b44dbdbf1 | ||
|
|
4bbbbac5f6 | ||
|
|
3c3a997d2a | ||
|
|
1676f8b5dd | ||
|
|
c87a7469a0 | ||
|
|
132e26ddbf | ||
|
|
f1da70b1de | ||
|
|
5c9d1910af | ||
|
|
18abcab208 | ||
|
|
01e7dc2d02 | ||
|
|
611854e4b6 | ||
|
|
d56dec4ba7 | ||
|
|
c952e9ae3d | ||
|
|
6470243095 | ||
|
|
c8321cfbd9 | ||
|
|
46c246e01f | ||
|
|
9964d8e6c0 | ||
|
|
df33143396 | ||
|
|
571aeaaea2 | ||
|
|
edfea03917 | ||
|
|
81c88cc742 | ||
|
|
99b9390d80 | ||
|
|
23c30521d8 | ||
|
|
e681d610de | ||
|
|
a1fdeded3e | ||
|
|
2051312d12 | ||
|
|
20cb7a76af | ||
|
|
a493aec174 | ||
|
|
3ce3ac8e61 | ||
|
|
91ad64feda | ||
|
|
60b55f9d92 | ||
|
|
3c6c2bf13b | ||
|
|
d4f9375548 | ||
|
|
28b39f547e | ||
|
|
7520f5efa8 | ||
|
|
eb4cdf4b20 | ||
|
|
9f6fc1c3c5 | ||
|
|
dfede9ae6e | ||
|
|
fc45c0c944 | ||
|
|
9d869f784c | ||
|
|
bd244f73af | ||
|
|
dd34556e9c | ||
|
|
f7dd48e60d | ||
|
|
93c779cf48 | ||
|
|
360c04c542 | ||
|
|
529fd57e75 | ||
|
|
faea3777e1 | ||
|
|
a4664e2344 | ||
|
|
cdc1d8a94d | ||
|
|
fdd6d6600f | ||
|
|
9f44cfd595 | ||
|
|
70229b150c | ||
|
|
050ff943a6 | ||
|
|
88b58fd6a0 | ||
|
|
5d67e13df5 | ||
|
|
57d1a60efc | ||
|
|
add81b9739 | ||
|
|
81bdb8e269 | ||
|
|
a563fdd287 | ||
|
|
7c93bf5993 | ||
|
|
6a5a4247c6 | ||
|
|
a39136a2a0 | ||
|
|
9f5b59f336 | ||
|
|
01c125b058 | ||
|
|
d41aa2bc72 | ||
|
|
f45deb37f0 | ||
|
|
e89972a396 | ||
|
|
c3c647a21a | ||
|
|
b79167ce66 | ||
|
|
7ac0a2bc65 | ||
|
|
cb032cff2b | ||
|
|
867a69a751 | ||
|
|
20b8efcc50 | ||
|
|
a86d42149f | ||
|
|
82a36acfe3 | ||
|
|
0793c3f2a3 | ||
|
|
5c860b0d69 | ||
|
|
05bb127a8e | ||
|
|
1bbd84008f | ||
|
|
fdfd4d69d3 | ||
|
|
7f659cce36 | ||
|
|
48fcaa83be | ||
|
|
70c16c4c95 | ||
|
|
c1e1ef6eb5 | ||
|
|
bb155db8b2 | ||
|
|
7c91f668d1 | ||
|
|
1af103d29e | ||
|
|
8a3e581edc | ||
|
|
749e7838a4 | ||
|
|
73b46c2bf9 | ||
|
|
8bd250fb15 | ||
|
|
b1ab641905 | ||
|
|
76e256ed64 | ||
|
|
4f955f2127 | ||
|
|
bbeb579d3a | ||
|
|
f707fb3f8d | ||
|
|
6b98acb7be | ||
|
|
2487b18f62 | ||
|
|
533f64fe26 | ||
|
|
b5c85d3806 | ||
|
|
bcf952bc8a | ||
|
|
a6dc75a44c | ||
|
|
416daca9c6 | ||
|
|
636fe0fb64 | ||
|
|
95e0957d64 | ||
|
|
2eefdae6a9 | ||
|
|
d62746ceb7 | ||
|
|
4b2ce14ff3 | ||
|
|
294a11752e | ||
|
|
1cf1d1f634 | ||
|
|
2ce694d41f | ||
|
|
d6eff3b3a3 | ||
|
|
e63a6d45c1 | ||
|
|
93686519ba | ||
|
|
f593792fb5 | ||
|
|
2cdb37c32b | ||
|
|
535d79b64c | ||
|
|
b4e4c3f662 | ||
|
|
ba676e7ae0 | ||
|
|
a1c8e5af45 | ||
|
|
f1e7e7c138 | ||
|
|
80b77caec0 | ||
|
|
86a2ea44b5 | ||
|
|
a2002c88c6 | ||
|
|
d8bcf4f4e7 | ||
|
|
31e0326f78 | ||
|
|
a53d2ea356 | ||
|
|
229a280652 | ||
|
|
8d0350d923 | ||
|
|
4192d7eacc | ||
|
|
7b8b4cf8c7 | ||
|
|
1f4de75348 | ||
|
|
457755c690 | ||
|
|
052a1e7514 | ||
|
|
139d6e2818 | ||
|
|
06554efdf4 | ||
|
|
67e9bda94f | ||
|
|
53bb6b4c4f | ||
|
|
73d54c7068 | ||
|
|
90d6c4ab41 | ||
|
|
736396fc70 | ||
|
|
177bfed93e | ||
|
|
91f8477ef5 | ||
|
|
f04a5e50ee | ||
|
|
bb28b70700 | ||
|
|
7361a02ef3 | ||
|
|
d465f150fc | ||
|
|
17fa8c117b | ||
|
|
9aa0c40a00 | ||
|
|
fd4648da17 | ||
|
|
aadca5013a | ||
|
|
5c3d490e59 | ||
|
|
1254f48135 | ||
|
|
1729c310d9 | ||
|
|
0130190bbd | ||
|
|
97a31ddffc | ||
|
|
3249420ad1 | ||
|
|
4bb8536d34 | ||
|
|
c73d4a137e | ||
|
|
57ac8f2741 | ||
|
|
2f1acee5a1 | ||
|
|
9ca54020ac | ||
|
|
f7d44b178b | ||
|
|
b4950a157c | ||
|
|
dfbef066c7 | ||
|
|
26fd76fbee | ||
|
|
04769d8a26 | ||
|
|
34b576d9b5 | ||
|
|
22b244f847 | ||
|
|
7e1fc275e7 | ||
|
|
3b9b391320 | ||
|
|
766bfd025c | ||
|
|
c7f30e1065 | ||
|
|
1c4fd7f28f | ||
|
|
85805d2c38 | ||
|
|
982cb3e71a | ||
|
|
294d0e7ee3 | ||
|
|
8be1ca836c | ||
|
|
2e5f96fa41 | ||
|
|
c056b0add9 | ||
|
|
b00bb3c083 | ||
|
|
d9befd3aa6 | ||
|
|
49de703ba1 | ||
|
|
22988894c8 | ||
|
|
34b1754f25 | ||
|
|
54fe3504ba | ||
|
|
d2c862e32d | ||
|
|
afc53afb35 | ||
|
|
b56e49c5dc | ||
|
|
8b2a909e1f | ||
|
|
e9c954d45e | ||
|
|
6f449d13af | ||
|
|
6e375bef0d | ||
|
|
67106a6967 | ||
|
|
b5d690620d | ||
|
|
9db3ce1d0b | ||
|
|
1cc55b68ef | ||
|
|
469f667774 | ||
|
|
6603d9a9f0 | ||
|
|
5dc1920a4c | ||
|
|
d3e5f3f3a8 | ||
|
|
ce4cb820f7 | ||
|
|
ba5be6b625 | ||
|
|
f95c3f4177 | ||
|
|
d2b1307bff | ||
|
|
b40ba32adc | ||
|
|
ce0cebb7d7 | ||
|
|
f478f89a68 | ||
|
|
85d95f0f2b | ||
|
|
1515efc77c | ||
|
|
6d393759e1 | ||
|
|
a1701678cd | ||
|
|
c411a26d6f | ||
|
|
85dbfeb314 | ||
|
|
085c0e4e2b | ||
|
|
8404a97c3e | ||
|
|
0ee3b1ede2 | ||
|
|
a826936702 | ||
|
|
fd4a5d5a63 | ||
|
|
69cf1d7b7e | ||
|
|
8e0a1d1167 | ||
|
|
f22021187d | ||
|
|
febecc348a | ||
|
|
c5ccfc3e94 | ||
|
|
1f6efc6b94 | ||
|
|
727fe6f942 | ||
|
|
a91e79382e | ||
|
|
5c626e0a2f | ||
|
|
8e9e383219 | ||
|
|
f383008cc1 | ||
|
|
303ade25ed | ||
|
|
53f8e7850e | ||
|
|
ca8ce88354 | ||
|
|
37a86439c4 | ||
|
|
269b43f4de | ||
|
|
3f25e5bf86 | ||
|
|
67765fa47c | ||
|
|
58b1c58bc5 | ||
|
|
d80badc50f | ||
|
|
75279e5ccf | ||
|
|
7893b84614 | ||
|
|
cfc715bd48 | ||
|
|
39bcba85a9 | ||
|
|
da3df51316 | ||
|
|
12190e4efc | ||
|
|
d2a9b2f64a | ||
|
|
aacadd8a8a | ||
|
|
969154a473 | ||
|
|
4d6ca3fab1 | ||
|
|
00ea5082e7 | ||
|
|
4a878b88c0 | ||
|
|
6de955847c | ||
|
|
3ba5d528b4 | ||
|
|
f99e2b3429 | ||
|
|
7e4e6f6e51 | ||
|
|
0514f3f43b | ||
|
|
1e07384364 | ||
|
|
4c4739c422 | ||
|
|
2d8b90a6ff | ||
|
|
a2fa7ffa42 | ||
|
|
f7d6175283 | ||
|
|
9ed187ee52 | ||
|
|
14d81e574b | ||
|
|
6efe8cc8df | ||
|
|
daa5fc916a | ||
|
|
c659496b96 | ||
|
|
21fbf21cb6 | ||
|
|
f31cbf2744 | ||
|
|
8322f18e03 | ||
|
|
562bdb95e2 | ||
|
|
a57ce8365d | ||
|
|
0da83ae67e | ||
|
|
662d022a48 | ||
|
|
9efef03919 | ||
|
|
7a9fb3fa92 | ||
|
|
ea96ead346 | ||
|
|
6100a77b85 | ||
|
|
c7a59ee2b1 | ||
|
|
a272b58fe9 | ||
|
|
9948fcf1b6 | ||
|
|
0d50c867ff | ||
|
|
27f7e02f12 | ||
|
|
0f93ecd564 | ||
|
|
da909d9684 | ||
|
|
facd851b11 | ||
|
|
c51de945a5 | ||
|
|
9253a3ca9e | ||
|
|
7cfa297a78 | ||
|
|
661b74def6 | ||
|
|
b478e5655c | ||
|
|
f884766445 | ||
|
|
76b2e4539c | ||
|
|
d87922c0eb | ||
|
|
2446483df5 | ||
|
|
f4c453155d | ||
|
|
969ad80ed2 | ||
|
|
af064b41d7 | ||
|
|
ea6bfef21a | ||
|
|
107363b1d9 | ||
|
|
85214d7c59 | ||
|
|
997cb2d945 | ||
|
|
45b139390c | ||
|
|
994368de15 | ||
|
|
143fd8e076 | ||
|
|
06dba28bd6 | ||
|
|
b8d276a049 | ||
|
|
ee01f01271 | ||
|
|
32d5db4f0a | ||
|
|
f6108b7be8 | ||
|
|
94ef341c9d | ||
|
|
f9abc7c84f | ||
|
|
891ed6ebc0 | ||
|
|
163e23a68b | ||
|
|
f13b0af491 | ||
|
|
4a0be45d3d | ||
|
|
23788674c8 | ||
|
|
121eb24e73 | ||
|
|
571d60182a | ||
|
|
167a9dcaf3 | ||
|
|
37327259cb | ||
|
|
cdb25656d5 | ||
|
|
25c876caa2 | ||
|
|
cf83e31f23 | ||
|
|
3bc238b58b | ||
|
|
b8de69dced | ||
|
|
e7fcb692a4 | ||
|
|
dae38574ab | ||
|
|
ed4f862b49 | ||
|
|
fce59db94a | ||
|
|
3e2a0c7281 | ||
|
|
5a0910ea79 | ||
|
|
1dffabcfda | ||
|
|
c389e0ed43 | ||
|
|
204801052a | ||
|
|
2528d8cb88 | ||
|
|
6b73ffd1c1 | ||
|
|
0eadc50a33 | ||
|
|
aeea84a877 | ||
|
|
a54c5c6298 | ||
|
|
8825cd3811 | ||
|
|
3d9a5d9970 | ||
|
|
1f9e195fa6 | ||
|
|
73c012c76c | ||
|
|
2ace57404b | ||
|
|
8c4b5e088b | ||
|
|
69920a73d7 | ||
|
|
ae76a3467a | ||
|
|
701107cda4 | ||
|
|
b99565959b | ||
|
|
67aa7ce04d | ||
|
|
c663fbc3ee | ||
|
|
2090bab537 | ||
|
|
64d5fff9a3 | ||
|
|
925f695503 | ||
|
|
f1c925795d | ||
|
|
c82a060eca | ||
|
|
63e783ef79 | ||
|
|
35d6273fb3 | ||
|
|
b89d4a16fd | ||
|
|
2799a96032 | ||
|
|
8f4b79227c | ||
|
|
c810b6d206 | ||
|
|
fa35407572 | ||
|
|
8bbbc07aff | ||
|
|
75a21ba3ce | ||
|
|
0d6fb68a88 | ||
|
|
242b886434 | ||
|
|
caf465a9da | ||
|
|
bbf77c6139 | ||
|
|
53b7e04b86 | ||
|
|
9e75e3ed18 | ||
|
|
6389858d41 | ||
|
|
7e5941e14b | ||
|
|
c68aeed8d9 | ||
|
|
b199a609a8 | ||
|
|
4a5a93b3f8 | ||
|
|
e99bdcefac | ||
|
|
26dcb85de1 | ||
|
|
11d042be25 | ||
|
|
33b5fe236a | ||
|
|
d56991006c | ||
|
|
739a9f71c3 | ||
|
|
aef81fce0b | ||
|
|
8f3d7b4038 | ||
|
|
de15e67834 | ||
|
|
fea56d8de6 | ||
|
|
3d71be2b45 | ||
|
|
58baca2a5b | ||
|
|
ef73926db6 | ||
|
|
9ad1687f04 | ||
|
|
c573270e66 | ||
|
|
9ebad68274 | ||
|
|
03664ba588 | ||
|
|
5a107b275c | ||
|
|
dd5736fe5f | ||
|
|
9f3ba03965 | ||
|
|
d090c08ef0 | ||
|
|
68e82e4d94 | ||
|
|
a4aa0e6f8d | ||
|
|
8c1ae2717c | ||
|
|
72d48759d7 | ||
|
|
986144b377 | ||
|
|
1fdb326aa7 | ||
|
|
463257e7e4 | ||
|
|
0f41e60bd6 | ||
|
|
7df81f7b3e | ||
|
|
dd22cb2bb0 | ||
|
|
248325925f | ||
|
|
ca48a4f0fb | ||
|
|
98ee5a3d87 | ||
|
|
67480e5a1c | ||
|
|
2581a9b54c | ||
|
|
14a293e124 | ||
|
|
780419ecae | ||
|
|
f0962e2d9c | ||
|
|
3a9584a419 | ||
|
|
196f42cbff | ||
|
|
322385f6b1 | ||
|
|
b7446cd7b9 | ||
|
|
f618e569ab | ||
|
|
7b394b91e2 | ||
|
|
6a7983a4ea | ||
|
|
737146fca1 | ||
|
|
688f3fd12f | ||
|
|
145df08444 | ||
|
|
8b400515ea | ||
|
|
289797f56d | ||
|
|
be0811ecc3 | ||
|
|
0676bcd4fd | ||
|
|
d076def561 | ||
|
|
e0807d7317 | ||
|
|
fa2723f2d0 | ||
|
|
87d62514db | ||
|
|
2f8cf9146b | ||
|
|
8e0ec6b037 | ||
|
|
6dc434cb83 | ||
|
|
d972c27f03 | ||
|
|
9e2bb63688 | ||
|
|
49053b66a9 | ||
|
|
47497aef07 | ||
|
|
8455029de1 | ||
|
|
9f07f89384 | ||
|
|
d840d43e8f | ||
|
|
9ead2f3dfb | ||
|
|
f3742ddbb8 | ||
|
|
b61a841aa8 | ||
|
|
ebcf11e574 | ||
|
|
065f0aaddf | ||
|
|
c0773dc7c5 | ||
|
|
1c3c74bd36 | ||
|
|
79bbf90b72 | ||
|
|
226a4a7f36 | ||
|
|
df3b424830 | ||
|
|
3cfd9d80bc | ||
|
|
e0553b8d2c | ||
|
|
391c837b37 | ||
|
|
5773d9d1a3 | ||
|
|
ce611963c3 | ||
|
|
f865cacfb8 | ||
|
|
2ec0611f42 | ||
|
|
334161a30e | ||
|
|
dbb6e55226 | ||
|
|
d0f9260559 | ||
|
|
d2176064e1 | ||
|
|
ed8d277e49 | ||
|
|
59b3268c64 | ||
|
|
d043f67761 | ||
|
|
51bf193889 | ||
|
|
f8b78f08b4 | ||
|
|
a4f32d602b | ||
|
|
dc3dd21cf3 | ||
|
|
b4c2fcccf5 | ||
|
|
e950ad5306 | ||
|
|
8ca713b737 | ||
|
|
5b54554fd5 | ||
|
|
4bc651f958 | ||
|
|
3b6976a9c8 | ||
|
|
863d5c1e8e | ||
|
|
97e19e9677 | ||
|
|
b27851461f | ||
|
|
209687377a | ||
|
|
90face1c09 | ||
|
|
936e2ce48b | ||
|
|
16ee8ee379 | ||
|
|
ac39308dad | ||
|
|
346b49219d | ||
|
|
d84c1f20c7 | ||
|
|
dfb8777555 | ||
|
|
008af18156 | ||
|
|
ab23167f80 | ||
|
|
b17ec46463 | ||
|
|
2e26b58d16 | ||
|
|
31b56e5a05 | ||
|
|
47c401cf25 | ||
|
|
fab8dc9e6f | ||
|
|
f39a2b1f16 | ||
|
|
66830ced4e | ||
|
|
9d3fad754d | ||
|
|
dcd3131f58 | ||
|
|
3d02e07161 | ||
|
|
4dbc6a43a6 | ||
|
|
5394b5188b | ||
|
|
8e680b3957 | ||
|
|
1b8cd796d6 | ||
|
|
35fba793d0 | ||
|
|
5358d43b74 | ||
|
|
f777347bac | ||
|
|
17c8b914df | ||
|
|
43b467dd12 | ||
|
|
0e0770921e | ||
|
|
8edbb74352 | ||
|
|
e6bfa95758 | ||
|
|
e4120b6287 | ||
|
|
ccbc9e00f2 | ||
|
|
7d13baadc8 | ||
|
|
9acc83697f | ||
|
|
db24bf87c0 | ||
|
|
f4c0d2d2fd | ||
|
|
d240f4c676 | ||
|
|
9c90cdbe08 | ||
|
|
fc7af31fe5 | ||
|
|
2f8d23ec66 | ||
|
|
77ae3fb9b9 | ||
|
|
4e7f6c47fd | ||
|
|
50469ed750 | ||
|
|
aaab785493 | ||
|
|
9751937894 | ||
|
|
0fc8dfc77e | ||
|
|
81b7df61ec | ||
|
|
8217b96d4a | ||
|
|
7dd0918d32 | ||
|
|
4b26b43855 | ||
|
|
9d7cfda9fe | ||
|
|
a3cf18c905 | ||
|
|
0b1a8ae699 | ||
|
|
eb70b1e5c8 | ||
|
|
00a3d818b6 | ||
|
|
2384c7e734 | ||
|
|
1bad3d9894 | ||
|
|
4f715e66dc | ||
|
|
ec001ca02f | ||
|
|
a2d3b9f0c8 | ||
|
|
9cfb6ff964 | ||
|
|
6ed661c140 | ||
|
|
9dc00edfc9 | ||
|
|
e063bf888e | ||
|
|
6f18475428 | ||
|
|
3664b09812 | ||
|
|
7050cc0ac3 | ||
|
|
4d3d63294d | ||
|
|
6bc61cbc2d | ||
|
|
01d351bebe | ||
|
|
dbba4a97aa | ||
|
|
0dc586faef | ||
|
|
f19c6b05f2 | ||
|
|
bc34f08333 | ||
|
|
b7ee16aabd | ||
|
|
ed1b0d97bf | ||
|
|
8d3b2fb821 | ||
|
|
fa991920bc | ||
|
|
5e79e3d7a5 | ||
|
|
966015c9ae | ||
|
|
61f057337a | ||
|
|
0b261054a2 | ||
|
|
e2e481cbb5 | ||
|
|
5140e83012 | ||
|
|
100d6212be | ||
|
|
f0e19a6542 | ||
|
|
00c4d4f9f8 | ||
|
|
6e6fe6e013 | ||
|
|
d05b60291e | ||
|
|
5162361372 | ||
|
|
d271b9f75b | ||
|
|
333569bed3 | ||
|
|
09b89fdb23 | ||
|
|
0e8c3359d1 | ||
|
|
37e0a7050f | ||
|
|
774dcb6980 | ||
|
|
28bc49ad17 | ||
|
|
dc1947838c | ||
|
|
3ea2daaa4c | ||
|
|
137e964131 | ||
|
|
8efbe497fd | ||
|
|
119d2d966c | ||
|
|
194415e785 | ||
|
|
1684042fb6 | ||
|
|
59f0004d34 | ||
|
|
da35a64fa1 | ||
|
|
460338ca53 | ||
|
|
53c18a64b4 | ||
|
|
b8144c5654 | ||
|
|
9081e17fcc | ||
|
|
ef3fd5900f | ||
|
|
453d690c11 | ||
|
|
c45be6a645 | ||
|
|
7b9b177088 | ||
|
|
3cee5b0470 | ||
|
|
9246d1c901 | ||
|
|
cc12abc83e | ||
|
|
4f7e4a9436 | ||
|
|
eee396f903 | ||
|
|
0d2f8e175a | ||
|
|
4df40e0d9b | ||
|
|
b72e17a8b7 | ||
|
|
61160dc220 | ||
|
|
98734ff28c | ||
|
|
9991352663 | ||
|
|
91c4da5dbd | ||
|
|
2fd0e7dd6b | ||
|
|
d50b7ad481 | ||
|
|
df95c49401 | ||
|
|
8b73c52f00 | ||
|
|
5603098d17 | ||
|
|
f436a50125 | ||
|
|
e19e977591 | ||
|
|
addbe295b1 | ||
|
|
9a573dedc6 | ||
|
|
9ea0d71e8d | ||
|
|
b1a3599017 | ||
|
|
7b0329f67f | ||
|
|
311b9c74dd | ||
|
|
f7e8dd2ff8 | ||
|
|
40b1dd7ef2 | ||
|
|
261e76e0a3 | ||
|
|
a300bfaccb | ||
|
|
41dba0db08 | ||
|
|
6674c6083a | ||
|
|
f6afa2c6bb | ||
|
|
b2fb0508ea | ||
|
|
93f4252bb1 | ||
|
|
46ab9c16dd | ||
|
|
d869df4fee | ||
|
|
b99d4650ec | ||
|
|
261bb7f110 | ||
|
|
0515fbb260 | ||
|
|
88211d8c5b | ||
|
|
a812f95b9d | ||
|
|
3728a12bee | ||
|
|
af07e51213 | ||
|
|
3113788c92 | ||
|
|
efb5fe6d4e | ||
|
|
54dd6c644d | ||
|
|
39ad8f2667 | ||
|
|
c4a2c84e53 | ||
|
|
44fe012812 | ||
|
|
f5e7f079ea | ||
|
|
15a8936806 | ||
|
|
4e4cff49c0 | ||
|
|
5540503bee | ||
|
|
193718034b | ||
|
|
72108c0296 | ||
|
|
ec1c9f8cd1 | ||
|
|
a85b0a370e | ||
|
|
e7784d2864 | ||
|
|
97c4815444 | ||
|
|
7d1a1663c8 | ||
|
|
24c0ce6e53 | ||
|
|
4cdc86612c | ||
|
|
f1f3f8d12c | ||
|
|
e78d3b54bf | ||
|
|
f8a7cd372d | ||
|
|
f48eac638d | ||
|
|
e1f12f93eb | ||
|
|
7ca8334a8b | ||
|
|
f1a2b2eba4 | ||
|
|
4b132656df | ||
|
|
26bab00dab | ||
|
|
568c04753e | ||
|
|
4a06e164d2 | ||
|
|
c57b52c300 | ||
|
|
0b8f48f17f | ||
|
|
3862184ccb | ||
|
|
8619c50976 | ||
|
|
bb6b56b72a | ||
|
|
1252b65166 | ||
|
|
6840276dad | ||
|
|
bd8c3cd0f1 | ||
|
|
e5e9b3e3c0 | ||
|
|
1e8a681de9 | ||
|
|
a834bedc17 | ||
|
|
6a3392385e | ||
|
|
6a00e063c4 | ||
|
|
73a0ce2b7d | ||
|
|
4d1afd01fa | ||
|
|
801d5f47bd | ||
|
|
b6caae9708 | ||
|
|
183ca64ef9 | ||
|
|
8c32cfe829 | ||
|
|
73dcc88da1 | ||
|
|
14bded65dc | ||
|
|
87d1d3fb62 | ||
|
|
e054454109 | ||
|
|
a6142cf975 | ||
|
|
69332e5fa3 | ||
|
|
20201ba3c4 | ||
|
|
658067186a | ||
|
|
ac777b77cf | ||
|
|
5944ae2023 | ||
|
|
2f10961ba8 | ||
|
|
fae97978a3 | ||
|
|
3423415e49 | ||
|
|
1d0bfc2b2a | ||
|
|
bd46cf0f86 | ||
|
|
d4157d9a96 | ||
|
|
6e4ef585d8 | ||
|
|
e05c3b7a76 | ||
|
|
f99904bc1c | ||
|
|
b796d6763f | ||
|
|
c1250abdf8 | ||
|
|
ebe51534a1 | ||
|
|
b8bbee4718 | ||
|
|
8f852b396f | ||
|
|
ae4d089c06 | ||
|
|
5110fbdaf9 | ||
|
|
e6ddb474fc | ||
|
|
0dc71774ce | ||
|
|
b470466e30 | ||
|
|
d1f9311931 | ||
|
|
1c58023df9 | ||
|
|
4e0aa58b7e | ||
|
|
23ee34b35f | ||
|
|
674c9a5220 | ||
|
|
54c86ed43a | ||
|
|
676d75ee75 | ||
|
|
70dc0a12f2 | ||
|
|
d579c5e8aa | ||
|
|
ee91f31313 | ||
|
|
57b3051024 | ||
|
|
ae5cf3cc23 | ||
|
|
68e1b3c46c | ||
|
|
2d68814abc | ||
|
|
a5da5127fa | ||
|
|
b5a4439704 | ||
|
|
9c5616521d | ||
|
|
3fe163416d | ||
|
|
d054f88130 | ||
|
|
b929b4f4b9 | ||
|
|
4c0c83b02d | ||
|
|
d6d45bdc63 | ||
|
|
13a83721b0 | ||
|
|
f0edffbae9 | ||
|
|
8131bee49a | ||
|
|
b5f44ae13f | ||
|
|
0d23f2a7fd | ||
|
|
ac096d84ad | ||
|
|
fcaf0e6dbf | ||
|
|
19e259d90d | ||
|
|
2c9fd1e776 | ||
|
|
63996c4189 | ||
|
|
c7bb7ce4de | ||
|
|
c8eb1b24c3 | ||
|
|
b9f894f1e9 | ||
|
|
7c0d10a4ce | ||
|
|
06af406146 | ||
|
|
0e3458b112 | ||
|
|
2d15c683e0 | ||
|
|
3c94d26570 | ||
|
|
1a553e525f | ||
|
|
3c4e966216 | ||
|
|
0721620ed8 | ||
|
|
9fc6734f32 | ||
|
|
e1733a423d | ||
|
|
d42e3db7e0 | ||
|
|
cdb26f6d83 | ||
|
|
fe05edaa79 | ||
|
|
7d174767b0 | ||
|
|
c5eefd1752 | ||
|
|
77a6b3bdd6 | ||
|
|
7effff56c0 | ||
|
|
e30fba0d3c | ||
|
|
7fbb2ca9a6 | ||
|
|
230d0a1510 | ||
|
|
46ff2c0ae0 | ||
|
|
b8a89dab0f | ||
|
|
7351e12886 | ||
|
|
38879dee2d | ||
|
|
c4ff8dd205 | ||
|
|
0e035b3115 | ||
|
|
b855511d9a | ||
|
|
783faf554d | ||
|
|
bfd4269d7d | ||
|
|
25f78b053b | ||
|
|
87f260ee17 | ||
|
|
12931a869d | ||
|
|
f759e1804d | ||
|
|
c9b4564d36 | ||
|
|
d097c546db | ||
|
|
adb54521b4 | ||
|
|
2ea0399aa7 | ||
|
|
fa1266263d | ||
|
|
fe109c921e | ||
|
|
37bb8895fe | ||
|
|
89b95be4de | ||
|
|
eaf295bac7 | ||
|
|
27d3cec477 | ||
|
|
574d494c3c | ||
|
|
0239761f31 | ||
|
|
a53f9165e9 | ||
|
|
ffc231bd8b | ||
|
|
3cf4ef56fb | ||
|
|
c738e26438 | ||
|
|
9c6aa82ac1 | ||
|
|
ef74d97491 | ||
|
|
af892e5432 | ||
|
|
d7aca6230d | ||
|
|
0f9c2c5c27 | ||
|
|
6a261dedb4 | ||
|
|
ec928d88b5 | ||
|
|
59a5f120c0 | ||
|
|
ce07f80b19 | ||
|
|
168fd9b2e3 | ||
|
|
df13b155f9 | ||
|
|
eeed5b8718 | ||
|
|
148ef90210 | ||
|
|
67023bb007 | ||
|
|
a316aed4fe | ||
|
|
9f7c0bd599 | ||
|
|
c7e1068f90 | ||
|
|
e2052d790b | ||
|
|
d3b2763c14 | ||
|
|
c6492de7ac | ||
|
|
d8fa0fb50c | ||
|
|
18ab8faa1d | ||
|
|
f35ce180e2 | ||
|
|
2bee48a9bc | ||
|
|
10ddd654cf | ||
|
|
61396b93ed | ||
|
|
62b9a30a9c | ||
|
|
5706c6ad3a | ||
|
|
e8e03c895a | ||
|
|
38667682a7 | ||
|
|
d7d5fc39fb | ||
|
|
0caf25adee | ||
|
|
37febc6873 | ||
|
|
4169f0c412 | ||
|
|
b7f06bbc1f | ||
|
|
1b8cfe9e99 | ||
|
|
97837d2d23 | ||
|
|
9abc2a0cf8 | ||
|
|
9fb47bc855 | ||
|
|
73e9fb53d5 | ||
|
|
f03637b1fc | ||
|
|
2c376c5abc | ||
|
|
442e1b52ad | ||
|
|
e8c3abc369 | ||
|
|
c8648baba2 | ||
|
|
7b3a799856 | ||
|
|
9356b6c35a | ||
|
|
29a6603a89 | ||
|
|
a454ba8895 | ||
|
|
5eae7aef0e | ||
|
|
1031bceef7 | ||
|
|
653965ef59 | ||
|
|
ca0ea3f94d | ||
|
|
98bd5109c2 | ||
|
|
78f65e4789 | ||
|
|
75dd2f75aa | ||
|
|
fe86e58bbb | ||
|
|
ae339015fc | ||
|
|
cce2e4ad75 | ||
|
|
a1ce35c208 | ||
|
|
69d6709a19 | ||
|
|
52ec134b2d | ||
|
|
db88bede05 | ||
|
|
d4d218d7d6 | ||
|
|
3e086e3ab9 | ||
|
|
2f5faae34b | ||
|
|
e3ad6a0698 | ||
|
|
b536b45536 | ||
|
|
81c245035f | ||
|
|
dda7059e57 | ||
|
|
0cca75ef48 | ||
|
|
ee1f55dbe2 |
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = 80
|
||||
37
.github/workflows/build.yml
vendored
37
.github/workflows/build.yml
vendored
@@ -1,37 +0,0 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ">=1.23.2"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- run: go mod download
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: build --snapshot --clean
|
||||
7
.github/workflows/deploy.yml
vendored
7
.github/workflows/deploy.yml
vendored
@@ -3,7 +3,8 @@ name: deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dontlook
|
||||
- dev
|
||||
- production
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -16,10 +17,10 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: 1.2.17
|
||||
|
||||
- run: bun install
|
||||
|
||||
- run: bun sst deploy --stage=dev
|
||||
- run: bun sst deploy --stage=${{ github.ref_name }}
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
14
.github/workflows/notify-discord.yml
vendored
Normal file
14
.github/workflows/notify-discord.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: discord
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published] # fires only when a release is published
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send nicely-formatted embed to Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
24
.github/workflows/opencode.yml
vendored
Normal file
24
.github/workflows/opencode.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: opencode
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
opencode:
|
||||
if: startsWith(github.event.comment.body, 'hey opencode')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run opencode
|
||||
uses: sst/opencode/sdks/github@github-v1
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
with:
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
30
.github/workflows/publish-github-action.yml
vendored
Normal file
30
.github/workflows/publish-github-action.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: publish-github-action
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "github-v*.*.*"
|
||||
- "!github-v1"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
git config --global user.email "opencode@sst.dev"
|
||||
git config --global user.name "opencode"
|
||||
./script/publish
|
||||
working-directory: ./sdks/github
|
||||
36
.github/workflows/publish-vscode.yml
vendored
Normal file
36
.github/workflows/publish-vscode.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: publish-vscode
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "vscode-v*.*.*"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.17
|
||||
|
||||
- run: git fetch --force --tags
|
||||
- run: bun install -g @vscode/vsce
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
bun install
|
||||
./script/publish
|
||||
working-directory: ./sdks/vscode
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OPENVSX_TOKEN: ${{ secrets.OPENVSX_TOKEN }}
|
||||
34
.github/workflows/publish.yml
vendored
34
.github/workflows/publish.yml
vendored
@@ -3,8 +3,12 @@ name: publish
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
tags:
|
||||
- "*"
|
||||
- "!vscode-v*"
|
||||
- "!github-v*"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -30,18 +34,32 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.16
|
||||
bun-version: 1.2.19
|
||||
|
||||
- name: Test npm auth
|
||||
run: npm whoami
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Install makepkg
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pacman-package-manager
|
||||
|
||||
- run: |
|
||||
- name: Setup SSH for AUR
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
git config --global user.email "opencode@sst.dev"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
bun install
|
||||
./script/publish.ts
|
||||
if [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then
|
||||
./script/publish.ts
|
||||
else
|
||||
./script/publish.ts --snapshot
|
||||
fi
|
||||
working-directory: ./packages/opencode
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
32
.github/workflows/stats.yml
vendored
Normal file
32
.github/workflows/stats.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: stats
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * *" # Run daily at 12:00 UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
stats:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Run stats script
|
||||
run: bun scripts/stats.ts
|
||||
|
||||
- name: Commit stats
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add STATS.md
|
||||
git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)"
|
||||
git push
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,3 +3,6 @@ node_modules
|
||||
.opencode
|
||||
.sst
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
openapi.json
|
||||
|
||||
13
AGENTS.md
Normal file
13
AGENTS.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## Style
|
||||
|
||||
- prefer single word variable/function names
|
||||
- avoid try catch where possible - prefer to let exceptions bubble up
|
||||
- avoid else statements where possible
|
||||
- do not make useless helper functions - inline functionality unless the
|
||||
function is reusable or composable
|
||||
- prefer Bun apis
|
||||
|
||||
## Workflow
|
||||
|
||||
- you can regenerate the golang sdk by calling ./scripts/stainless.ts
|
||||
- we use bun for everything
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 OpenCode
|
||||
Copyright (c) 2025 opencode
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
716
README.md
716
README.md
@@ -1,658 +1,110 @@
|
||||
◧ opencode
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/web/src/assets/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/web/src/assets/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/web/src/assets/logo-ornate-light.svg" alt="opencode logo">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">AI coding agent, built for the terminal.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||

|
||||
[](https://opencode.ai)
|
||||
|
||||
> **⚠️ Notice:** We are in progress of a complete overhaul in the `dontlook` branch - should be released mid June. The README below is for the current version
|
||||
---
|
||||
|
||||
A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenCode is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
|
||||
- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, Groq, Azure OpenAI, and OpenRouter
|
||||
- **Session Management**: Save and manage multiple conversation sessions
|
||||
- **Tool Integration**: AI can execute commands, search files, and modify code
|
||||
- **Vim-like Editor**: Integrated editor with text input capabilities
|
||||
- **Persistent Storage**: SQLite database for storing conversations and sessions
|
||||
- **LSP Integration**: Language Server Protocol support for code intelligence
|
||||
- **File Change Tracking**: Track and visualize file changes during sessions
|
||||
- **External Editor Support**: Open your preferred editor for composing messages
|
||||
- **Named Arguments for Custom Commands**: Create powerful custom commands with multiple named placeholders
|
||||
|
||||
## Installation
|
||||
|
||||
### Using the Install Script
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install the latest version
|
||||
# YOLO
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Install a specific version
|
||||
curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
|
||||
# Package managers
|
||||
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
||||
brew install sst/tap/opencode # macOS
|
||||
paru -S opencode-bin # Arch Linux
|
||||
```
|
||||
|
||||
### Using Homebrew (macOS and Linux)
|
||||
> [!TIP]
|
||||
> Remove versions older than 0.1.x before installing.
|
||||
|
||||
#### Installation Directory
|
||||
|
||||
The install script respects the following priority order for the installation path:
|
||||
|
||||
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
|
||||
2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path
|
||||
3. `$HOME/bin` - Standard user binary directory (if exists or can be created)
|
||||
4. `$HOME/.opencode/bin` - Default fallback
|
||||
|
||||
```bash
|
||||
brew install sst/tap/opencode
|
||||
# Examples
|
||||
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
### Using AUR (Arch Linux)
|
||||
### Documentation
|
||||
|
||||
For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs).
|
||||
|
||||
### Contributing
|
||||
|
||||
opencode is an opinionated tool so any fundamental feature needs to go through a
|
||||
design process with the core team.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> We do not accept PRs for core features.
|
||||
|
||||
However we still merge a ton of PRs - you can contribute:
|
||||
|
||||
- Bug fixes
|
||||
- Improvements to LLM performance
|
||||
- Support for new providers
|
||||
- Fixes for env specific quirks
|
||||
- Missing standard behavior
|
||||
- Documentation
|
||||
|
||||
Take a look at the git history to see what kind of PRs we end up merging.
|
||||
|
||||
> [!NOTE]
|
||||
> If you do not follow the above guidelines we might close your PR.
|
||||
|
||||
To run opencode locally you need.
|
||||
|
||||
- Bun
|
||||
- Golang 1.24.x
|
||||
|
||||
And run.
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -S opencode-bin
|
||||
|
||||
# Using paru
|
||||
paru -S opencode-bin
|
||||
$ bun install
|
||||
$ bun run packages/opencode/src/index.ts
|
||||
```
|
||||
|
||||
### Using Go
|
||||
#### Development Notes
|
||||
|
||||
```bash
|
||||
go install github.com/sst/opencode@latest
|
||||
```
|
||||
**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients.
|
||||
|
||||
## Configuration
|
||||
### FAQ
|
||||
|
||||
OpenCode looks for configuration in the following locations:
|
||||
#### How is this different than Claude Code?
|
||||
|
||||
- `$HOME/.opencode.json`
|
||||
- `$XDG_CONFIG_HOME/opencode/.opencode.json`
|
||||
- `./.opencode.json` (local directory)
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
### Environment Variables
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
|
||||
- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||
- A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
||||
|
||||
You can configure OpenCode using environment variables:
|
||||
#### What's the other repo?
|
||||
|
||||
| Environment Variable | Purpose |
|
||||
| -------------------------- | ------------------------------------------------------ |
|
||||
| `ANTHROPIC_API_KEY` | For Claude models |
|
||||
| `OPENAI_API_KEY` | For OpenAI models |
|
||||
| `GEMINI_API_KEY` | For Google Gemini models |
|
||||
| `VERTEXAI_PROJECT` | For Google Cloud VertexAI (Gemini) |
|
||||
| `VERTEXAI_LOCATION` | For Google Cloud VertexAI (Gemini) |
|
||||
| `GROQ_API_KEY` | For Groq models |
|
||||
| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
|
||||
| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
|
||||
| `AWS_REGION` | For AWS Bedrock (Claude) |
|
||||
| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
|
||||
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
|
||||
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
|
||||
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
|
||||
|
||||
### Configuration File Structure
|
||||
---
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"directory": ".opencode"
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"anthropic": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"groq": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"openrouter": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"primary": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 5000
|
||||
},
|
||||
"task": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 5000
|
||||
},
|
||||
"title": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 80
|
||||
}
|
||||
},
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"type": "stdio",
|
||||
"command": "path/to/mcp-server",
|
||||
"env": [],
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
"lsp": {
|
||||
"go": {
|
||||
"disabled": false,
|
||||
"command": "gopls"
|
||||
}
|
||||
},
|
||||
"shell": {
|
||||
"path": "/bin/zsh",
|
||||
"args": ["-l"]
|
||||
},
|
||||
"debug": false,
|
||||
"debugLSP": false
|
||||
}
|
||||
```
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
OpenCode supports a variety of AI models from different providers:
|
||||
|
||||
### OpenAI
|
||||
|
||||
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
|
||||
- GPT-4.5 Preview
|
||||
- GPT-4o family (gpt-4o, gpt-4o-mini)
|
||||
- O1 family (o1, o1-pro, o1-mini)
|
||||
- O3 family (o3, o3-mini)
|
||||
- O4 Mini
|
||||
|
||||
### Anthropic
|
||||
|
||||
- Claude 3.5 Sonnet
|
||||
- Claude 3.5 Haiku
|
||||
- Claude 3.7 Sonnet
|
||||
- Claude 3 Haiku
|
||||
- Claude 3 Opus
|
||||
|
||||
### Google
|
||||
|
||||
- Gemini 2.5
|
||||
- Gemini 2.5 Flash
|
||||
- Gemini 2.0 Flash
|
||||
- Gemini 2.0 Flash Lite
|
||||
|
||||
### AWS Bedrock
|
||||
|
||||
- Claude 3.7 Sonnet
|
||||
|
||||
### Groq
|
||||
|
||||
- Llama 4 Maverick (17b-128e-instruct)
|
||||
- Llama 4 Scout (17b-16e-instruct)
|
||||
- QWEN QWQ-32b
|
||||
- Deepseek R1 distill Llama 70b
|
||||
- Llama 3.3 70b Versatile
|
||||
|
||||
### Azure OpenAI
|
||||
|
||||
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
|
||||
- GPT-4.5 Preview
|
||||
- GPT-4o family (gpt-4o, gpt-4o-mini)
|
||||
- O1 family (o1, o1-mini)
|
||||
- O3 family (o3, o3-mini)
|
||||
- O4 Mini
|
||||
|
||||
### Google Cloud VertexAI
|
||||
|
||||
- Gemini 2.5
|
||||
- Gemini 2.5 Flash
|
||||
|
||||
## Using Bedrock Models
|
||||
|
||||
To use bedrock models with OpenCode you need three things.
|
||||
|
||||
1. Valid AWS credentials (the env vars: `AWS_SECRET_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION`)
|
||||
2. Access to the corresponding model in AWS Bedrock in your region.
|
||||
a. You can request access in the AWS console on the Bedrock -> "Model access" page.
|
||||
3. A correct configuration file. You don't need the `providers` key. Instead you have to prefix your models per agent with `bedrock.` and then a valid model. For now only Claude 3.7 is supported.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"primary": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 5000,
|
||||
"reasoningEffort": ""
|
||||
},
|
||||
"task": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 5000,
|
||||
"reasoningEffort": ""
|
||||
},
|
||||
"title": {
|
||||
"model": "bedrock.claude-3.7-sonnet",
|
||||
"maxTokens": 80,
|
||||
"reasoningEffort": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Interactive Mode Usage
|
||||
|
||||
```bash
|
||||
# Start OpenCode
|
||||
opencode
|
||||
|
||||
# Start with debug logging
|
||||
opencode -d
|
||||
|
||||
# Start with a specific working directory
|
||||
opencode -c /path/to/project
|
||||
```
|
||||
|
||||
## Non-interactive Prompt Mode
|
||||
|
||||
You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument or by piping text into the command. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
|
||||
|
||||
```bash
|
||||
# Run a single prompt and print the AI's response to the terminal
|
||||
opencode -p "Explain the use of context in Go"
|
||||
|
||||
# Pipe input to OpenCode (equivalent to using -p flag)
|
||||
echo "Explain the use of context in Go" | opencode
|
||||
|
||||
# Get response in JSON format
|
||||
opencode -p "Explain the use of context in Go" -f json
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode -f json
|
||||
|
||||
# Run without showing the spinner
|
||||
opencode -p "Explain the use of context in Go" -q
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode -q
|
||||
|
||||
# Enable verbose logging to stderr
|
||||
opencode -p "Explain the use of context in Go" --verbose
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode --verbose
|
||||
|
||||
# Restrict the agent to only use specific tools
|
||||
opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode --allowedTools=view,ls,glob
|
||||
|
||||
# Prevent the agent from using specific tools
|
||||
opencode -p "Explain the use of context in Go" --excludedTools=bash,edit
|
||||
# Or with piped input
|
||||
echo "Explain the use of context in Go" | opencode --excludedTools=bash,edit
|
||||
```
|
||||
|
||||
In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session.
|
||||
|
||||
### Tool Restrictions
|
||||
|
||||
You can control which tools the AI assistant has access to in non-interactive mode:
|
||||
|
||||
- `--allowedTools`: Comma-separated list of tools that the agent is allowed to use. Only these tools will be available.
|
||||
- `--excludedTools`: Comma-separated list of tools that the agent is not allowed to use. All other tools will be available.
|
||||
|
||||
These flags are mutually exclusive - you can use either `--allowedTools` or `--excludedTools`, but not both at the same time.
|
||||
|
||||
### Output Formats
|
||||
|
||||
OpenCode supports the following output formats in non-interactive mode:
|
||||
|
||||
| Format | Description |
|
||||
| ------ | ------------------------------- |
|
||||
| `text` | Plain text output (default) |
|
||||
| `json` | Output wrapped in a JSON object |
|
||||
|
||||
The output format is implemented as a strongly-typed `OutputFormat` in the codebase, ensuring type safety and validation when processing outputs.
|
||||
|
||||
## Command-line Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
| ----------------- | ----- | --------------------------------------------------- |
|
||||
| `--help` | `-h` | Display help information |
|
||||
| `--debug` | `-d` | Enable debug mode |
|
||||
| `--cwd` | `-c` | Set current working directory |
|
||||
| `--prompt` | `-p` | Run a single prompt in non-interactive mode |
|
||||
| `--output-format` | `-f` | Output format for non-interactive mode (text, json) |
|
||||
| `--quiet` | `-q` | Hide spinner in non-interactive mode |
|
||||
| `--verbose` | | Display logs to stderr in non-interactive mode |
|
||||
| `--allowedTools` | | Restrict the agent to only use specified tools |
|
||||
| `--excludedTools` | | Prevent the agent from using specified tools |
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Global Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | ------------------------------------------------------- |
|
||||
| `Ctrl+C` | Quit application |
|
||||
| `Ctrl+?` | Toggle help dialog |
|
||||
| `?` | Toggle help dialog (when not in editing mode) |
|
||||
| `Ctrl+L` | View logs |
|
||||
| `Ctrl+A` | Switch session |
|
||||
| `Ctrl+K` | Command dialog |
|
||||
| `Ctrl+O` | Toggle model selection dialog |
|
||||
| `Esc` | Close current overlay/dialog or return to previous mode |
|
||||
|
||||
### Chat Page Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | --------------------------------------- |
|
||||
| `Ctrl+N` | Create new session |
|
||||
| `Ctrl+X` | Cancel current operation/generation |
|
||||
| `i` | Focus editor (when not in writing mode) |
|
||||
| `Esc` | Exit writing mode and focus messages |
|
||||
|
||||
### Editor Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `Ctrl+S` | Send message (when editor is focused) |
|
||||
| `Enter` or `Ctrl+S` | Send message (when editor is not focused) |
|
||||
| `Ctrl+E` | Open external editor |
|
||||
| `Esc` | Blur editor and focus messages |
|
||||
|
||||
### Session Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ---------- | ---------------- |
|
||||
| `↑` or `k` | Previous session |
|
||||
| `↓` or `j` | Next session |
|
||||
| `Enter` | Select session |
|
||||
| `Esc` | Close dialog |
|
||||
|
||||
### Model Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ---------- | ----------------- |
|
||||
| `↑` or `k` | Move up |
|
||||
| `↓` or `j` | Move down |
|
||||
| `←` or `h` | Previous provider |
|
||||
| `→` or `l` | Next provider |
|
||||
| `Esc` | Close dialog |
|
||||
|
||||
### Permission Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ----------------------- | ---------------------------- |
|
||||
| `←` or `left` | Switch options left |
|
||||
| `→` or `right` or `tab` | Switch options right |
|
||||
| `Enter` or `space` | Confirm selection |
|
||||
| `a` | Allow permission |
|
||||
| `A` | Allow permission for session |
|
||||
| `d` | Deny permission |
|
||||
|
||||
### Logs Page Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------ | ------------------- |
|
||||
| `Backspace` or `q` | Return to chat page |
|
||||
|
||||
## AI Assistant Tools
|
||||
|
||||
OpenCode's AI assistant has access to various tools to help with coding tasks:
|
||||
|
||||
### File and Code Tools
|
||||
|
||||
| Tool | Description | Parameters |
|
||||
| ------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `glob` | Find files by pattern | `pattern` (required), `path` (optional) |
|
||||
| `grep` | Search file contents | `pattern` (required), `path` (optional), `include` (optional), `literal_text` (optional) |
|
||||
| `ls` | List directory contents | `path` (optional), `ignore` (optional array of patterns) |
|
||||
| `view` | View file contents | `file_path` (required), `offset` (optional), `limit` (optional) |
|
||||
| `write` | Write to files | `file_path` (required), `content` (required) |
|
||||
| `edit` | Edit files | Various parameters for file editing |
|
||||
| `patch` | Apply patches to files | `file_path` (required), `diff` (required) |
|
||||
| `diagnostics` | Get diagnostics information | `file_path` (optional) |
|
||||
|
||||
### Other Tools
|
||||
|
||||
| Tool | Description | Parameters |
|
||||
| ------- | ------------------------------- | ----------------------------------------------------------- |
|
||||
| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
|
||||
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
|
||||
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
|
||||
|
||||
### Shell Configuration
|
||||
|
||||
OpenCode allows you to configure the shell used by the `bash` tool. By default, it uses:
|
||||
|
||||
1. The shell specified in the config file (if provided)
|
||||
2. The shell from the `$SHELL` environment variable (if available)
|
||||
3. Falls back to `/bin/bash` if neither of the above is available
|
||||
|
||||
To configure a custom shell, add a `shell` section to your `.opencode.json` configuration file:
|
||||
|
||||
```json
|
||||
{
|
||||
"shell": {
|
||||
"path": "/bin/zsh",
|
||||
"args": ["-l"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can specify any shell executable and custom arguments:
|
||||
|
||||
```json
|
||||
{
|
||||
"shell": {
|
||||
"path": "/usr/bin/fish",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
OpenCode is built with a modular architecture:
|
||||
|
||||
- **cmd**: Command-line interface using Cobra
|
||||
- **internal/app**: Core application services
|
||||
- **internal/config**: Configuration management
|
||||
- **internal/db**: Database operations and migrations
|
||||
- **internal/llm**: LLM providers and tools integration
|
||||
- **internal/tui**: Terminal UI components and layouts
|
||||
- **internal/logging**: Logging infrastructure
|
||||
- **internal/message**: Message handling
|
||||
- **internal/session**: Session management
|
||||
- **internal/lsp**: Language Server Protocol integration
|
||||
|
||||
## Custom Commands
|
||||
|
||||
OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
|
||||
|
||||
### Creating Custom Commands
|
||||
|
||||
Custom commands are predefined prompts stored as Markdown files in one of three locations:
|
||||
|
||||
1. **User Commands** (prefixed with `user:`):
|
||||
|
||||
```
|
||||
$XDG_CONFIG_HOME/opencode/commands/
|
||||
```
|
||||
|
||||
(typically `~/.config/opencode/commands/` on Linux/macOS)
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
$HOME/.opencode/commands/
|
||||
```
|
||||
|
||||
2. **Project Commands** (prefixed with `project:`):
|
||||
```
|
||||
<PROJECT DIR>/.opencode/commands/
|
||||
```
|
||||
|
||||
Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
|
||||
|
||||
For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content:
|
||||
|
||||
```markdown
|
||||
RUN git ls-files
|
||||
READ README.md
|
||||
```
|
||||
|
||||
This creates a command called `user:prime-context`.
|
||||
|
||||
### Command Arguments
|
||||
|
||||
OpenCode supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter).
|
||||
|
||||
For example:
|
||||
|
||||
```markdown
|
||||
# Fetch Context for Issue $ISSUE_NUMBER
|
||||
|
||||
RUN gh issue view $ISSUE_NUMBER --json title,body,comments
|
||||
RUN git grep --author="$AUTHOR_NAME" -n .
|
||||
RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
|
||||
```
|
||||
|
||||
When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
|
||||
|
||||
- Clear identification of what each argument represents
|
||||
- Ability to use the same argument multiple times
|
||||
- Better organization for commands with multiple inputs
|
||||
|
||||
### Organizing Commands
|
||||
|
||||
You can organize commands in subdirectories:
|
||||
|
||||
```
|
||||
~/.config/opencode/commands/git/commit.md
|
||||
```
|
||||
|
||||
This creates a command with ID `user:git:commit`.
|
||||
|
||||
### Using Custom Commands
|
||||
|
||||
1. Press `Ctrl+K` to open the command dialog
|
||||
2. Select your custom command (prefixed with either `user:` or `project:`)
|
||||
3. Press Enter to execute the command
|
||||
|
||||
The content of the command file will be sent as a message to the AI assistant.
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.
|
||||
|
||||
### MCP Features
|
||||
|
||||
- **External Tool Integration**: Connect to external tools and services via a standardized protocol
|
||||
- **Tool Discovery**: Automatically discover available tools from MCP servers
|
||||
- **Multiple Connection Types**:
|
||||
- **Stdio**: Communicate with tools via standard input/output
|
||||
- **SSE**: Communicate with tools via Server-Sent Events
|
||||
- **Security**: Permission system for controlling access to MCP tools
|
||||
|
||||
### Configuring MCP Servers
|
||||
|
||||
MCP servers are defined in the configuration file under the `mcpServers` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"type": "stdio",
|
||||
"command": "path/to/mcp-server",
|
||||
"env": [],
|
||||
"args": []
|
||||
},
|
||||
"web-example": {
|
||||
"type": "sse",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Usage
|
||||
|
||||
Once configured, MCP tools are automatically available to the AI assistant alongside built-in tools. They follow the same permission model as other tools, requiring user approval before execution.
|
||||
|
||||
## LSP (Language Server Protocol)
|
||||
|
||||
OpenCode integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
|
||||
|
||||
### LSP Features
|
||||
|
||||
- **Multi-language Support**: Connect to language servers for different programming languages
|
||||
- **Diagnostics**: Receive error checking and linting information
|
||||
- **File Watching**: Automatically notify language servers of file changes
|
||||
|
||||
### Configuring LSP
|
||||
|
||||
Language servers are configured in the configuration file under the `lsp` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"go": {
|
||||
"disabled": false,
|
||||
"command": "gopls"
|
||||
},
|
||||
"typescript": {
|
||||
"disabled": false,
|
||||
"command": "typescript-language-server",
|
||||
"args": ["--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### LSP Integration with AI
|
||||
|
||||
The AI assistant can access LSP features through the `diagnostics` tool, allowing it to:
|
||||
|
||||
- Check for errors in your code
|
||||
- Suggest fixes based on diagnostics
|
||||
|
||||
While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.24.0 or higher
|
||||
|
||||
### Building from Source
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/sst/opencode.git
|
||||
cd opencode
|
||||
|
||||
# Build
|
||||
go build -o opencode
|
||||
|
||||
# Run
|
||||
./opencode
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
OpenCode gratefully acknowledges the contributions and support from these key individuals:
|
||||
|
||||
- [@isaacphi](https://github.com/isaacphi) - For the [mcp-language-server](https://github.com/isaacphi/mcp-language-server) project which provided the foundation for our LSP client implementation
|
||||
- [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture
|
||||
|
||||
Special thanks to the broader open source community whose tools and libraries have made this project possible.
|
||||
|
||||
## License
|
||||
|
||||
OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Here's how you can contribute:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
Please make sure to update tests as appropriate and follow the existing code style.
|
||||
**Join our community** [Discord](https://discord.gg/opencode) | [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
|
||||
|
||||
28
STATS.md
Normal file
28
STATS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Download Stats
|
||||
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | ---------------- | ----------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-10 | 43,796 (+5,744) | 71,402 (+6,934) | 115,198 (+12,678) |
|
||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
||||
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
||||
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
||||
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
|
||||
@@ -4,37 +4,43 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/alecthomas/chroma/v2 v2.15.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1
|
||||
github.com/catppuccin/go v0.3.0
|
||||
github.com/alecthomas/chroma/v2 v2.18.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.4
|
||||
github.com/charmbracelet/glamour v0.9.1
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/x/ansi v0.8.0
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/x/ansi v0.9.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/oapi-codegen/runtime v1.1.1
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
|
||||
golang.org/x/image v0.28.0
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
replace (
|
||||
github.com/charmbracelet/x/input => ./packages/tui/input
|
||||
)
|
||||
|
||||
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/input v0.3.7 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.1 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/getkin/kin-openapi v0.127.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/goccy/go-yaml v1.17.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/invopop/yaml v0.3.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
@@ -47,43 +53,43 @@ require (
|
||||
github.com/sosodev/duration v1.3.1 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
|
||||
github.com/spf13/cobra v1.9.1 // indirect
|
||||
github.com/tidwall/gjson v1.14.4 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/image v0.26.0
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/term v0.31.0 // indirect
|
||||
golang.org/x/text v0.24.0
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
golang.org/x/text v0.26.0
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -4,15 +4,12 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
|
||||
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
|
||||
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
|
||||
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
@@ -23,29 +20,32 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
|
||||
github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA=
|
||||
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
||||
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
|
||||
github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
@@ -54,15 +54,11 @@ github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
@@ -108,7 +104,6 @@ github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
|
||||
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -118,16 +113,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
@@ -150,8 +141,6 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
|
||||
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
|
||||
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
@@ -192,14 +181,22 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
@@ -219,14 +216,13 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -237,15 +233,15 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -259,34 +255,33 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
25
infra/app.ts
25
infra/app.ts
@@ -4,13 +4,18 @@ export const domain = (() => {
|
||||
return `${$app.stage}.dev.opencode.ai`
|
||||
})()
|
||||
|
||||
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
|
||||
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
|
||||
const bucket = new sst.cloudflare.Bucket("Bucket")
|
||||
|
||||
export const api = new sst.cloudflare.Worker("Api", {
|
||||
domain: `api.${domain}`,
|
||||
handler: "packages/function/src/api.ts",
|
||||
environment: {
|
||||
WEB_DOMAIN: domain,
|
||||
},
|
||||
url: true,
|
||||
link: [bucket],
|
||||
link: [bucket, GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY],
|
||||
transform: {
|
||||
worker: (args) => {
|
||||
args.logpush = true
|
||||
@@ -23,29 +28,21 @@ export const api = new sst.cloudflare.Worker("Api", {
|
||||
},
|
||||
])
|
||||
args.migrations = {
|
||||
oldTag: "v1",
|
||||
newTag: "v1",
|
||||
// Note: when releasing the next tag, make sure all stages use tag v2
|
||||
oldTag: $app.stage === "production" ? "" : "v1",
|
||||
newTag: $app.stage === "production" ? "" : "v1",
|
||||
//newSqliteClasses: ["SyncServer"],
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// new sst.cloudflare.StaticSite("Web", {
|
||||
// path: "packages/web",
|
||||
// domain,
|
||||
// environment: {
|
||||
// VITE_API_URL: api.url,
|
||||
// },
|
||||
// build: {
|
||||
// command: "bun run build",
|
||||
// output: "dist",
|
||||
// },
|
||||
// })
|
||||
new sst.cloudflare.x.Astro("Web", {
|
||||
domain,
|
||||
path: "packages/web",
|
||||
environment: {
|
||||
// For astro config
|
||||
SST_STAGE: $app.stage,
|
||||
VITE_API_URL: api.url,
|
||||
},
|
||||
})
|
||||
|
||||
28
install
28
install
@@ -12,23 +12,28 @@ requested_version=${VERSION:-}
|
||||
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$os" == "darwin" ]]; then
|
||||
os="mac"
|
||||
os="darwin"
|
||||
fi
|
||||
arch=$(uname -m)
|
||||
|
||||
if [[ "$arch" == "aarch64" ]]; then
|
||||
arch="arm64"
|
||||
elif [[ "$arch" == "x86_64" ]]; then
|
||||
arch="x64"
|
||||
fi
|
||||
|
||||
filename="$APP-$os-$arch.tar.gz"
|
||||
filename="$APP-$os-$arch.zip"
|
||||
|
||||
|
||||
case "$filename" in
|
||||
*"-linux-"*)
|
||||
[[ "$arch" == "x86_64" || "$arch" == "arm64" || "$arch" == "i386" ]] || exit 1
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
|
||||
;;
|
||||
*"-mac-"*)
|
||||
[[ "$arch" == "x86_64" || "$arch" == "arm64" ]] || exit 1
|
||||
*"-darwin-"*)
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
|
||||
;;
|
||||
*"-windows-"*)
|
||||
[[ "$arch" == "x64" ]] || exit 1
|
||||
;;
|
||||
*)
|
||||
echo "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
@@ -43,7 +48,7 @@ if [ -z "$requested_version" ]; then
|
||||
url="https://github.com/sst/opencode/releases/latest/download/$filename"
|
||||
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
||||
echo "${RED}Failed to fetch version information${NC}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -88,8 +93,9 @@ check_version() {
|
||||
download_and_install() {
|
||||
print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
|
||||
mkdir -p opencodetmp && cd opencodetmp
|
||||
curl -# -L $url | tar xz
|
||||
mv opencode $INSTALL_DIR
|
||||
curl -# -L -o "$filename" "$url"
|
||||
unzip -q "$filename"
|
||||
mv opencode "$INSTALL_DIR"
|
||||
cd .. && rm -rf opencodetmp
|
||||
}
|
||||
|
||||
@@ -101,7 +107,9 @@ add_to_path() {
|
||||
local config_file=$1
|
||||
local command=$2
|
||||
|
||||
if [[ -w $config_file ]]; then
|
||||
if grep -Fxq "$command" "$config_file"; then
|
||||
print_message info "Command already exists in $config_file, skipping write."
|
||||
elif [[ -w $config_file ]]; then
|
||||
echo -e "\n# opencode" >> "$config_file"
|
||||
echo "$command" >> "$config_file"
|
||||
print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
|
||||
@@ -167,6 +175,7 @@ if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
|
||||
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"
|
||||
;;
|
||||
@@ -177,4 +186,3 @@ if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
||||
echo "$INSTALL_DIR" >> $GITHUB_PATH
|
||||
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
|
||||
fi
|
||||
|
||||
|
||||
28
opencode.json
Normal file
28
opencode.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"openrouter": {
|
||||
"models": {
|
||||
"moonshotai/kimi-k2": {
|
||||
"options": {
|
||||
"provider": {
|
||||
"order": ["baseten"],
|
||||
"allow_fallbacks": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"huggingface": {
|
||||
"models": {
|
||||
"Qwen/Qwen3-235B-A22B-Instruct-2507:fireworks-ai": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"weather": {
|
||||
"type": "local",
|
||||
"command": ["opencode", "x", "@h1deya/mcp-server-weather"]
|
||||
}
|
||||
}
|
||||
}
|
||||
21
package.json
21
package.json
@@ -3,10 +3,12 @@
|
||||
"name": "opencode",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.2.14",
|
||||
"packageManager": "bun@1.2.19",
|
||||
"scripts": {
|
||||
"dev": "bun run packages/opencode/src/index.ts",
|
||||
"typecheck": "bun run --filter='*' typecheck",
|
||||
"dev": "sst dev"
|
||||
"stainless": "./scripts/stainless",
|
||||
"postinstall": "./scripts/hooks"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
@@ -15,13 +17,13 @@
|
||||
"catalog": {
|
||||
"typescript": "5.8.2",
|
||||
"@types/node": "22.13.9",
|
||||
"zod": "3.24.2",
|
||||
"ai": "5.0.0-alpha.7"
|
||||
"zod": "3.25.49",
|
||||
"ai": "5.0.0-beta.21"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "3.5.3",
|
||||
"sst": "3.17.4"
|
||||
"sst": "3.17.8"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -29,14 +31,13 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"prettier": {
|
||||
"semi": false
|
||||
},
|
||||
"overrides": {
|
||||
"zod": "3.24.2"
|
||||
"semi": false,
|
||||
"printWidth": 120
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
]
|
||||
],
|
||||
"patchedDependencies": {}
|
||||
}
|
||||
|
||||
145
packages/client/tui/cmd/tui/main.go
Normal file
145
packages/client/tui/cmd/tui/main.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
flag "github.com/spf13/pflag"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode-sdk-go/option"
|
||||
"github.com/sst/opencode/internal/api"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/tui"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
version := Version
|
||||
if version != "dev" && !strings.HasPrefix(Version, "v") {
|
||||
version = "v" + Version
|
||||
}
|
||||
|
||||
var model *string = flag.String("model", "", "model to begin with")
|
||||
var prompt *string = flag.String("prompt", "", "prompt to begin with")
|
||||
var mode *string = flag.String("mode", "", "mode to begin with")
|
||||
flag.Parse()
|
||||
|
||||
url := os.Getenv("OPENCODE_SERVER")
|
||||
|
||||
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
|
||||
var appInfo opencode.App
|
||||
err := json.Unmarshal([]byte(appInfoStr), &appInfo)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal app info", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
modesStr := os.Getenv("OPENCODE_MODES")
|
||||
var modes []opencode.Mode
|
||||
err = json.Unmarshal([]byte(modesStr), &modes)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal modes", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
slog.Error("Failed to stat stdin", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check if there's data piped to stdin
|
||||
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
||||
stdin, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read stdin", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
stdinContent := strings.TrimSpace(string(stdin))
|
||||
if stdinContent != "" {
|
||||
if prompt == nil || *prompt == "" {
|
||||
prompt = &stdinContent
|
||||
} else {
|
||||
combined := *prompt + "\n" + stdinContent
|
||||
prompt = &combined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
httpClient := opencode.NewClient(
|
||||
option.WithBaseURL(url),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
apiHandler := util.NewAPILogHandler(ctx, httpClient, "tui", slog.LevelDebug)
|
||||
logger := slog.New(apiHandler)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Debug("TUI launched", "app", appInfoStr, "modes", modesStr)
|
||||
|
||||
go func() {
|
||||
err = clipboard.Init()
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize clipboard", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create main context for the application
|
||||
app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
program := tea.NewProgram(
|
||||
tui.NewModel(app_),
|
||||
tea.WithAltScreen(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
// Set up signal handling for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
||||
|
||||
go func() {
|
||||
stream := httpClient.Event.ListStreaming(ctx)
|
||||
for stream.Next() {
|
||||
evt := stream.Current().AsUnion()
|
||||
if _, ok := evt.(opencode.EventListResponseEventStorageWrite); ok {
|
||||
continue
|
||||
}
|
||||
program.Send(evt)
|
||||
}
|
||||
if err := stream.Err(); err != nil {
|
||||
slog.Error("Error streaming events", "error", err)
|
||||
program.Send(err)
|
||||
}
|
||||
}()
|
||||
|
||||
go api.Start(ctx, program, httpClient)
|
||||
|
||||
// Handle signals in a separate goroutine
|
||||
go func() {
|
||||
sig := <-sigChan
|
||||
slog.Info("Received signal, shutting down gracefully", "signal", sig)
|
||||
program.Quit()
|
||||
}()
|
||||
|
||||
// Run the TUI
|
||||
result, err := program.Run()
|
||||
if err != nil {
|
||||
slog.Error("TUI error", "error", err)
|
||||
}
|
||||
|
||||
slog.Info("TUI exited", "result", result)
|
||||
}
|
||||
99
packages/client/tui/go.mod
Normal file
99
packages/client/tui/go.mod
Normal file
@@ -0,0 +1,99 @@
|
||||
module github.com/sst/opencode
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/alecthomas/chroma/v2 v2.18.0
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/x/ansi v0.9.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
|
||||
golang.org/x/image v0.28.0
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
replace (
|
||||
github.com/charmbracelet/x/input => ./input
|
||||
github.com/sst/opencode/packages/sdk/go => ../sdk/go
|
||||
)
|
||||
|
||||
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/input v0.3.7 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.1 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/getkin/kin-openapi v0.127.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/goccy/go-yaml v1.17.1 // indirect
|
||||
github.com/invopop/yaml v0.3.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sanity-io/litter v1.5.8 // indirect
|
||||
github.com/sosodev/duration v1.3.1 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
|
||||
github.com/spf13/cobra v1.9.1 // indirect
|
||||
github.com/tidwall/gjson v1.14.4 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
golang.org/x/text v0.26.0
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/atombender/go-jsonschema
|
||||
github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||
)
|
||||
315
packages/client/tui/go.sum
Normal file
315
packages/client/tui/go.sum
Normal file
@@ -0,0 +1,315 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
|
||||
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
|
||||
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA=
|
||||
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
||||
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
|
||||
github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY=
|
||||
github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
|
||||
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
|
||||
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
|
||||
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
|
||||
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw=
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
14
packages/client/tui/input/cancelreader_other.go
Normal file
14
packages/client/tui/input/cancelreader_other.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/muesli/cancelreader"
|
||||
)
|
||||
|
||||
func newCancelreader(r io.Reader, _ int) (cancelreader.CancelReader, error) {
|
||||
return cancelreader.NewReader(r) //nolint:wrapcheck
|
||||
}
|
||||
143
packages/client/tui/input/cancelreader_windows.go
Normal file
143
packages/client/tui/input/cancelreader_windows.go
Normal file
@@ -0,0 +1,143 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
xwindows "github.com/charmbracelet/x/windows"
|
||||
"github.com/muesli/cancelreader"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type conInputReader struct {
|
||||
cancelMixin
|
||||
conin windows.Handle
|
||||
originalMode uint32
|
||||
}
|
||||
|
||||
var _ cancelreader.CancelReader = &conInputReader{}
|
||||
|
||||
func newCancelreader(r io.Reader, flags int) (cancelreader.CancelReader, error) {
|
||||
fallback := func(io.Reader) (cancelreader.CancelReader, error) {
|
||||
return cancelreader.NewReader(r)
|
||||
}
|
||||
|
||||
var dummy uint32
|
||||
if f, ok := r.(cancelreader.File); !ok || f.Fd() != os.Stdin.Fd() ||
|
||||
// If data was piped to the standard input, it does not emit events
|
||||
// anymore. We can detect this if the console mode cannot be set anymore,
|
||||
// in this case, we fallback to the default cancelreader implementation.
|
||||
windows.GetConsoleMode(windows.Handle(f.Fd()), &dummy) != nil {
|
||||
return fallback(r)
|
||||
}
|
||||
|
||||
conin, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
|
||||
if err != nil {
|
||||
return fallback(r)
|
||||
}
|
||||
|
||||
// Discard any pending input events.
|
||||
if err := xwindows.FlushConsoleInputBuffer(conin); err != nil {
|
||||
return fallback(r)
|
||||
}
|
||||
|
||||
modes := []uint32{
|
||||
windows.ENABLE_WINDOW_INPUT,
|
||||
windows.ENABLE_EXTENDED_FLAGS,
|
||||
}
|
||||
|
||||
// Enabling mouse mode implicitly blocks console text selection. Thus, we
|
||||
// need to enable it only if the mouse mode is requested.
|
||||
// In order to toggle mouse mode, the caller must recreate the reader with
|
||||
// the appropriate flag toggled.
|
||||
if flags&FlagMouseMode != 0 {
|
||||
modes = append(modes, windows.ENABLE_MOUSE_INPUT)
|
||||
}
|
||||
|
||||
originalMode, err := prepareConsole(conin, modes...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare console input: %w", err)
|
||||
}
|
||||
|
||||
return &conInputReader{
|
||||
conin: conin,
|
||||
originalMode: originalMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Cancel implements cancelreader.CancelReader.
|
||||
func (r *conInputReader) Cancel() bool {
|
||||
r.setCanceled()
|
||||
|
||||
return windows.CancelIoEx(r.conin, nil) == nil || windows.CancelIo(r.conin) == nil
|
||||
}
|
||||
|
||||
// Close implements cancelreader.CancelReader.
|
||||
func (r *conInputReader) Close() error {
|
||||
if r.originalMode != 0 {
|
||||
err := windows.SetConsoleMode(r.conin, r.originalMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reset console mode: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read implements cancelreader.CancelReader.
|
||||
func (r *conInputReader) Read(data []byte) (int, error) {
|
||||
if r.isCanceled() {
|
||||
return 0, cancelreader.ErrCanceled
|
||||
}
|
||||
|
||||
var n uint32
|
||||
if err := windows.ReadFile(r.conin, data, &n, nil); err != nil {
|
||||
return int(n), fmt.Errorf("read console input: %w", err)
|
||||
}
|
||||
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) {
|
||||
err = windows.GetConsoleMode(input, &originalMode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get console mode: %w", err)
|
||||
}
|
||||
|
||||
var newMode uint32
|
||||
for _, mode := range modes {
|
||||
newMode |= mode
|
||||
}
|
||||
|
||||
err = windows.SetConsoleMode(input, newMode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("set console mode: %w", err)
|
||||
}
|
||||
|
||||
return originalMode, nil
|
||||
}
|
||||
|
||||
// cancelMixin represents a goroutine-safe cancelation status.
|
||||
type cancelMixin struct {
|
||||
unsafeCanceled bool
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func (c *cancelMixin) setCanceled() {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
c.unsafeCanceled = true
|
||||
}
|
||||
|
||||
func (c *cancelMixin) isCanceled() bool {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
return c.unsafeCanceled
|
||||
}
|
||||
25
packages/client/tui/input/clipboard.go
Normal file
25
packages/client/tui/input/clipboard.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package input
|
||||
|
||||
import "github.com/charmbracelet/x/ansi"
|
||||
|
||||
// ClipboardSelection represents a clipboard selection. The most common
|
||||
// clipboard selections are "system" and "primary" and selections.
|
||||
type ClipboardSelection = byte
|
||||
|
||||
// Clipboard selections.
|
||||
const (
|
||||
SystemClipboard ClipboardSelection = ansi.SystemClipboard
|
||||
PrimaryClipboard ClipboardSelection = ansi.PrimaryClipboard
|
||||
)
|
||||
|
||||
// ClipboardEvent is a clipboard read message event. This message is emitted when
|
||||
// a terminal receives an OSC52 clipboard read message event.
|
||||
type ClipboardEvent struct {
|
||||
Content string
|
||||
Selection ClipboardSelection
|
||||
}
|
||||
|
||||
// String returns the string representation of the clipboard message.
|
||||
func (e ClipboardEvent) String() string {
|
||||
return e.Content
|
||||
}
|
||||
136
packages/client/tui/input/color.go
Normal file
136
packages/client/tui/input/color.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ForegroundColorEvent represents a foreground color event. This event is
|
||||
// emitted when the terminal requests the terminal foreground color using
|
||||
// [ansi.RequestForegroundColor].
|
||||
type ForegroundColorEvent struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e ForegroundColorEvent) String() string {
|
||||
return colorToHex(e.Color)
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e ForegroundColorEvent) IsDark() bool {
|
||||
return isDarkColor(e.Color)
|
||||
}
|
||||
|
||||
// BackgroundColorEvent represents a background color event. This event is
|
||||
// emitted when the terminal requests the terminal background color using
|
||||
// [ansi.RequestBackgroundColor].
|
||||
type BackgroundColorEvent struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e BackgroundColorEvent) String() string {
|
||||
return colorToHex(e)
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e BackgroundColorEvent) IsDark() bool {
|
||||
return isDarkColor(e.Color)
|
||||
}
|
||||
|
||||
// CursorColorEvent represents a cursor color change event. This event is
|
||||
// emitted when the program requests the terminal cursor color using
|
||||
// [ansi.RequestCursorColor].
|
||||
type CursorColorEvent struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e CursorColorEvent) String() string {
|
||||
return colorToHex(e)
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e CursorColorEvent) IsDark() bool {
|
||||
return isDarkColor(e)
|
||||
}
|
||||
|
||||
type shiftable interface {
|
||||
~uint | ~uint16 | ~uint32 | ~uint64
|
||||
}
|
||||
|
||||
func shift[T shiftable](x T) T {
|
||||
if x > 0xff {
|
||||
x >>= 8
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func colorToHex(c color.Color) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
r, g, b, _ := c.RGBA()
|
||||
return fmt.Sprintf("#%02x%02x%02x", shift(r), shift(g), shift(b))
|
||||
}
|
||||
|
||||
func getMaxMin(a, b, c float64) (ma, mi float64) {
|
||||
if a > b {
|
||||
ma = a
|
||||
mi = b
|
||||
} else {
|
||||
ma = b
|
||||
mi = a
|
||||
}
|
||||
if c > ma {
|
||||
ma = c
|
||||
} else if c < mi {
|
||||
mi = c
|
||||
}
|
||||
return ma, mi
|
||||
}
|
||||
|
||||
func round(x float64) float64 {
|
||||
return math.Round(x*1000) / 1000
|
||||
}
|
||||
|
||||
// rgbToHSL converts an RGB triple to an HSL triple.
|
||||
func rgbToHSL(r, g, b uint8) (h, s, l float64) {
|
||||
// convert uint32 pre-multiplied value to uint8
|
||||
// The r,g,b values are divided by 255 to change the range from 0..255 to 0..1:
|
||||
Rnot := float64(r) / 255
|
||||
Gnot := float64(g) / 255
|
||||
Bnot := float64(b) / 255
|
||||
Cmax, Cmin := getMaxMin(Rnot, Gnot, Bnot)
|
||||
Δ := Cmax - Cmin
|
||||
// Lightness calculation:
|
||||
l = (Cmax + Cmin) / 2
|
||||
// Hue and Saturation Calculation:
|
||||
if Δ == 0 {
|
||||
h = 0
|
||||
s = 0
|
||||
} else {
|
||||
switch Cmax {
|
||||
case Rnot:
|
||||
h = 60 * (math.Mod((Gnot-Bnot)/Δ, 6))
|
||||
case Gnot:
|
||||
h = 60 * (((Bnot - Rnot) / Δ) + 2)
|
||||
case Bnot:
|
||||
h = 60 * (((Rnot - Gnot) / Δ) + 4)
|
||||
}
|
||||
if h < 0 {
|
||||
h += 360
|
||||
}
|
||||
|
||||
s = Δ / (1 - math.Abs((2*l)-1))
|
||||
}
|
||||
|
||||
return h, round(s), round(l)
|
||||
}
|
||||
|
||||
// isDarkColor returns whether the given color is dark.
|
||||
func isDarkColor(c color.Color) bool {
|
||||
if c == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
r, g, b, _ := c.RGBA()
|
||||
_, _, l := rgbToHSL(uint8(r>>8), uint8(g>>8), uint8(b>>8)) //nolint:gosec
|
||||
return l < 0.5
|
||||
}
|
||||
7
packages/client/tui/input/cursor.go
Normal file
7
packages/client/tui/input/cursor.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package input
|
||||
|
||||
import "image"
|
||||
|
||||
// CursorPositionEvent represents a cursor position event. Where X is the
|
||||
// zero-based column and Y is the zero-based row.
|
||||
type CursorPositionEvent image.Point
|
||||
18
packages/client/tui/input/da1.go
Normal file
18
packages/client/tui/input/da1.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package input
|
||||
|
||||
import "github.com/charmbracelet/x/ansi"
|
||||
|
||||
// PrimaryDeviceAttributesEvent is an event that represents the terminal
|
||||
// primary device attributes.
|
||||
type PrimaryDeviceAttributesEvent []int
|
||||
|
||||
func parsePrimaryDevAttrs(params ansi.Params) Event {
|
||||
// Primary Device Attributes
|
||||
da1 := make(PrimaryDeviceAttributesEvent, len(params))
|
||||
for i, p := range params {
|
||||
if !p.HasMore() {
|
||||
da1[i] = p.Param(0)
|
||||
}
|
||||
}
|
||||
return da1
|
||||
}
|
||||
6
packages/client/tui/input/doc.go
Normal file
6
packages/client/tui/input/doc.go
Normal file
@@ -0,0 +1,6 @@
|
||||
// Package input provides a set of utilities for handling input events in a
|
||||
// terminal environment. It includes support for reading input events, parsing
|
||||
// escape sequences, and handling clipboard events.
|
||||
// The package is designed to work with various terminal types and supports
|
||||
// customization through flags and options.
|
||||
package input
|
||||
192
packages/client/tui/input/driver.go
Normal file
192
packages/client/tui/input/driver.go
Normal file
@@ -0,0 +1,192 @@
|
||||
//nolint:unused,revive,nolintlint
|
||||
package input
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/muesli/cancelreader"
|
||||
)
|
||||
|
||||
// Logger is a simple logger interface.
|
||||
type Logger interface {
|
||||
Printf(format string, v ...any)
|
||||
}
|
||||
|
||||
// win32InputState is a state machine for parsing key events from the Windows
|
||||
// Console API into escape sequences and utf8 runes, and keeps track of the last
|
||||
// control key state to determine modifier key changes. It also keeps track of
|
||||
// the last mouse button state and window size changes to determine which mouse
|
||||
// buttons were released and to prevent multiple size events from firing.
|
||||
type win32InputState struct {
|
||||
ansiBuf [256]byte
|
||||
ansiIdx int
|
||||
utf16Buf [2]rune
|
||||
utf16Half bool
|
||||
lastCks uint32 // the last control key state for the previous event
|
||||
lastMouseBtns uint32 // the last mouse button state for the previous event
|
||||
lastWinsizeX, lastWinsizeY int16 // the last window size for the previous event to prevent multiple size events from firing
|
||||
}
|
||||
|
||||
// Reader represents an input event reader. It reads input events and parses
|
||||
// escape sequences from the terminal input buffer and translates them into
|
||||
// human‑readable events.
|
||||
type Reader struct {
|
||||
rd cancelreader.CancelReader
|
||||
table map[string]Key // table is a lookup table for key sequences.
|
||||
term string // $TERM
|
||||
paste []byte // bracketed paste buffer; nil when disabled
|
||||
buf [256]byte // read buffer
|
||||
partialSeq []byte // holds incomplete escape sequences
|
||||
keyState win32InputState
|
||||
parser Parser
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewReader returns a new input event reader.
|
||||
func NewReader(r io.Reader, termType string, flags int) (*Reader, error) {
|
||||
d := new(Reader)
|
||||
cr, err := newCancelreader(r, flags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.rd = cr
|
||||
d.table = buildKeysTable(flags, termType)
|
||||
d.term = termType
|
||||
d.parser.flags = flags
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SetLogger sets a logger for the reader.
|
||||
func (d *Reader) SetLogger(l Logger) { d.logger = l }
|
||||
|
||||
// Read implements io.Reader.
|
||||
func (d *Reader) Read(p []byte) (int, error) { return d.rd.Read(p) }
|
||||
|
||||
// Cancel cancels the underlying reader.
|
||||
func (d *Reader) Cancel() bool { return d.rd.Cancel() }
|
||||
|
||||
// Close closes the underlying reader.
|
||||
func (d *Reader) Close() error { return d.rd.Close() }
|
||||
|
||||
func (d *Reader) readEvents() ([]Event, error) {
|
||||
nb, err := d.rd.Read(d.buf[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var events []Event
|
||||
|
||||
// Combine any partial sequence from previous read with new data.
|
||||
var buf []byte
|
||||
if len(d.partialSeq) > 0 {
|
||||
buf = make([]byte, len(d.partialSeq)+nb)
|
||||
copy(buf, d.partialSeq)
|
||||
copy(buf[len(d.partialSeq):], d.buf[:nb])
|
||||
d.partialSeq = nil
|
||||
} else {
|
||||
buf = d.buf[:nb]
|
||||
}
|
||||
|
||||
// Fast path: direct lookup for simple escape sequences.
|
||||
if bytes.HasPrefix(buf, []byte{0x1b}) {
|
||||
if k, ok := d.table[string(buf)]; ok {
|
||||
if d.logger != nil {
|
||||
d.logger.Printf("input: %q", buf)
|
||||
}
|
||||
events = append(events, KeyPressEvent(k))
|
||||
return events, nil
|
||||
}
|
||||
}
|
||||
|
||||
var i int
|
||||
for i < len(buf) {
|
||||
consumed, ev := d.parser.parseSequence(buf[i:])
|
||||
if d.logger != nil && consumed > 0 {
|
||||
d.logger.Printf("input: %q", buf[i:i+consumed])
|
||||
}
|
||||
|
||||
// Incomplete sequence – store remainder and exit.
|
||||
if consumed == 0 && ev == nil {
|
||||
rem := len(buf) - i
|
||||
if rem > 0 {
|
||||
d.partialSeq = make([]byte, rem)
|
||||
copy(d.partialSeq, buf[i:])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Handle bracketed paste specially so we don’t emit a paste event for
|
||||
// every byte.
|
||||
if d.paste != nil {
|
||||
if _, ok := ev.(PasteEndEvent); !ok {
|
||||
d.paste = append(d.paste, buf[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
switch ev.(type) {
|
||||
case PasteStartEvent:
|
||||
d.paste = []byte{}
|
||||
case PasteEndEvent:
|
||||
var paste []rune
|
||||
for len(d.paste) > 0 {
|
||||
r, w := utf8.DecodeRune(d.paste)
|
||||
if r != utf8.RuneError {
|
||||
paste = append(paste, r)
|
||||
}
|
||||
d.paste = d.paste[w:]
|
||||
}
|
||||
d.paste = nil
|
||||
events = append(events, PasteEvent(paste))
|
||||
case nil:
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if mevs, ok := ev.(MultiEvent); ok {
|
||||
events = append(events, []Event(mevs)...)
|
||||
} else {
|
||||
events = append(events, ev)
|
||||
}
|
||||
i += consumed
|
||||
}
|
||||
|
||||
// Collapse bursts of wheel/motion events into a single event each.
|
||||
events = coalesceMouseEvents(events)
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// coalesceMouseEvents reduces the volume of MouseWheelEvent and MouseMotionEvent
|
||||
// objects that arrive in rapid succession by keeping only the most recent
|
||||
// event in each contiguous run.
|
||||
func coalesceMouseEvents(in []Event) []Event {
|
||||
if len(in) < 2 {
|
||||
return in
|
||||
}
|
||||
|
||||
out := make([]Event, 0, len(in))
|
||||
for _, ev := range in {
|
||||
switch ev.(type) {
|
||||
case MouseWheelEvent:
|
||||
if len(out) > 0 {
|
||||
if _, ok := out[len(out)-1].(MouseWheelEvent); ok {
|
||||
out[len(out)-1] = ev // replace previous wheel event
|
||||
continue
|
||||
}
|
||||
}
|
||||
case MouseMotionEvent:
|
||||
if len(out) > 0 {
|
||||
if _, ok := out[len(out)-1].(MouseMotionEvent); ok {
|
||||
out[len(out)-1] = ev // replace previous motion event
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out
|
||||
}
|
||||
17
packages/client/tui/input/driver_other.go
Normal file
17
packages/client/tui/input/driver_other.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package input
|
||||
|
||||
// ReadEvents reads input events from the terminal.
|
||||
//
|
||||
// It reads the events available in the input buffer and returns them.
|
||||
func (d *Reader) ReadEvents() ([]Event, error) {
|
||||
return d.readEvents()
|
||||
}
|
||||
|
||||
// parseWin32InputKeyEvent parses a Win32 input key events. This function is
|
||||
// only available on Windows.
|
||||
func (p *Parser) parseWin32InputKeyEvent(*win32InputState, uint16, uint16, rune, bool, uint32, uint16) Event {
|
||||
return nil
|
||||
}
|
||||
25
packages/client/tui/input/driver_test.go
Normal file
25
packages/client/tui/input/driver_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkDriver(b *testing.B) {
|
||||
input := "\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~"
|
||||
rdr := strings.NewReader(input)
|
||||
drv, err := NewReader(rdr, "dumb", 0)
|
||||
if err != nil {
|
||||
b.Fatalf("could not create driver: %v", err)
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
rdr.Reset(input)
|
||||
if _, err := drv.ReadEvents(); err != nil && err != io.EOF {
|
||||
b.Errorf("error reading input: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
642
packages/client/tui/input/driver_windows.go
Normal file
642
packages/client/tui/input/driver_windows.go
Normal file
@@ -0,0 +1,642 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
xwindows "github.com/charmbracelet/x/windows"
|
||||
"github.com/muesli/cancelreader"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// ReadEvents reads input events from the terminal.
|
||||
//
|
||||
// It reads the events available in the input buffer and returns them.
|
||||
func (d *Reader) ReadEvents() ([]Event, error) {
|
||||
events, err := d.handleConInput()
|
||||
if errors.Is(err, errNotConInputReader) {
|
||||
return d.readEvents()
|
||||
}
|
||||
return events, err
|
||||
}
|
||||
|
||||
var errNotConInputReader = fmt.Errorf("handleConInput: not a conInputReader")
|
||||
|
||||
func (d *Reader) handleConInput() ([]Event, error) {
|
||||
cc, ok := d.rd.(*conInputReader)
|
||||
if !ok {
|
||||
return nil, errNotConInputReader
|
||||
}
|
||||
|
||||
var (
|
||||
events []xwindows.InputRecord
|
||||
err error
|
||||
)
|
||||
for {
|
||||
// Peek up to 256 events, this is to allow for sequences events reported as
|
||||
// key events.
|
||||
events, err = peekNConsoleInputs(cc.conin, 256)
|
||||
if cc.isCanceled() {
|
||||
return nil, cancelreader.ErrCanceled
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peek coninput events: %w", err)
|
||||
}
|
||||
if len(events) > 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Sleep for a bit to avoid busy waiting.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
events, err = readNConsoleInputs(cc.conin, uint32(len(events)))
|
||||
if cc.isCanceled() {
|
||||
return nil, cancelreader.ErrCanceled
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read coninput events: %w", err)
|
||||
}
|
||||
|
||||
var evs []Event
|
||||
for _, event := range events {
|
||||
if e := d.parser.parseConInputEvent(event, &d.keyState); e != nil {
|
||||
if multi, ok := e.(MultiEvent); ok {
|
||||
evs = append(evs, multi...)
|
||||
} else {
|
||||
evs = append(evs, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseConInputEvent(event xwindows.InputRecord, keyState *win32InputState) Event {
|
||||
switch event.EventType {
|
||||
case xwindows.KEY_EVENT:
|
||||
kevent := event.KeyEvent()
|
||||
return p.parseWin32InputKeyEvent(keyState, kevent.VirtualKeyCode, kevent.VirtualScanCode,
|
||||
kevent.Char, kevent.KeyDown, kevent.ControlKeyState, kevent.RepeatCount)
|
||||
|
||||
case xwindows.WINDOW_BUFFER_SIZE_EVENT:
|
||||
wevent := event.WindowBufferSizeEvent()
|
||||
if wevent.Size.X != keyState.lastWinsizeX || wevent.Size.Y != keyState.lastWinsizeY {
|
||||
keyState.lastWinsizeX, keyState.lastWinsizeY = wevent.Size.X, wevent.Size.Y
|
||||
return WindowSizeEvent{
|
||||
Width: int(wevent.Size.X),
|
||||
Height: int(wevent.Size.Y),
|
||||
}
|
||||
}
|
||||
case xwindows.MOUSE_EVENT:
|
||||
mevent := event.MouseEvent()
|
||||
Event := mouseEvent(keyState.lastMouseBtns, mevent)
|
||||
keyState.lastMouseBtns = mevent.ButtonState
|
||||
return Event
|
||||
case xwindows.FOCUS_EVENT:
|
||||
fevent := event.FocusEvent()
|
||||
if fevent.SetFocus {
|
||||
return FocusEvent{}
|
||||
}
|
||||
return BlurEvent{}
|
||||
case xwindows.MENU_EVENT:
|
||||
// ignore
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mouseEventButton(p, s uint32) (MouseButton, bool) {
|
||||
var isRelease bool
|
||||
button := MouseNone
|
||||
btn := p ^ s
|
||||
if btn&s == 0 {
|
||||
isRelease = true
|
||||
}
|
||||
|
||||
if btn == 0 {
|
||||
switch {
|
||||
case s&xwindows.FROM_LEFT_1ST_BUTTON_PRESSED > 0:
|
||||
button = MouseLeft
|
||||
case s&xwindows.FROM_LEFT_2ND_BUTTON_PRESSED > 0:
|
||||
button = MouseMiddle
|
||||
case s&xwindows.RIGHTMOST_BUTTON_PRESSED > 0:
|
||||
button = MouseRight
|
||||
case s&xwindows.FROM_LEFT_3RD_BUTTON_PRESSED > 0:
|
||||
button = MouseBackward
|
||||
case s&xwindows.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
|
||||
button = MouseForward
|
||||
}
|
||||
return button, isRelease
|
||||
}
|
||||
|
||||
switch btn {
|
||||
case xwindows.FROM_LEFT_1ST_BUTTON_PRESSED: // left button
|
||||
button = MouseLeft
|
||||
case xwindows.RIGHTMOST_BUTTON_PRESSED: // right button
|
||||
button = MouseRight
|
||||
case xwindows.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button
|
||||
button = MouseMiddle
|
||||
case xwindows.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward)
|
||||
button = MouseBackward
|
||||
case xwindows.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward)
|
||||
button = MouseForward
|
||||
}
|
||||
|
||||
return button, isRelease
|
||||
}
|
||||
|
||||
func mouseEvent(p uint32, e xwindows.MouseEventRecord) (ev Event) {
|
||||
var mod KeyMod
|
||||
var isRelease bool
|
||||
if e.ControlKeyState&(xwindows.LEFT_ALT_PRESSED|xwindows.RIGHT_ALT_PRESSED) != 0 {
|
||||
mod |= ModAlt
|
||||
}
|
||||
if e.ControlKeyState&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_CTRL_PRESSED) != 0 {
|
||||
mod |= ModCtrl
|
||||
}
|
||||
if e.ControlKeyState&(xwindows.SHIFT_PRESSED) != 0 {
|
||||
mod |= ModShift
|
||||
}
|
||||
|
||||
m := Mouse{
|
||||
X: int(e.MousePositon.X),
|
||||
Y: int(e.MousePositon.Y),
|
||||
Mod: mod,
|
||||
}
|
||||
|
||||
wheelDirection := int16(highWord(e.ButtonState)) //nolint:gosec
|
||||
switch e.EventFlags {
|
||||
case 0, xwindows.DOUBLE_CLICK:
|
||||
m.Button, isRelease = mouseEventButton(p, e.ButtonState)
|
||||
case xwindows.MOUSE_WHEELED:
|
||||
if wheelDirection > 0 {
|
||||
m.Button = MouseWheelUp
|
||||
} else {
|
||||
m.Button = MouseWheelDown
|
||||
}
|
||||
case xwindows.MOUSE_HWHEELED:
|
||||
if wheelDirection > 0 {
|
||||
m.Button = MouseWheelRight
|
||||
} else {
|
||||
m.Button = MouseWheelLeft
|
||||
}
|
||||
case xwindows.MOUSE_MOVED:
|
||||
m.Button, _ = mouseEventButton(p, e.ButtonState)
|
||||
return MouseMotionEvent(m)
|
||||
}
|
||||
|
||||
if isWheel(m.Button) {
|
||||
return MouseWheelEvent(m)
|
||||
} else if isRelease {
|
||||
return MouseReleaseEvent(m)
|
||||
}
|
||||
|
||||
return MouseClickEvent(m)
|
||||
}
|
||||
|
||||
func highWord(data uint32) uint16 {
|
||||
return uint16((data & 0xFFFF0000) >> 16) //nolint:gosec
|
||||
}
|
||||
|
||||
func readNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
|
||||
if maxEvents == 0 {
|
||||
return nil, fmt.Errorf("maxEvents cannot be zero")
|
||||
}
|
||||
|
||||
records := make([]xwindows.InputRecord, maxEvents)
|
||||
n, err := readConsoleInput(console, records)
|
||||
return records[:n], err
|
||||
}
|
||||
|
||||
func readConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
|
||||
if len(inputRecords) == 0 {
|
||||
return 0, fmt.Errorf("size of input record buffer cannot be zero")
|
||||
}
|
||||
|
||||
var read uint32
|
||||
|
||||
err := xwindows.ReadConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
|
||||
|
||||
return read, err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
func peekConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
|
||||
if len(inputRecords) == 0 {
|
||||
return 0, fmt.Errorf("size of input record buffer cannot be zero")
|
||||
}
|
||||
|
||||
var read uint32
|
||||
|
||||
err := xwindows.PeekConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
|
||||
|
||||
return read, err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
func peekNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
|
||||
if maxEvents == 0 {
|
||||
return nil, fmt.Errorf("maxEvents cannot be zero")
|
||||
}
|
||||
|
||||
records := make([]xwindows.InputRecord, maxEvents)
|
||||
n, err := peekConsoleInput(console, records)
|
||||
return records[:n], err
|
||||
}
|
||||
|
||||
// parseWin32InputKeyEvent parses a single key event from either the Windows
|
||||
// Console API or win32-input-mode events. When state is nil, it means this is
|
||||
// an event from win32-input-mode. Otherwise, it's a key event from the Windows
|
||||
// Console API and needs a state to decode ANSI escape sequences and utf16
|
||||
// runes.
|
||||
func (p *Parser) parseWin32InputKeyEvent(state *win32InputState, vkc uint16, _ uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) (event Event) {
|
||||
defer func() {
|
||||
// Respect the repeat count.
|
||||
if repeatCount > 1 {
|
||||
var multi MultiEvent
|
||||
for i := 0; i < int(repeatCount); i++ {
|
||||
multi = append(multi, event)
|
||||
}
|
||||
event = multi
|
||||
}
|
||||
}()
|
||||
if state != nil {
|
||||
defer func() {
|
||||
state.lastCks = cks
|
||||
}()
|
||||
}
|
||||
|
||||
var utf8Buf [utf8.UTFMax]byte
|
||||
var key Key
|
||||
if state != nil && state.utf16Half {
|
||||
state.utf16Half = false
|
||||
state.utf16Buf[1] = r
|
||||
codepoint := utf16.DecodeRune(state.utf16Buf[0], state.utf16Buf[1])
|
||||
rw := utf8.EncodeRune(utf8Buf[:], codepoint)
|
||||
r, _ = utf8.DecodeRune(utf8Buf[:rw])
|
||||
key.Code = r
|
||||
key.Text = string(r)
|
||||
key.Mod = translateControlKeyState(cks)
|
||||
key = ensureKeyCase(key, cks)
|
||||
if keyDown {
|
||||
return KeyPressEvent(key)
|
||||
}
|
||||
return KeyReleaseEvent(key)
|
||||
}
|
||||
|
||||
var baseCode rune
|
||||
switch {
|
||||
case vkc == 0:
|
||||
// Zero means this event is either an escape code or a unicode
|
||||
// codepoint.
|
||||
if state != nil && state.ansiIdx == 0 && r != ansi.ESC {
|
||||
// This is a unicode codepoint.
|
||||
baseCode = r
|
||||
break
|
||||
}
|
||||
|
||||
if state != nil {
|
||||
// Collect ANSI escape code.
|
||||
state.ansiBuf[state.ansiIdx] = byte(r)
|
||||
state.ansiIdx++
|
||||
if state.ansiIdx <= 2 {
|
||||
// We haven't received enough bytes to determine if this is an
|
||||
// ANSI escape code.
|
||||
return nil
|
||||
}
|
||||
if r == ansi.ESC {
|
||||
// We're expecting a closing String Terminator [ansi.ST].
|
||||
return nil
|
||||
}
|
||||
|
||||
n, event := p.parseSequence(state.ansiBuf[:state.ansiIdx])
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, ok := event.(UnknownEvent); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.ansiIdx = 0
|
||||
return event
|
||||
}
|
||||
case vkc == xwindows.VK_BACK:
|
||||
baseCode = KeyBackspace
|
||||
case vkc == xwindows.VK_TAB:
|
||||
baseCode = KeyTab
|
||||
case vkc == xwindows.VK_RETURN:
|
||||
baseCode = KeyEnter
|
||||
case vkc == xwindows.VK_SHIFT:
|
||||
//nolint:nestif
|
||||
if cks&xwindows.SHIFT_PRESSED != 0 {
|
||||
if cks&xwindows.ENHANCED_KEY != 0 {
|
||||
baseCode = KeyRightShift
|
||||
} else {
|
||||
baseCode = KeyLeftShift
|
||||
}
|
||||
} else if state != nil {
|
||||
if state.lastCks&xwindows.SHIFT_PRESSED != 0 {
|
||||
if state.lastCks&xwindows.ENHANCED_KEY != 0 {
|
||||
baseCode = KeyRightShift
|
||||
} else {
|
||||
baseCode = KeyLeftShift
|
||||
}
|
||||
}
|
||||
}
|
||||
case vkc == xwindows.VK_CONTROL:
|
||||
if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyLeftCtrl
|
||||
} else if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyRightCtrl
|
||||
} else if state != nil {
|
||||
if state.lastCks&xwindows.LEFT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyLeftCtrl
|
||||
} else if state.lastCks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyRightCtrl
|
||||
}
|
||||
}
|
||||
case vkc == xwindows.VK_MENU:
|
||||
if cks&xwindows.LEFT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyLeftAlt
|
||||
} else if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyRightAlt
|
||||
} else if state != nil {
|
||||
if state.lastCks&xwindows.LEFT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyLeftAlt
|
||||
} else if state.lastCks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyRightAlt
|
||||
}
|
||||
}
|
||||
case vkc == xwindows.VK_PAUSE:
|
||||
baseCode = KeyPause
|
||||
case vkc == xwindows.VK_CAPITAL:
|
||||
baseCode = KeyCapsLock
|
||||
case vkc == xwindows.VK_ESCAPE:
|
||||
baseCode = KeyEscape
|
||||
case vkc == xwindows.VK_SPACE:
|
||||
baseCode = KeySpace
|
||||
case vkc == xwindows.VK_PRIOR:
|
||||
baseCode = KeyPgUp
|
||||
case vkc == xwindows.VK_NEXT:
|
||||
baseCode = KeyPgDown
|
||||
case vkc == xwindows.VK_END:
|
||||
baseCode = KeyEnd
|
||||
case vkc == xwindows.VK_HOME:
|
||||
baseCode = KeyHome
|
||||
case vkc == xwindows.VK_LEFT:
|
||||
baseCode = KeyLeft
|
||||
case vkc == xwindows.VK_UP:
|
||||
baseCode = KeyUp
|
||||
case vkc == xwindows.VK_RIGHT:
|
||||
baseCode = KeyRight
|
||||
case vkc == xwindows.VK_DOWN:
|
||||
baseCode = KeyDown
|
||||
case vkc == xwindows.VK_SELECT:
|
||||
baseCode = KeySelect
|
||||
case vkc == xwindows.VK_SNAPSHOT:
|
||||
baseCode = KeyPrintScreen
|
||||
case vkc == xwindows.VK_INSERT:
|
||||
baseCode = KeyInsert
|
||||
case vkc == xwindows.VK_DELETE:
|
||||
baseCode = KeyDelete
|
||||
case vkc >= '0' && vkc <= '9':
|
||||
baseCode = rune(vkc)
|
||||
case vkc >= 'A' && vkc <= 'Z':
|
||||
// Convert to lowercase.
|
||||
baseCode = rune(vkc) + 32
|
||||
case vkc == xwindows.VK_LWIN:
|
||||
baseCode = KeyLeftSuper
|
||||
case vkc == xwindows.VK_RWIN:
|
||||
baseCode = KeyRightSuper
|
||||
case vkc == xwindows.VK_APPS:
|
||||
baseCode = KeyMenu
|
||||
case vkc >= xwindows.VK_NUMPAD0 && vkc <= xwindows.VK_NUMPAD9:
|
||||
baseCode = rune(vkc-xwindows.VK_NUMPAD0) + KeyKp0
|
||||
case vkc == xwindows.VK_MULTIPLY:
|
||||
baseCode = KeyKpMultiply
|
||||
case vkc == xwindows.VK_ADD:
|
||||
baseCode = KeyKpPlus
|
||||
case vkc == xwindows.VK_SEPARATOR:
|
||||
baseCode = KeyKpComma
|
||||
case vkc == xwindows.VK_SUBTRACT:
|
||||
baseCode = KeyKpMinus
|
||||
case vkc == xwindows.VK_DECIMAL:
|
||||
baseCode = KeyKpDecimal
|
||||
case vkc == xwindows.VK_DIVIDE:
|
||||
baseCode = KeyKpDivide
|
||||
case vkc >= xwindows.VK_F1 && vkc <= xwindows.VK_F24:
|
||||
baseCode = rune(vkc-xwindows.VK_F1) + KeyF1
|
||||
case vkc == xwindows.VK_NUMLOCK:
|
||||
baseCode = KeyNumLock
|
||||
case vkc == xwindows.VK_SCROLL:
|
||||
baseCode = KeyScrollLock
|
||||
case vkc == xwindows.VK_LSHIFT:
|
||||
baseCode = KeyLeftShift
|
||||
case vkc == xwindows.VK_RSHIFT:
|
||||
baseCode = KeyRightShift
|
||||
case vkc == xwindows.VK_LCONTROL:
|
||||
baseCode = KeyLeftCtrl
|
||||
case vkc == xwindows.VK_RCONTROL:
|
||||
baseCode = KeyRightCtrl
|
||||
case vkc == xwindows.VK_LMENU:
|
||||
baseCode = KeyLeftAlt
|
||||
case vkc == xwindows.VK_RMENU:
|
||||
baseCode = KeyRightAlt
|
||||
case vkc == xwindows.VK_VOLUME_MUTE:
|
||||
baseCode = KeyMute
|
||||
case vkc == xwindows.VK_VOLUME_DOWN:
|
||||
baseCode = KeyLowerVol
|
||||
case vkc == xwindows.VK_VOLUME_UP:
|
||||
baseCode = KeyRaiseVol
|
||||
case vkc == xwindows.VK_MEDIA_NEXT_TRACK:
|
||||
baseCode = KeyMediaNext
|
||||
case vkc == xwindows.VK_MEDIA_PREV_TRACK:
|
||||
baseCode = KeyMediaPrev
|
||||
case vkc == xwindows.VK_MEDIA_STOP:
|
||||
baseCode = KeyMediaStop
|
||||
case vkc == xwindows.VK_MEDIA_PLAY_PAUSE:
|
||||
baseCode = KeyMediaPlayPause
|
||||
case vkc == xwindows.VK_OEM_1, vkc == xwindows.VK_OEM_PLUS, vkc == xwindows.VK_OEM_COMMA,
|
||||
vkc == xwindows.VK_OEM_MINUS, vkc == xwindows.VK_OEM_PERIOD, vkc == xwindows.VK_OEM_2,
|
||||
vkc == xwindows.VK_OEM_3, vkc == xwindows.VK_OEM_4, vkc == xwindows.VK_OEM_5,
|
||||
vkc == xwindows.VK_OEM_6, vkc == xwindows.VK_OEM_7:
|
||||
// Use the actual character provided by Windows for current keyboard layout
|
||||
// instead of hardcoded US layout mappings
|
||||
if !unicode.IsControl(r) && unicode.IsPrint(r) {
|
||||
baseCode = r
|
||||
} else {
|
||||
// Fallback to original hardcoded mappings for non-printable cases
|
||||
switch vkc {
|
||||
case xwindows.VK_OEM_1:
|
||||
baseCode = ';'
|
||||
case xwindows.VK_OEM_PLUS:
|
||||
baseCode = '+'
|
||||
case xwindows.VK_OEM_COMMA:
|
||||
baseCode = ','
|
||||
case xwindows.VK_OEM_MINUS:
|
||||
baseCode = '-'
|
||||
case xwindows.VK_OEM_PERIOD:
|
||||
baseCode = '.'
|
||||
case xwindows.VK_OEM_2:
|
||||
baseCode = '/'
|
||||
case xwindows.VK_OEM_3:
|
||||
baseCode = '`'
|
||||
case xwindows.VK_OEM_4:
|
||||
baseCode = '['
|
||||
case xwindows.VK_OEM_5:
|
||||
baseCode = '\\'
|
||||
case xwindows.VK_OEM_6:
|
||||
baseCode = ']'
|
||||
case xwindows.VK_OEM_7:
|
||||
baseCode = '\''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if utf16.IsSurrogate(r) {
|
||||
if state != nil {
|
||||
state.utf16Buf[0] = r
|
||||
state.utf16Half = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AltGr is left ctrl + right alt. On non-US keyboards, this is used to type
|
||||
// special characters and produce printable events.
|
||||
// XXX: Should this be a KeyMod?
|
||||
altGr := cks&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED) == xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED
|
||||
|
||||
// FIXED: Remove numlock and scroll lock states when checking for printable text
|
||||
// These lock states shouldn't affect normal typing
|
||||
cksForTextCheck := cks &^ (xwindows.NUMLOCK_ON | xwindows.SCROLLLOCK_ON)
|
||||
|
||||
var text string
|
||||
keyCode := baseCode
|
||||
if !unicode.IsControl(r) {
|
||||
rw := utf8.EncodeRune(utf8Buf[:], r)
|
||||
keyCode, _ = utf8.DecodeRune(utf8Buf[:rw])
|
||||
if unicode.IsPrint(keyCode) && (cksForTextCheck == 0 ||
|
||||
cksForTextCheck == xwindows.SHIFT_PRESSED ||
|
||||
cksForTextCheck == xwindows.CAPSLOCK_ON ||
|
||||
altGr) {
|
||||
// If the control key state is 0, shift is pressed, or caps lock
|
||||
// then the key event is a printable event i.e. [text] is not empty.
|
||||
text = string(keyCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: numeric keypad divide should produce "/" text on all layouts (fix french keyboard layout)
|
||||
if baseCode == KeyKpDivide {
|
||||
text = "/"
|
||||
}
|
||||
|
||||
key.Code = keyCode
|
||||
key.Text = text
|
||||
key.Mod = translateControlKeyState(cks)
|
||||
key.BaseCode = baseCode
|
||||
key = ensureKeyCase(key, cks)
|
||||
if keyDown {
|
||||
return KeyPressEvent(key)
|
||||
}
|
||||
|
||||
return KeyReleaseEvent(key)
|
||||
}
|
||||
|
||||
// ensureKeyCase ensures that the key's text is in the correct case based on the
|
||||
// control key state.
|
||||
func ensureKeyCase(key Key, cks uint32) Key {
|
||||
if len(key.Text) == 0 {
|
||||
return key
|
||||
}
|
||||
|
||||
hasShift := cks&xwindows.SHIFT_PRESSED != 0
|
||||
hasCaps := cks&xwindows.CAPSLOCK_ON != 0
|
||||
if hasShift || hasCaps {
|
||||
if unicode.IsLower(key.Code) {
|
||||
key.ShiftedCode = unicode.ToUpper(key.Code)
|
||||
key.Text = string(key.ShiftedCode)
|
||||
}
|
||||
} else {
|
||||
if unicode.IsUpper(key.Code) {
|
||||
key.ShiftedCode = unicode.ToLower(key.Code)
|
||||
key.Text = string(key.ShiftedCode)
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// translateControlKeyState translates the control key state from the Windows
|
||||
// Console API into a Mod bitmask.
|
||||
func translateControlKeyState(cks uint32) (m KeyMod) {
|
||||
if cks&xwindows.LEFT_CTRL_PRESSED != 0 || cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
m |= ModCtrl
|
||||
}
|
||||
if cks&xwindows.LEFT_ALT_PRESSED != 0 || cks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
m |= ModAlt
|
||||
}
|
||||
if cks&xwindows.SHIFT_PRESSED != 0 {
|
||||
m |= ModShift
|
||||
}
|
||||
if cks&xwindows.CAPSLOCK_ON != 0 {
|
||||
m |= ModCapsLock
|
||||
}
|
||||
if cks&xwindows.NUMLOCK_ON != 0 {
|
||||
m |= ModNumLock
|
||||
}
|
||||
if cks&xwindows.SCROLLLOCK_ON != 0 {
|
||||
m |= ModScrollLock
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func keyEventString(vkc, sc uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) string {
|
||||
var s strings.Builder
|
||||
s.WriteString("vkc: ")
|
||||
s.WriteString(fmt.Sprintf("%d, 0x%02x", vkc, vkc))
|
||||
s.WriteString(", sc: ")
|
||||
s.WriteString(fmt.Sprintf("%d, 0x%02x", sc, sc))
|
||||
s.WriteString(", r: ")
|
||||
s.WriteString(fmt.Sprintf("%q", r))
|
||||
s.WriteString(", down: ")
|
||||
s.WriteString(fmt.Sprintf("%v", keyDown))
|
||||
s.WriteString(", cks: [")
|
||||
if cks&xwindows.LEFT_ALT_PRESSED != 0 {
|
||||
s.WriteString("left alt, ")
|
||||
}
|
||||
if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
s.WriteString("right alt, ")
|
||||
}
|
||||
if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
|
||||
s.WriteString("left ctrl, ")
|
||||
}
|
||||
if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
s.WriteString("right ctrl, ")
|
||||
}
|
||||
if cks&xwindows.SHIFT_PRESSED != 0 {
|
||||
s.WriteString("shift, ")
|
||||
}
|
||||
if cks&xwindows.CAPSLOCK_ON != 0 {
|
||||
s.WriteString("caps lock, ")
|
||||
}
|
||||
if cks&xwindows.NUMLOCK_ON != 0 {
|
||||
s.WriteString("num lock, ")
|
||||
}
|
||||
if cks&xwindows.SCROLLLOCK_ON != 0 {
|
||||
s.WriteString("scroll lock, ")
|
||||
}
|
||||
if cks&xwindows.ENHANCED_KEY != 0 {
|
||||
s.WriteString("enhanced key, ")
|
||||
}
|
||||
s.WriteString("], repeat count: ")
|
||||
s.WriteString(fmt.Sprintf("%d", repeatCount))
|
||||
return s.String()
|
||||
}
|
||||
271
packages/client/tui/input/driver_windows_test.go
Normal file
271
packages/client/tui/input/driver_windows_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"image/color"
|
||||
"reflect"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
xwindows "github.com/charmbracelet/x/windows"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestWindowsInputEvents(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
events []xwindows.InputRecord
|
||||
expected []Event
|
||||
sequence bool // indicates that the input events are ANSI sequence or utf16
|
||||
}{
|
||||
{
|
||||
name: "single key event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: 'a',
|
||||
VirtualKeyCode: 'A',
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Text: "a"}},
|
||||
},
|
||||
{
|
||||
name: "single key event with control key",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: 'a',
|
||||
VirtualKeyCode: 'A',
|
||||
ControlKeyState: xwindows.LEFT_CTRL_PRESSED,
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Mod: ModCtrl}},
|
||||
},
|
||||
{
|
||||
name: "escape alt key event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: ansi.ESC,
|
||||
VirtualKeyCode: ansi.ESC,
|
||||
ControlKeyState: xwindows.LEFT_ALT_PRESSED,
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: ansi.ESC, BaseCode: ansi.ESC, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
name: "single shifted key event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: 'A',
|
||||
VirtualKeyCode: 'A',
|
||||
ControlKeyState: xwindows.SHIFT_PRESSED,
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: 'A', BaseCode: 'a', Text: "A", Mod: ModShift}},
|
||||
},
|
||||
{
|
||||
name: "utf16 rune",
|
||||
events: encodeUtf16Rune('😊'), // smiley emoji '😊'
|
||||
expected: []Event{
|
||||
KeyPressEvent{Code: '😊', Text: "😊"},
|
||||
},
|
||||
sequence: true,
|
||||
},
|
||||
{
|
||||
name: "background color response",
|
||||
events: encodeSequence("\x1b]11;rgb:ff/ff/ff\x07"),
|
||||
expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
|
||||
sequence: true,
|
||||
},
|
||||
{
|
||||
name: "st terminated background color response",
|
||||
events: encodeSequence("\x1b]11;rgb:ffff/ffff/ffff\x1b\\"),
|
||||
expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
|
||||
sequence: true,
|
||||
},
|
||||
{
|
||||
name: "simple mouse event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeMouseEvent(xwindows.MouseEventRecord{
|
||||
MousePositon: windows.Coord{X: 10, Y: 20},
|
||||
ButtonState: xwindows.FROM_LEFT_1ST_BUTTON_PRESSED,
|
||||
EventFlags: 0,
|
||||
}),
|
||||
encodeMouseEvent(xwindows.MouseEventRecord{
|
||||
MousePositon: windows.Coord{X: 10, Y: 20},
|
||||
EventFlags: 0,
|
||||
}),
|
||||
},
|
||||
expected: []Event{
|
||||
MouseClickEvent{Button: MouseLeft, X: 10, Y: 20},
|
||||
MouseReleaseEvent{Button: MouseLeft, X: 10, Y: 20},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "focus event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeFocusEvent(xwindows.FocusEventRecord{
|
||||
SetFocus: true,
|
||||
}),
|
||||
encodeFocusEvent(xwindows.FocusEventRecord{
|
||||
SetFocus: false,
|
||||
}),
|
||||
},
|
||||
expected: []Event{
|
||||
FocusEvent{},
|
||||
BlurEvent{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "window size event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeWindowBufferSizeEvent(xwindows.WindowBufferSizeRecord{
|
||||
Size: windows.Coord{X: 10, Y: 20},
|
||||
}),
|
||||
},
|
||||
expected: []Event{
|
||||
WindowSizeEvent{Width: 10, Height: 20},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// p is the parser to parse the input events
|
||||
var p Parser
|
||||
|
||||
// keep track of the state of the driver to handle ANSI sequences and utf16
|
||||
var state win32InputState
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.sequence {
|
||||
var Event Event
|
||||
for _, ev := range tc.events {
|
||||
if ev.EventType != xwindows.KEY_EVENT {
|
||||
t.Fatalf("expected key event, got %v", ev.EventType)
|
||||
}
|
||||
|
||||
key := ev.KeyEvent()
|
||||
Event = p.parseWin32InputKeyEvent(&state, key.VirtualKeyCode, key.VirtualScanCode, key.Char, key.KeyDown, key.ControlKeyState, key.RepeatCount)
|
||||
}
|
||||
if len(tc.expected) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(tc.expected))
|
||||
}
|
||||
if !reflect.DeepEqual(Event, tc.expected[0]) {
|
||||
t.Errorf("expected %v, got %v", tc.expected[0], Event)
|
||||
}
|
||||
} else {
|
||||
if len(tc.events) != len(tc.expected) {
|
||||
t.Fatalf("expected %d events, got %d", len(tc.expected), len(tc.events))
|
||||
}
|
||||
for j, ev := range tc.events {
|
||||
Event := p.parseConInputEvent(ev, &state)
|
||||
if !reflect.DeepEqual(Event, tc.expected[j]) {
|
||||
t.Errorf("expected %#v, got %#v", tc.expected[j], Event)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func boolToUint32(b bool) uint32 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func encodeMenuEvent(menu xwindows.MenuEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint32(bts[0:4], menu.CommandID)
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.MENU_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeWindowBufferSizeEvent(size xwindows.WindowBufferSizeRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint16(bts[0:2], uint16(size.Size.X))
|
||||
binary.LittleEndian.PutUint16(bts[2:4], uint16(size.Size.Y))
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.WINDOW_BUFFER_SIZE_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeFocusEvent(focus xwindows.FocusEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
if focus.SetFocus {
|
||||
bts[0] = 1
|
||||
}
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.FOCUS_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeMouseEvent(mouse xwindows.MouseEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint16(bts[0:2], uint16(mouse.MousePositon.X))
|
||||
binary.LittleEndian.PutUint16(bts[2:4], uint16(mouse.MousePositon.Y))
|
||||
binary.LittleEndian.PutUint32(bts[4:8], mouse.ButtonState)
|
||||
binary.LittleEndian.PutUint32(bts[8:12], mouse.ControlKeyState)
|
||||
binary.LittleEndian.PutUint32(bts[12:16], mouse.EventFlags)
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.MOUSE_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeKeyEvent(key xwindows.KeyEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint32(bts[0:4], boolToUint32(key.KeyDown))
|
||||
binary.LittleEndian.PutUint16(bts[4:6], key.RepeatCount)
|
||||
binary.LittleEndian.PutUint16(bts[6:8], key.VirtualKeyCode)
|
||||
binary.LittleEndian.PutUint16(bts[8:10], key.VirtualScanCode)
|
||||
binary.LittleEndian.PutUint16(bts[10:12], uint16(key.Char))
|
||||
binary.LittleEndian.PutUint32(bts[12:16], key.ControlKeyState)
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.KEY_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
// encodeSequence encodes a string of ANSI escape sequences into a slice of
|
||||
// Windows input key records.
|
||||
func encodeSequence(s string) (evs []xwindows.InputRecord) {
|
||||
var state byte
|
||||
for len(s) > 0 {
|
||||
seq, _, n, newState := ansi.DecodeSequence(s, state, nil)
|
||||
for i := 0; i < n; i++ {
|
||||
evs = append(evs, encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: rune(seq[i]),
|
||||
}))
|
||||
}
|
||||
state = newState
|
||||
s = s[n:]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func encodeUtf16Rune(r rune) []xwindows.InputRecord {
|
||||
r1, r2 := utf16.EncodeRune(r)
|
||||
return encodeUtf16Pair(r1, r2)
|
||||
}
|
||||
|
||||
func encodeUtf16Pair(r1, r2 rune) []xwindows.InputRecord {
|
||||
return []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: r1,
|
||||
}),
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: r2,
|
||||
}),
|
||||
}
|
||||
}
|
||||
9
packages/client/tui/input/focus.go
Normal file
9
packages/client/tui/input/focus.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package input
|
||||
|
||||
// FocusEvent represents a terminal focus event.
|
||||
// This occurs when the terminal gains focus.
|
||||
type FocusEvent struct{}
|
||||
|
||||
// BlurEvent represents a terminal blur event.
|
||||
// This occurs when the terminal loses focus.
|
||||
type BlurEvent struct{}
|
||||
27
packages/client/tui/input/focus_test.go
Normal file
27
packages/client/tui/input/focus_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFocus(t *testing.T) {
|
||||
var p Parser
|
||||
_, e := p.parseSequence([]byte("\x1b[I"))
|
||||
switch e.(type) {
|
||||
case FocusEvent:
|
||||
// ok
|
||||
default:
|
||||
t.Error("invalid sequence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlur(t *testing.T) {
|
||||
var p Parser
|
||||
_, e := p.parseSequence([]byte("\x1b[O"))
|
||||
switch e.(type) {
|
||||
case BlurEvent:
|
||||
// ok
|
||||
default:
|
||||
t.Error("invalid sequence")
|
||||
}
|
||||
}
|
||||
18
packages/client/tui/input/go.mod
Normal file
18
packages/client/tui/input/go.mod
Normal file
@@ -0,0 +1,18 @@
|
||||
module github.com/charmbracelet/x/input
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/x/ansi v0.9.3
|
||||
github.com/charmbracelet/x/windows v0.2.1
|
||||
github.com/muesli/cancelreader v0.2.2
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
|
||||
golang.org/x/sys v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
)
|
||||
19
packages/client/tui/input/go.sum
Normal file
19
packages/client/tui/input/go.sum
Normal file
@@ -0,0 +1,19 @@
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
|
||||
github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
45
packages/client/tui/input/input.go
Normal file
45
packages/client/tui/input/input.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Event represents a terminal event.
|
||||
type Event any
|
||||
|
||||
// UnknownEvent represents an unknown event.
|
||||
type UnknownEvent string
|
||||
|
||||
// String returns a string representation of the unknown event.
|
||||
func (e UnknownEvent) String() string {
|
||||
return fmt.Sprintf("%q", string(e))
|
||||
}
|
||||
|
||||
// MultiEvent represents multiple messages event.
|
||||
type MultiEvent []Event
|
||||
|
||||
// String returns a string representation of the multiple messages event.
|
||||
func (e MultiEvent) String() string {
|
||||
var sb strings.Builder
|
||||
for _, ev := range e {
|
||||
sb.WriteString(fmt.Sprintf("%v\n", ev))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// WindowSizeEvent is used to report the terminal size. Note that Windows does
|
||||
// not have support for reporting resizes via SIGWINCH signals and relies on
|
||||
// the Windows Console API to report window size changes.
|
||||
type WindowSizeEvent struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// WindowOpEvent is a window operation (XTWINOPS) report event. This is used to
|
||||
// report various window operations such as reporting the window size or cell
|
||||
// size.
|
||||
type WindowOpEvent struct {
|
||||
Op int
|
||||
Args []int
|
||||
}
|
||||
574
packages/client/tui/input/key.go
Normal file
574
packages/client/tui/input/key.go
Normal file
@@ -0,0 +1,574 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
// KeyExtended is a special key code used to signify that a key event
|
||||
// contains multiple runes.
|
||||
KeyExtended = unicode.MaxRune + 1
|
||||
)
|
||||
|
||||
// Special key symbols.
|
||||
const (
|
||||
|
||||
// Special keys.
|
||||
|
||||
KeyUp rune = KeyExtended + iota + 1
|
||||
KeyDown
|
||||
KeyRight
|
||||
KeyLeft
|
||||
KeyBegin
|
||||
KeyFind
|
||||
KeyInsert
|
||||
KeyDelete
|
||||
KeySelect
|
||||
KeyPgUp
|
||||
KeyPgDown
|
||||
KeyHome
|
||||
KeyEnd
|
||||
|
||||
// Keypad keys.
|
||||
|
||||
KeyKpEnter
|
||||
KeyKpEqual
|
||||
KeyKpMultiply
|
||||
KeyKpPlus
|
||||
KeyKpComma
|
||||
KeyKpMinus
|
||||
KeyKpDecimal
|
||||
KeyKpDivide
|
||||
KeyKp0
|
||||
KeyKp1
|
||||
KeyKp2
|
||||
KeyKp3
|
||||
KeyKp4
|
||||
KeyKp5
|
||||
KeyKp6
|
||||
KeyKp7
|
||||
KeyKp8
|
||||
KeyKp9
|
||||
|
||||
//nolint:godox
|
||||
// The following are keys defined in the Kitty keyboard protocol.
|
||||
// TODO: Investigate the names of these keys.
|
||||
|
||||
KeyKpSep
|
||||
KeyKpUp
|
||||
KeyKpDown
|
||||
KeyKpLeft
|
||||
KeyKpRight
|
||||
KeyKpPgUp
|
||||
KeyKpPgDown
|
||||
KeyKpHome
|
||||
KeyKpEnd
|
||||
KeyKpInsert
|
||||
KeyKpDelete
|
||||
KeyKpBegin
|
||||
|
||||
// Function keys.
|
||||
|
||||
KeyF1
|
||||
KeyF2
|
||||
KeyF3
|
||||
KeyF4
|
||||
KeyF5
|
||||
KeyF6
|
||||
KeyF7
|
||||
KeyF8
|
||||
KeyF9
|
||||
KeyF10
|
||||
KeyF11
|
||||
KeyF12
|
||||
KeyF13
|
||||
KeyF14
|
||||
KeyF15
|
||||
KeyF16
|
||||
KeyF17
|
||||
KeyF18
|
||||
KeyF19
|
||||
KeyF20
|
||||
KeyF21
|
||||
KeyF22
|
||||
KeyF23
|
||||
KeyF24
|
||||
KeyF25
|
||||
KeyF26
|
||||
KeyF27
|
||||
KeyF28
|
||||
KeyF29
|
||||
KeyF30
|
||||
KeyF31
|
||||
KeyF32
|
||||
KeyF33
|
||||
KeyF34
|
||||
KeyF35
|
||||
KeyF36
|
||||
KeyF37
|
||||
KeyF38
|
||||
KeyF39
|
||||
KeyF40
|
||||
KeyF41
|
||||
KeyF42
|
||||
KeyF43
|
||||
KeyF44
|
||||
KeyF45
|
||||
KeyF46
|
||||
KeyF47
|
||||
KeyF48
|
||||
KeyF49
|
||||
KeyF50
|
||||
KeyF51
|
||||
KeyF52
|
||||
KeyF53
|
||||
KeyF54
|
||||
KeyF55
|
||||
KeyF56
|
||||
KeyF57
|
||||
KeyF58
|
||||
KeyF59
|
||||
KeyF60
|
||||
KeyF61
|
||||
KeyF62
|
||||
KeyF63
|
||||
|
||||
//nolint:godox
|
||||
// The following are keys defined in the Kitty keyboard protocol.
|
||||
// TODO: Investigate the names of these keys.
|
||||
|
||||
KeyCapsLock
|
||||
KeyScrollLock
|
||||
KeyNumLock
|
||||
KeyPrintScreen
|
||||
KeyPause
|
||||
KeyMenu
|
||||
|
||||
KeyMediaPlay
|
||||
KeyMediaPause
|
||||
KeyMediaPlayPause
|
||||
KeyMediaReverse
|
||||
KeyMediaStop
|
||||
KeyMediaFastForward
|
||||
KeyMediaRewind
|
||||
KeyMediaNext
|
||||
KeyMediaPrev
|
||||
KeyMediaRecord
|
||||
|
||||
KeyLowerVol
|
||||
KeyRaiseVol
|
||||
KeyMute
|
||||
|
||||
KeyLeftShift
|
||||
KeyLeftAlt
|
||||
KeyLeftCtrl
|
||||
KeyLeftSuper
|
||||
KeyLeftHyper
|
||||
KeyLeftMeta
|
||||
KeyRightShift
|
||||
KeyRightAlt
|
||||
KeyRightCtrl
|
||||
KeyRightSuper
|
||||
KeyRightHyper
|
||||
KeyRightMeta
|
||||
KeyIsoLevel3Shift
|
||||
KeyIsoLevel5Shift
|
||||
|
||||
// Special names in C0.
|
||||
|
||||
KeyBackspace = rune(ansi.DEL)
|
||||
KeyTab = rune(ansi.HT)
|
||||
KeyEnter = rune(ansi.CR)
|
||||
KeyReturn = KeyEnter
|
||||
KeyEscape = rune(ansi.ESC)
|
||||
KeyEsc = KeyEscape
|
||||
|
||||
// Special names in G0.
|
||||
|
||||
KeySpace = rune(ansi.SP)
|
||||
)
|
||||
|
||||
// KeyPressEvent represents a key press event.
|
||||
type KeyPressEvent Key
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. For details, on what this returns see [Key.String].
|
||||
func (k KeyPressEvent) String() string {
|
||||
return Key(k).String()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k KeyPressEvent) Keystroke() string {
|
||||
return Key(k).Keystroke()
|
||||
}
|
||||
|
||||
// Key returns the underlying key event. This is a syntactic sugar for casting
|
||||
// the key event to a [Key].
|
||||
func (k KeyPressEvent) Key() Key {
|
||||
return Key(k)
|
||||
}
|
||||
|
||||
// KeyReleaseEvent represents a key release event.
|
||||
type KeyReleaseEvent Key
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. For details, on what this returns see [Key.String].
|
||||
func (k KeyReleaseEvent) String() string {
|
||||
return Key(k).String()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k KeyReleaseEvent) Keystroke() string {
|
||||
return Key(k).Keystroke()
|
||||
}
|
||||
|
||||
// Key returns the underlying key event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [KeyEvent] interface, and cast the key event to
|
||||
// [Key].
|
||||
func (k KeyReleaseEvent) Key() Key {
|
||||
return Key(k)
|
||||
}
|
||||
|
||||
// KeyEvent represents a key event. This can be either a key press or a key
|
||||
// release event.
|
||||
type KeyEvent interface {
|
||||
fmt.Stringer
|
||||
|
||||
// Key returns the underlying key event.
|
||||
Key() Key
|
||||
}
|
||||
|
||||
// Key represents a Key press or release event. It contains information about
|
||||
// the Key pressed, like the runes, the type of Key, and the modifiers pressed.
|
||||
// There are a couple general patterns you could use to check for key presses
|
||||
// or releases:
|
||||
//
|
||||
// // Switch on the string representation of the key (shorter)
|
||||
// switch ev := ev.(type) {
|
||||
// case KeyPressEvent:
|
||||
// switch ev.String() {
|
||||
// case "enter":
|
||||
// fmt.Println("you pressed enter!")
|
||||
// case "a":
|
||||
// fmt.Println("you pressed a!")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Switch on the key type (more foolproof)
|
||||
// switch ev := ev.(type) {
|
||||
// case KeyEvent:
|
||||
// // catch both KeyPressEvent and KeyReleaseEvent
|
||||
// switch key := ev.Key(); key.Code {
|
||||
// case KeyEnter:
|
||||
// fmt.Println("you pressed enter!")
|
||||
// default:
|
||||
// switch key.Text {
|
||||
// case "a":
|
||||
// fmt.Println("you pressed a!")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Note that [Key.Text] will be empty for special keys like [KeyEnter],
|
||||
// [KeyTab], and for keys that don't represent printable characters like key
|
||||
// combos with modifier keys. In other words, [Key.Text] is populated only for
|
||||
// keys that represent printable characters shifted or unshifted (like 'a',
|
||||
// 'A', '1', '!', etc.).
|
||||
type Key struct {
|
||||
// Text contains the actual characters received. This usually the same as
|
||||
// [Key.Code]. When [Key.Text] is non-empty, it indicates that the key
|
||||
// pressed represents printable character(s).
|
||||
Text string
|
||||
|
||||
// Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on.
|
||||
Mod KeyMod
|
||||
|
||||
// Code represents the key pressed. This is usually a special key like
|
||||
// [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'.
|
||||
Code rune
|
||||
|
||||
// ShiftedCode is the actual, shifted key pressed by the user. For example,
|
||||
// if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will
|
||||
// be 'A' and [Key.Code] will be 'a'.
|
||||
//
|
||||
// In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the
|
||||
// unshifted key on the keyboard.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
ShiftedCode rune
|
||||
|
||||
// BaseCode is the key pressed according to the standard PC-101 key layout.
|
||||
// On international keyboards, this is the key that would be pressed if the
|
||||
// keyboard was set to US PC-101 layout.
|
||||
//
|
||||
// For example, if the user presses 'q' on a French AZERTY keyboard,
|
||||
// [Key.BaseCode] will be 'q'.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
BaseCode rune
|
||||
|
||||
// IsRepeat indicates whether the key is being held down and sending events
|
||||
// repeatedly.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
IsRepeat bool
|
||||
}
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. It will return the textual representation of the [Key] if there is
|
||||
// one, otherwise, it will fallback to [Key.Keystroke].
|
||||
//
|
||||
// For example, you'll always get "?" and instead of "shift+/" on a US ANSI
|
||||
// keyboard.
|
||||
func (k Key) String() string {
|
||||
if len(k.Text) > 0 && k.Text != " " {
|
||||
return k.Text
|
||||
}
|
||||
return k.Keystroke()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k Key) Keystroke() string {
|
||||
var sb strings.Builder
|
||||
if k.Mod.Contains(ModCtrl) && k.Code != KeyLeftCtrl && k.Code != KeyRightCtrl {
|
||||
sb.WriteString("ctrl+")
|
||||
}
|
||||
if k.Mod.Contains(ModAlt) && k.Code != KeyLeftAlt && k.Code != KeyRightAlt {
|
||||
sb.WriteString("alt+")
|
||||
}
|
||||
if k.Mod.Contains(ModShift) && k.Code != KeyLeftShift && k.Code != KeyRightShift {
|
||||
sb.WriteString("shift+")
|
||||
}
|
||||
if k.Mod.Contains(ModMeta) && k.Code != KeyLeftMeta && k.Code != KeyRightMeta {
|
||||
sb.WriteString("meta+")
|
||||
}
|
||||
if k.Mod.Contains(ModHyper) && k.Code != KeyLeftHyper && k.Code != KeyRightHyper {
|
||||
sb.WriteString("hyper+")
|
||||
}
|
||||
if k.Mod.Contains(ModSuper) && k.Code != KeyLeftSuper && k.Code != KeyRightSuper {
|
||||
sb.WriteString("super+")
|
||||
}
|
||||
|
||||
if kt, ok := keyTypeString[k.Code]; ok {
|
||||
sb.WriteString(kt)
|
||||
} else {
|
||||
code := k.Code
|
||||
if k.BaseCode != 0 {
|
||||
// If a [Key.BaseCode] is present, use it to represent a key using the standard
|
||||
// PC-101 key layout.
|
||||
code = k.BaseCode
|
||||
}
|
||||
|
||||
switch code {
|
||||
case KeySpace:
|
||||
// Space is the only invisible printable character.
|
||||
sb.WriteString("space")
|
||||
case KeyExtended:
|
||||
// Write the actual text of the key when the key contains multiple
|
||||
// runes.
|
||||
sb.WriteString(k.Text)
|
||||
default:
|
||||
sb.WriteRune(code)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var keyTypeString = map[rune]string{
|
||||
KeyEnter: "enter",
|
||||
KeyTab: "tab",
|
||||
KeyBackspace: "backspace",
|
||||
KeyEscape: "esc",
|
||||
KeySpace: "space",
|
||||
KeyUp: "up",
|
||||
KeyDown: "down",
|
||||
KeyLeft: "left",
|
||||
KeyRight: "right",
|
||||
KeyBegin: "begin",
|
||||
KeyFind: "find",
|
||||
KeyInsert: "insert",
|
||||
KeyDelete: "delete",
|
||||
KeySelect: "select",
|
||||
KeyPgUp: "pgup",
|
||||
KeyPgDown: "pgdown",
|
||||
KeyHome: "home",
|
||||
KeyEnd: "end",
|
||||
KeyKpEnter: "kpenter",
|
||||
KeyKpEqual: "kpequal",
|
||||
KeyKpMultiply: "kpmul",
|
||||
KeyKpPlus: "kpplus",
|
||||
KeyKpComma: "kpcomma",
|
||||
KeyKpMinus: "kpminus",
|
||||
KeyKpDecimal: "kpperiod",
|
||||
KeyKpDivide: "kpdiv",
|
||||
KeyKp0: "kp0",
|
||||
KeyKp1: "kp1",
|
||||
KeyKp2: "kp2",
|
||||
KeyKp3: "kp3",
|
||||
KeyKp4: "kp4",
|
||||
KeyKp5: "kp5",
|
||||
KeyKp6: "kp6",
|
||||
KeyKp7: "kp7",
|
||||
KeyKp8: "kp8",
|
||||
KeyKp9: "kp9",
|
||||
|
||||
// Kitty keyboard extension
|
||||
KeyKpSep: "kpsep",
|
||||
KeyKpUp: "kpup",
|
||||
KeyKpDown: "kpdown",
|
||||
KeyKpLeft: "kpleft",
|
||||
KeyKpRight: "kpright",
|
||||
KeyKpPgUp: "kppgup",
|
||||
KeyKpPgDown: "kppgdown",
|
||||
KeyKpHome: "kphome",
|
||||
KeyKpEnd: "kpend",
|
||||
KeyKpInsert: "kpinsert",
|
||||
KeyKpDelete: "kpdelete",
|
||||
KeyKpBegin: "kpbegin",
|
||||
|
||||
KeyF1: "f1",
|
||||
KeyF2: "f2",
|
||||
KeyF3: "f3",
|
||||
KeyF4: "f4",
|
||||
KeyF5: "f5",
|
||||
KeyF6: "f6",
|
||||
KeyF7: "f7",
|
||||
KeyF8: "f8",
|
||||
KeyF9: "f9",
|
||||
KeyF10: "f10",
|
||||
KeyF11: "f11",
|
||||
KeyF12: "f12",
|
||||
KeyF13: "f13",
|
||||
KeyF14: "f14",
|
||||
KeyF15: "f15",
|
||||
KeyF16: "f16",
|
||||
KeyF17: "f17",
|
||||
KeyF18: "f18",
|
||||
KeyF19: "f19",
|
||||
KeyF20: "f20",
|
||||
KeyF21: "f21",
|
||||
KeyF22: "f22",
|
||||
KeyF23: "f23",
|
||||
KeyF24: "f24",
|
||||
KeyF25: "f25",
|
||||
KeyF26: "f26",
|
||||
KeyF27: "f27",
|
||||
KeyF28: "f28",
|
||||
KeyF29: "f29",
|
||||
KeyF30: "f30",
|
||||
KeyF31: "f31",
|
||||
KeyF32: "f32",
|
||||
KeyF33: "f33",
|
||||
KeyF34: "f34",
|
||||
KeyF35: "f35",
|
||||
KeyF36: "f36",
|
||||
KeyF37: "f37",
|
||||
KeyF38: "f38",
|
||||
KeyF39: "f39",
|
||||
KeyF40: "f40",
|
||||
KeyF41: "f41",
|
||||
KeyF42: "f42",
|
||||
KeyF43: "f43",
|
||||
KeyF44: "f44",
|
||||
KeyF45: "f45",
|
||||
KeyF46: "f46",
|
||||
KeyF47: "f47",
|
||||
KeyF48: "f48",
|
||||
KeyF49: "f49",
|
||||
KeyF50: "f50",
|
||||
KeyF51: "f51",
|
||||
KeyF52: "f52",
|
||||
KeyF53: "f53",
|
||||
KeyF54: "f54",
|
||||
KeyF55: "f55",
|
||||
KeyF56: "f56",
|
||||
KeyF57: "f57",
|
||||
KeyF58: "f58",
|
||||
KeyF59: "f59",
|
||||
KeyF60: "f60",
|
||||
KeyF61: "f61",
|
||||
KeyF62: "f62",
|
||||
KeyF63: "f63",
|
||||
|
||||
// Kitty keyboard extension
|
||||
KeyCapsLock: "capslock",
|
||||
KeyScrollLock: "scrolllock",
|
||||
KeyNumLock: "numlock",
|
||||
KeyPrintScreen: "printscreen",
|
||||
KeyPause: "pause",
|
||||
KeyMenu: "menu",
|
||||
KeyMediaPlay: "mediaplay",
|
||||
KeyMediaPause: "mediapause",
|
||||
KeyMediaPlayPause: "mediaplaypause",
|
||||
KeyMediaReverse: "mediareverse",
|
||||
KeyMediaStop: "mediastop",
|
||||
KeyMediaFastForward: "mediafastforward",
|
||||
KeyMediaRewind: "mediarewind",
|
||||
KeyMediaNext: "medianext",
|
||||
KeyMediaPrev: "mediaprev",
|
||||
KeyMediaRecord: "mediarecord",
|
||||
KeyLowerVol: "lowervol",
|
||||
KeyRaiseVol: "raisevol",
|
||||
KeyMute: "mute",
|
||||
KeyLeftShift: "leftshift",
|
||||
KeyLeftAlt: "leftalt",
|
||||
KeyLeftCtrl: "leftctrl",
|
||||
KeyLeftSuper: "leftsuper",
|
||||
KeyLeftHyper: "lefthyper",
|
||||
KeyLeftMeta: "leftmeta",
|
||||
KeyRightShift: "rightshift",
|
||||
KeyRightAlt: "rightalt",
|
||||
KeyRightCtrl: "rightctrl",
|
||||
KeyRightSuper: "rightsuper",
|
||||
KeyRightHyper: "righthyper",
|
||||
KeyRightMeta: "rightmeta",
|
||||
KeyIsoLevel3Shift: "isolevel3shift",
|
||||
KeyIsoLevel5Shift: "isolevel5shift",
|
||||
}
|
||||
880
packages/client/tui/input/key_test.go
Normal file
880
packages/client/tui/input/key_test.go
Normal file
@@ -0,0 +1,880 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"io"
|
||||
"math/rand"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/ansi/kitty"
|
||||
)
|
||||
|
||||
var sequences = buildKeysTable(FlagTerminfo, "dumb")
|
||||
|
||||
func TestKeyString(t *testing.T) {
|
||||
t.Run("alt+space", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: KeySpace, Mod: ModAlt}
|
||||
if got := k.String(); got != "alt+space" {
|
||||
t.Fatalf(`expected a "alt+space", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("runes", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: 'a', Text: "a"}
|
||||
if got := k.String(); got != "a" {
|
||||
t.Fatalf(`expected an "a", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: 99999}
|
||||
if got := k.String(); got != "𘚟" {
|
||||
t.Fatalf(`expected a "unknown", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("space", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: KeySpace, Text: " "}
|
||||
if got := k.String(); got != "space" {
|
||||
t.Fatalf(`expected a "space", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("shift+space", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: KeySpace, Mod: ModShift}
|
||||
if got := k.String(); got != "shift+space" {
|
||||
t.Fatalf(`expected a "shift+space", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("?", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: '/', Mod: ModShift, Text: "?"}
|
||||
if got := k.String(); got != "?" {
|
||||
t.Fatalf(`expected a "?", got %q`, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type seqTest struct {
|
||||
seq []byte
|
||||
Events []Event
|
||||
}
|
||||
|
||||
var f3CurPosRegexp = regexp.MustCompile(`\x1b\[1;(\d+)R`)
|
||||
|
||||
// buildBaseSeqTests returns sequence tests that are valid for the
|
||||
// detectSequence() function.
|
||||
func buildBaseSeqTests() []seqTest {
|
||||
td := []seqTest{}
|
||||
for seq, key := range sequences {
|
||||
k := KeyPressEvent(key)
|
||||
st := seqTest{seq: []byte(seq), Events: []Event{k}}
|
||||
|
||||
// XXX: This is a special case to handle F3 key sequence and cursor
|
||||
// position report having the same sequence. See [parseCsi] for more
|
||||
// information.
|
||||
if f3CurPosRegexp.MatchString(seq) {
|
||||
st.Events = []Event{k, CursorPositionEvent{Y: 0, X: int(key.Mod)}}
|
||||
}
|
||||
td = append(td, st)
|
||||
}
|
||||
|
||||
// Additional special cases.
|
||||
td = append(td,
|
||||
// Unrecognized CSI sequence.
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
|
||||
[]Event{
|
||||
UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}),
|
||||
},
|
||||
},
|
||||
// A lone space character.
|
||||
seqTest{
|
||||
[]byte{' '},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Text: " "},
|
||||
},
|
||||
},
|
||||
// An escape character with the alt modifier.
|
||||
seqTest{
|
||||
[]byte{'\x1b', ' '},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
)
|
||||
return td
|
||||
}
|
||||
|
||||
func TestParseSequence(t *testing.T) {
|
||||
td := buildBaseSeqTests()
|
||||
td = append(td,
|
||||
// Background color.
|
||||
seqTest{
|
||||
[]byte("\x1b]11;rgb:1234/1234/1234\x07"),
|
||||
[]Event{BackgroundColorEvent{
|
||||
Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b]11;rgb:1234/1234/1234\x1b\\"),
|
||||
[]Event{BackgroundColorEvent{
|
||||
Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b]11;rgb:1234/1234/1234\x1b"), // Incomplete sequences are ignored.
|
||||
[]Event{
|
||||
UnknownEvent("\x1b]11;rgb:1234/1234/1234\x1b"),
|
||||
},
|
||||
},
|
||||
|
||||
// Kitty Graphics response.
|
||||
seqTest{
|
||||
[]byte("\x1b_Ga=t;OK\x1b\\"),
|
||||
[]Event{KittyGraphicsEvent{
|
||||
Options: kitty.Options{Action: kitty.Transmit},
|
||||
Payload: []byte("OK"),
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b_Gi=99,I=13;OK\x1b\\"),
|
||||
[]Event{KittyGraphicsEvent{
|
||||
Options: kitty.Options{ID: 99, Number: 13},
|
||||
Payload: []byte("OK"),
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b_Gi=1337,q=1;EINVAL:your face\x1b\\"),
|
||||
[]Event{KittyGraphicsEvent{
|
||||
Options: kitty.Options{ID: 1337, Quite: 1},
|
||||
Payload: []byte("EINVAL:your face"),
|
||||
}},
|
||||
},
|
||||
|
||||
// Xterm modifyOtherKeys CSI 27 ; <modifier> ; <code> ~
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;20320~"),
|
||||
[]Event{KeyPressEvent{Code: '你', Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;65~"),
|
||||
[]Event{KeyPressEvent{Code: 'A', Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;8~"),
|
||||
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;27~"),
|
||||
[]Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;127~"),
|
||||
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
|
||||
},
|
||||
|
||||
// Xterm report window text area size.
|
||||
seqTest{
|
||||
[]byte("\x1b[4;24;80t"),
|
||||
[]Event{
|
||||
WindowOpEvent{Op: 4, Args: []int{24, 80}},
|
||||
},
|
||||
},
|
||||
|
||||
// Kitty keyboard / CSI u (fixterms)
|
||||
seqTest{
|
||||
[]byte("\x1b[1B"),
|
||||
[]Event{KeyPressEvent{Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;B"),
|
||||
[]Event{KeyPressEvent{Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4B"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4:1B"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4:2B"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown, IsRepeat: true}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4:3B"),
|
||||
[]Event{KeyReleaseEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[8~"),
|
||||
[]Event{KeyPressEvent{Code: KeyEnd}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[8;~"),
|
||||
[]Event{KeyPressEvent{Code: KeyEnd}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[8;10~"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModMeta, Code: KeyEnd}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;4u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyEscape}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[127;4u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyBackspace}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[57358;4u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyCapsLock}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[9;2u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift, Code: KeyTab}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;u"),
|
||||
[]Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[20320;2u"),
|
||||
[]Event{KeyPressEvent{Text: "你", Mod: ModShift, Code: '你'}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;:1u"),
|
||||
[]Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:3u"),
|
||||
[]Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:2u"),
|
||||
[]Event{KeyPressEvent{Code: 'Ã', Text: "Ã", IsRepeat: true, Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:1u"),
|
||||
[]Event{KeyPressEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:3u"),
|
||||
[]Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[97;2;65u"),
|
||||
[]Event{KeyPressEvent{Code: 'a', Text: "A", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[97;;229u"),
|
||||
[]Event{KeyPressEvent{Code: 'a', Text: "å"}},
|
||||
},
|
||||
|
||||
// focus/blur
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', 'I'},
|
||||
[]Event{
|
||||
FocusEvent{},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', 'O'},
|
||||
[]Event{
|
||||
BlurEvent{},
|
||||
},
|
||||
},
|
||||
// Mouse event.
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
|
||||
[]Event{
|
||||
MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
},
|
||||
// SGR Mouse event.
|
||||
seqTest{
|
||||
[]byte("\x1b[<0;33;17M"),
|
||||
[]Event{
|
||||
MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
},
|
||||
// Runes.
|
||||
seqTest{
|
||||
[]byte{'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'a', 'a', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
// Multi-byte rune.
|
||||
seqTest{
|
||||
[]byte("☃"),
|
||||
[]Event{
|
||||
KeyPressEvent{Code: '☃', Text: "☃"},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b☃"),
|
||||
[]Event{
|
||||
KeyPressEvent{Code: '☃', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
// Standalone control characters.
|
||||
seqTest{
|
||||
[]byte{'\x1b'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyEscape},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{ansi.SOH},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', ansi.SOH},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{ansi.NUL},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', ansi.NUL},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
// C1 control characters.
|
||||
seqTest{
|
||||
[]byte{'\x80'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: rune(0x80 - '@'), Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
// Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows.
|
||||
// This is incorrect, but it makes our test fail if we try it out.
|
||||
td = append(td, seqTest{
|
||||
[]byte{'\xfe'},
|
||||
[]Event{
|
||||
UnknownEvent(rune(0xfe)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var p Parser
|
||||
for _, tc := range td {
|
||||
t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
|
||||
var events []Event
|
||||
buf := tc.seq
|
||||
for len(buf) > 0 {
|
||||
width, Event := p.parseSequence(buf)
|
||||
switch Event := Event.(type) {
|
||||
case MultiEvent:
|
||||
events = append(events, Event...)
|
||||
default:
|
||||
events = append(events, Event)
|
||||
}
|
||||
buf = buf[width:]
|
||||
}
|
||||
if !reflect.DeepEqual(tc.Events, events) {
|
||||
t.Errorf("\nexpected event for %q:\n %#v\ngot:\n %#v", tc.seq, tc.Events, events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLongInput(t *testing.T) {
|
||||
expect := make([]Event, 1000)
|
||||
for i := range 1000 {
|
||||
expect[i] = KeyPressEvent{Code: 'a', Text: "a"}
|
||||
}
|
||||
input := strings.Repeat("a", 1000)
|
||||
drv, err := NewReader(strings.NewReader(input), "dumb", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected input driver error: %v", err)
|
||||
}
|
||||
|
||||
var Events []Event
|
||||
for {
|
||||
events, err := drv.ReadEvents()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected input error: %v", err)
|
||||
}
|
||||
Events = append(Events, events...)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(expect, Events) {
|
||||
t.Errorf("unexpected messages, expected:\n %+v\ngot:\n %+v", expect, Events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadInput(t *testing.T) {
|
||||
type test struct {
|
||||
keyname string
|
||||
in []byte
|
||||
out []Event
|
||||
}
|
||||
testData := []test{
|
||||
{
|
||||
"a",
|
||||
[]byte{'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"space",
|
||||
[]byte{' '},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Text: " "},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a alt+a",
|
||||
[]byte{'a', '\x1b', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a alt+a a",
|
||||
[]byte{'a', '\x1b', 'a', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ctrl+a",
|
||||
[]byte{byte(ansi.SOH)},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ctrl+a ctrl+b",
|
||||
[]byte{byte(ansi.SOH), byte(ansi.STX)},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl},
|
||||
KeyPressEvent{Code: 'b', Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
{
|
||||
"alt+a",
|
||||
[]byte{byte(0x1b), 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a b c d",
|
||||
[]byte{'a', 'b', 'c', 'd'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'b', Text: "b"},
|
||||
KeyPressEvent{Code: 'c', Text: "c"},
|
||||
KeyPressEvent{Code: 'd', Text: "d"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"up",
|
||||
[]byte("\x1b[A"),
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyUp},
|
||||
},
|
||||
},
|
||||
{
|
||||
"wheel up",
|
||||
[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
|
||||
[]Event{
|
||||
MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
},
|
||||
{
|
||||
"left motion release",
|
||||
[]byte{
|
||||
'\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33),
|
||||
'\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33),
|
||||
},
|
||||
[]Event{
|
||||
MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
MouseReleaseEvent{X: 64, Y: 32, Button: MouseNone},
|
||||
},
|
||||
},
|
||||
{
|
||||
"shift+tab",
|
||||
[]byte{'\x1b', '[', 'Z'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyTab, Mod: ModShift},
|
||||
},
|
||||
},
|
||||
{
|
||||
"enter",
|
||||
[]byte{'\r'},
|
||||
[]Event{KeyPressEvent{Code: KeyEnter}},
|
||||
},
|
||||
{
|
||||
"alt+enter",
|
||||
[]byte{'\x1b', '\r'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyEnter, Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"insert",
|
||||
[]byte{'\x1b', '[', '2', '~'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyInsert},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ctrl+alt+a",
|
||||
[]byte{'\x1b', byte(ansi.SOH)},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"CSI?----X?",
|
||||
[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
|
||||
[]Event{UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})},
|
||||
},
|
||||
// Powershell sequences.
|
||||
{
|
||||
"up",
|
||||
[]byte{'\x1b', 'O', 'A'},
|
||||
[]Event{KeyPressEvent{Code: KeyUp}},
|
||||
},
|
||||
{
|
||||
"down",
|
||||
[]byte{'\x1b', 'O', 'B'},
|
||||
[]Event{KeyPressEvent{Code: KeyDown}},
|
||||
},
|
||||
{
|
||||
"right",
|
||||
[]byte{'\x1b', 'O', 'C'},
|
||||
[]Event{KeyPressEvent{Code: KeyRight}},
|
||||
},
|
||||
{
|
||||
"left",
|
||||
[]byte{'\x1b', 'O', 'D'},
|
||||
[]Event{KeyPressEvent{Code: KeyLeft}},
|
||||
},
|
||||
{
|
||||
"alt+enter",
|
||||
[]byte{'\x1b', '\x0d'},
|
||||
[]Event{KeyPressEvent{Code: KeyEnter, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
"alt+backspace",
|
||||
[]byte{'\x1b', '\x7f'},
|
||||
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
"ctrl+space",
|
||||
[]byte{'\x00'},
|
||||
[]Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl}},
|
||||
},
|
||||
{
|
||||
"ctrl+alt+space",
|
||||
[]byte{'\x1b', '\x00'},
|
||||
[]Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt}},
|
||||
},
|
||||
{
|
||||
"esc",
|
||||
[]byte{'\x1b'},
|
||||
[]Event{KeyPressEvent{Code: KeyEscape}},
|
||||
},
|
||||
{
|
||||
"alt+esc",
|
||||
[]byte{'\x1b', '\x1b'},
|
||||
[]Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
"a b o",
|
||||
[]byte{
|
||||
'\x1b', '[', '2', '0', '0', '~',
|
||||
'a', ' ', 'b',
|
||||
'\x1b', '[', '2', '0', '1', '~',
|
||||
'o',
|
||||
},
|
||||
[]Event{
|
||||
PasteStartEvent{},
|
||||
PasteEvent("a b"),
|
||||
PasteEndEvent{},
|
||||
KeyPressEvent{Code: 'o', Text: "o"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a\x03\nb",
|
||||
[]byte{
|
||||
'\x1b', '[', '2', '0', '0', '~',
|
||||
'a', '\x03', '\n', 'b',
|
||||
'\x1b', '[', '2', '0', '1', '~',
|
||||
},
|
||||
[]Event{
|
||||
PasteStartEvent{},
|
||||
PasteEvent("a\x03\nb"),
|
||||
PasteEndEvent{},
|
||||
},
|
||||
},
|
||||
{
|
||||
"?0xfe?",
|
||||
[]byte{'\xfe'},
|
||||
[]Event{
|
||||
UnknownEvent(rune(0xfe)),
|
||||
},
|
||||
},
|
||||
{
|
||||
"a ?0xfe? b",
|
||||
[]byte{'a', '\xfe', ' ', 'b'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
UnknownEvent(rune(0xfe)),
|
||||
KeyPressEvent{Code: KeySpace, Text: " "},
|
||||
KeyPressEvent{Code: 'b', Text: "b"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, td := range testData {
|
||||
t.Run(fmt.Sprintf("%d: %s", i, td.keyname), func(t *testing.T) {
|
||||
Events := testReadInputs(t, bytes.NewReader(td.in))
|
||||
var buf strings.Builder
|
||||
for i, Event := range Events {
|
||||
if i > 0 {
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
if s, ok := Event.(fmt.Stringer); ok {
|
||||
buf.WriteString(s.String())
|
||||
} else {
|
||||
fmt.Fprintf(&buf, "%#v:%T", Event, Event)
|
||||
}
|
||||
}
|
||||
|
||||
if len(Events) != len(td.out) {
|
||||
t.Fatalf("unexpected message list length: got %d, expected %d\n got: %#v\n expected: %#v\n", len(Events), len(td.out), Events, td.out)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(td.out, Events) {
|
||||
t.Fatalf("expected:\n%#v\ngot:\n%#v", td.out, Events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testReadInputs(t *testing.T, input io.Reader) []Event {
|
||||
// We'll check that the input reader finishes at the end
|
||||
// without error.
|
||||
var wg sync.WaitGroup
|
||||
var inputErr error
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
cancel()
|
||||
wg.Wait()
|
||||
if inputErr != nil && !errors.Is(inputErr, io.EOF) {
|
||||
t.Fatalf("unexpected input error: %v", inputErr)
|
||||
}
|
||||
}()
|
||||
|
||||
dr, err := NewReader(input, "dumb", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected input driver error: %v", err)
|
||||
}
|
||||
|
||||
// The messages we're consuming.
|
||||
EventsC := make(chan Event)
|
||||
|
||||
// Start the reader in the background.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var events []Event
|
||||
events, inputErr = dr.ReadEvents()
|
||||
out:
|
||||
for _, ev := range events {
|
||||
select {
|
||||
case EventsC <- ev:
|
||||
case <-ctx.Done():
|
||||
break out
|
||||
}
|
||||
}
|
||||
EventsC <- nil
|
||||
}()
|
||||
|
||||
var Events []Event
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case Event := <-EventsC:
|
||||
if Event == nil {
|
||||
// end of input marker for the test.
|
||||
break loop
|
||||
}
|
||||
Events = append(Events, Event)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Errorf("timeout waiting for input event")
|
||||
break loop
|
||||
}
|
||||
}
|
||||
return Events
|
||||
}
|
||||
|
||||
// randTest defines the test input and expected output for a sequence
|
||||
// of interleaved control sequences and control characters.
|
||||
type randTest struct {
|
||||
data []byte
|
||||
lengths []int
|
||||
names []string
|
||||
}
|
||||
|
||||
// seed is the random seed to randomize the input. This helps check
|
||||
// that all the sequences get ultimately exercised.
|
||||
var seed = flag.Int64("seed", 0, "random seed (0 to autoselect)")
|
||||
|
||||
// genRandomData generates a randomized test, with a random seed unless
|
||||
// the seed flag was set.
|
||||
func genRandomData(logfn func(int64), length int) randTest {
|
||||
// We'll use a random source. However, we give the user the option
|
||||
// to override it to a specific value for reproduceability.
|
||||
s := *seed
|
||||
if s == 0 {
|
||||
s = time.Now().UnixNano()
|
||||
}
|
||||
// Inform the user so they know what to reuse to get the same data.
|
||||
logfn(s)
|
||||
return genRandomDataWithSeed(s, length)
|
||||
}
|
||||
|
||||
// genRandomDataWithSeed generates a randomized test with a fixed seed.
|
||||
func genRandomDataWithSeed(s int64, length int) randTest {
|
||||
src := rand.NewSource(s)
|
||||
r := rand.New(src)
|
||||
|
||||
// allseqs contains all the sequences, in sorted order. We sort
|
||||
// to make the test deterministic (when the seed is also fixed).
|
||||
type seqpair struct {
|
||||
seq string
|
||||
name string
|
||||
}
|
||||
var allseqs []seqpair
|
||||
for seq, key := range sequences {
|
||||
allseqs = append(allseqs, seqpair{seq, key.String()})
|
||||
}
|
||||
sort.Slice(allseqs, func(i, j int) bool { return allseqs[i].seq < allseqs[j].seq })
|
||||
|
||||
// res contains the computed test.
|
||||
var res randTest
|
||||
|
||||
for len(res.data) < length {
|
||||
alt := r.Intn(2)
|
||||
prefix := ""
|
||||
esclen := 0
|
||||
if alt == 1 {
|
||||
prefix = "alt+"
|
||||
esclen = 1
|
||||
}
|
||||
kind := r.Intn(3)
|
||||
switch kind {
|
||||
case 0:
|
||||
// A control character.
|
||||
if alt == 1 {
|
||||
res.data = append(res.data, '\x1b')
|
||||
}
|
||||
res.data = append(res.data, 1)
|
||||
res.names = append(res.names, "ctrl+"+prefix+"a")
|
||||
res.lengths = append(res.lengths, 1+esclen)
|
||||
|
||||
case 1, 2:
|
||||
// A sequence.
|
||||
seqi := r.Intn(len(allseqs))
|
||||
s := allseqs[seqi]
|
||||
if strings.Contains(s.name, "alt+") || strings.Contains(s.name, "meta+") {
|
||||
esclen = 0
|
||||
prefix = ""
|
||||
alt = 0
|
||||
}
|
||||
if alt == 1 {
|
||||
res.data = append(res.data, '\x1b')
|
||||
}
|
||||
res.data = append(res.data, s.seq...)
|
||||
if strings.HasPrefix(s.name, "ctrl+") {
|
||||
prefix = "ctrl+" + prefix
|
||||
}
|
||||
name := prefix + strings.TrimPrefix(s.name, "ctrl+")
|
||||
res.names = append(res.names, name)
|
||||
res.lengths = append(res.lengths, len(s.seq)+esclen)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func FuzzParseSequence(f *testing.F) {
|
||||
var p Parser
|
||||
for seq := range sequences {
|
||||
f.Add(seq)
|
||||
}
|
||||
f.Add("\x1b]52;?\x07") // OSC 52
|
||||
f.Add("\x1b]11;rgb:0000/0000/0000\x1b\\") // OSC 11
|
||||
f.Add("\x1bP>|charm terminal(0.1.2)\x1b\\") // DCS (XTVERSION)
|
||||
f.Add("\x1b_Gi=123\x1b\\") // APC
|
||||
f.Fuzz(func(t *testing.T, seq string) {
|
||||
n, _ := p.parseSequence([]byte(seq))
|
||||
if n == 0 && seq != "" {
|
||||
t.Errorf("expected a non-zero width for %q", seq)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkDetectSequenceMap benchmarks the map-based sequence
|
||||
// detector.
|
||||
func BenchmarkDetectSequenceMap(b *testing.B) {
|
||||
var p Parser
|
||||
td := genRandomDataWithSeed(123, 10000)
|
||||
for i := 0; i < b.N; i++ {
|
||||
for j, w := 0, 0; j < len(td.data); j += w {
|
||||
w, _ = p.parseSequence(td.data[j:])
|
||||
}
|
||||
}
|
||||
}
|
||||
353
packages/client/tui/input/kitty.go
Normal file
353
packages/client/tui/input/kitty.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/ansi/kitty"
|
||||
)
|
||||
|
||||
// KittyGraphicsEvent represents a Kitty Graphics response event.
|
||||
//
|
||||
// See https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
||||
type KittyGraphicsEvent struct {
|
||||
Options kitty.Options
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// KittyEnhancementsEvent represents a Kitty enhancements event.
|
||||
type KittyEnhancementsEvent int
|
||||
|
||||
// Kitty keyboard enhancement constants.
|
||||
// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
|
||||
const (
|
||||
KittyDisambiguateEscapeCodes KittyEnhancementsEvent = 1 << iota
|
||||
KittyReportEventTypes
|
||||
KittyReportAlternateKeys
|
||||
KittyReportAllKeysAsEscapeCodes
|
||||
KittyReportAssociatedText
|
||||
)
|
||||
|
||||
// Contains reports whether m contains the given enhancements.
|
||||
func (e KittyEnhancementsEvent) Contains(enhancements KittyEnhancementsEvent) bool {
|
||||
return e&enhancements == enhancements
|
||||
}
|
||||
|
||||
// Kitty Clipboard Control Sequences.
|
||||
var kittyKeyMap = map[int]Key{
|
||||
ansi.BS: {Code: KeyBackspace},
|
||||
ansi.HT: {Code: KeyTab},
|
||||
ansi.CR: {Code: KeyEnter},
|
||||
ansi.ESC: {Code: KeyEscape},
|
||||
ansi.DEL: {Code: KeyBackspace},
|
||||
|
||||
57344: {Code: KeyEscape},
|
||||
57345: {Code: KeyEnter},
|
||||
57346: {Code: KeyTab},
|
||||
57347: {Code: KeyBackspace},
|
||||
57348: {Code: KeyInsert},
|
||||
57349: {Code: KeyDelete},
|
||||
57350: {Code: KeyLeft},
|
||||
57351: {Code: KeyRight},
|
||||
57352: {Code: KeyUp},
|
||||
57353: {Code: KeyDown},
|
||||
57354: {Code: KeyPgUp},
|
||||
57355: {Code: KeyPgDown},
|
||||
57356: {Code: KeyHome},
|
||||
57357: {Code: KeyEnd},
|
||||
57358: {Code: KeyCapsLock},
|
||||
57359: {Code: KeyScrollLock},
|
||||
57360: {Code: KeyNumLock},
|
||||
57361: {Code: KeyPrintScreen},
|
||||
57362: {Code: KeyPause},
|
||||
57363: {Code: KeyMenu},
|
||||
57364: {Code: KeyF1},
|
||||
57365: {Code: KeyF2},
|
||||
57366: {Code: KeyF3},
|
||||
57367: {Code: KeyF4},
|
||||
57368: {Code: KeyF5},
|
||||
57369: {Code: KeyF6},
|
||||
57370: {Code: KeyF7},
|
||||
57371: {Code: KeyF8},
|
||||
57372: {Code: KeyF9},
|
||||
57373: {Code: KeyF10},
|
||||
57374: {Code: KeyF11},
|
||||
57375: {Code: KeyF12},
|
||||
57376: {Code: KeyF13},
|
||||
57377: {Code: KeyF14},
|
||||
57378: {Code: KeyF15},
|
||||
57379: {Code: KeyF16},
|
||||
57380: {Code: KeyF17},
|
||||
57381: {Code: KeyF18},
|
||||
57382: {Code: KeyF19},
|
||||
57383: {Code: KeyF20},
|
||||
57384: {Code: KeyF21},
|
||||
57385: {Code: KeyF22},
|
||||
57386: {Code: KeyF23},
|
||||
57387: {Code: KeyF24},
|
||||
57388: {Code: KeyF25},
|
||||
57389: {Code: KeyF26},
|
||||
57390: {Code: KeyF27},
|
||||
57391: {Code: KeyF28},
|
||||
57392: {Code: KeyF29},
|
||||
57393: {Code: KeyF30},
|
||||
57394: {Code: KeyF31},
|
||||
57395: {Code: KeyF32},
|
||||
57396: {Code: KeyF33},
|
||||
57397: {Code: KeyF34},
|
||||
57398: {Code: KeyF35},
|
||||
57399: {Code: KeyKp0},
|
||||
57400: {Code: KeyKp1},
|
||||
57401: {Code: KeyKp2},
|
||||
57402: {Code: KeyKp3},
|
||||
57403: {Code: KeyKp4},
|
||||
57404: {Code: KeyKp5},
|
||||
57405: {Code: KeyKp6},
|
||||
57406: {Code: KeyKp7},
|
||||
57407: {Code: KeyKp8},
|
||||
57408: {Code: KeyKp9},
|
||||
57409: {Code: KeyKpDecimal},
|
||||
57410: {Code: KeyKpDivide},
|
||||
57411: {Code: KeyKpMultiply},
|
||||
57412: {Code: KeyKpMinus},
|
||||
57413: {Code: KeyKpPlus},
|
||||
57414: {Code: KeyKpEnter},
|
||||
57415: {Code: KeyKpEqual},
|
||||
57416: {Code: KeyKpSep},
|
||||
57417: {Code: KeyKpLeft},
|
||||
57418: {Code: KeyKpRight},
|
||||
57419: {Code: KeyKpUp},
|
||||
57420: {Code: KeyKpDown},
|
||||
57421: {Code: KeyKpPgUp},
|
||||
57422: {Code: KeyKpPgDown},
|
||||
57423: {Code: KeyKpHome},
|
||||
57424: {Code: KeyKpEnd},
|
||||
57425: {Code: KeyKpInsert},
|
||||
57426: {Code: KeyKpDelete},
|
||||
57427: {Code: KeyKpBegin},
|
||||
57428: {Code: KeyMediaPlay},
|
||||
57429: {Code: KeyMediaPause},
|
||||
57430: {Code: KeyMediaPlayPause},
|
||||
57431: {Code: KeyMediaReverse},
|
||||
57432: {Code: KeyMediaStop},
|
||||
57433: {Code: KeyMediaFastForward},
|
||||
57434: {Code: KeyMediaRewind},
|
||||
57435: {Code: KeyMediaNext},
|
||||
57436: {Code: KeyMediaPrev},
|
||||
57437: {Code: KeyMediaRecord},
|
||||
57438: {Code: KeyLowerVol},
|
||||
57439: {Code: KeyRaiseVol},
|
||||
57440: {Code: KeyMute},
|
||||
57441: {Code: KeyLeftShift},
|
||||
57442: {Code: KeyLeftCtrl},
|
||||
57443: {Code: KeyLeftAlt},
|
||||
57444: {Code: KeyLeftSuper},
|
||||
57445: {Code: KeyLeftHyper},
|
||||
57446: {Code: KeyLeftMeta},
|
||||
57447: {Code: KeyRightShift},
|
||||
57448: {Code: KeyRightCtrl},
|
||||
57449: {Code: KeyRightAlt},
|
||||
57450: {Code: KeyRightSuper},
|
||||
57451: {Code: KeyRightHyper},
|
||||
57452: {Code: KeyRightMeta},
|
||||
57453: {Code: KeyIsoLevel3Shift},
|
||||
57454: {Code: KeyIsoLevel5Shift},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// These are some faulty C0 mappings some terminals such as WezTerm have
|
||||
// and doesn't follow the specs.
|
||||
kittyKeyMap[ansi.NUL] = Key{Code: KeySpace, Mod: ModCtrl}
|
||||
for i := ansi.SOH; i <= ansi.SUB; i++ {
|
||||
if _, ok := kittyKeyMap[i]; !ok {
|
||||
kittyKeyMap[i] = Key{Code: rune(i + 0x60), Mod: ModCtrl}
|
||||
}
|
||||
}
|
||||
for i := ansi.FS; i <= ansi.US; i++ {
|
||||
if _, ok := kittyKeyMap[i]; !ok {
|
||||
kittyKeyMap[i] = Key{Code: rune(i + 0x40), Mod: ModCtrl}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
kittyShift = 1 << iota
|
||||
kittyAlt
|
||||
kittyCtrl
|
||||
kittySuper
|
||||
kittyHyper
|
||||
kittyMeta
|
||||
kittyCapsLock
|
||||
kittyNumLock
|
||||
)
|
||||
|
||||
func fromKittyMod(mod int) KeyMod {
|
||||
var m KeyMod
|
||||
if mod&kittyShift != 0 {
|
||||
m |= ModShift
|
||||
}
|
||||
if mod&kittyAlt != 0 {
|
||||
m |= ModAlt
|
||||
}
|
||||
if mod&kittyCtrl != 0 {
|
||||
m |= ModCtrl
|
||||
}
|
||||
if mod&kittySuper != 0 {
|
||||
m |= ModSuper
|
||||
}
|
||||
if mod&kittyHyper != 0 {
|
||||
m |= ModHyper
|
||||
}
|
||||
if mod&kittyMeta != 0 {
|
||||
m |= ModMeta
|
||||
}
|
||||
if mod&kittyCapsLock != 0 {
|
||||
m |= ModCapsLock
|
||||
}
|
||||
if mod&kittyNumLock != 0 {
|
||||
m |= ModNumLock
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseKittyKeyboard parses a Kitty Keyboard Protocol sequence.
|
||||
//
|
||||
// In `CSI u`, this is parsed as:
|
||||
//
|
||||
// CSI codepoint ; modifiers u
|
||||
// codepoint: ASCII Dec value
|
||||
//
|
||||
// The Kitty Keyboard Protocol extends this with optional components that can be
|
||||
// enabled progressively. The full sequence is parsed as:
|
||||
//
|
||||
// CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u
|
||||
//
|
||||
// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
||||
func parseKittyKeyboard(params ansi.Params) (Event Event) {
|
||||
var isRelease bool
|
||||
var key Key
|
||||
|
||||
// The index of parameters separated by semicolons ';'. Sub parameters are
|
||||
// separated by colons ':'.
|
||||
var paramIdx int
|
||||
var sudIdx int // The sub parameter index
|
||||
for _, p := range params {
|
||||
// Kitty Keyboard Protocol has 3 optional components.
|
||||
switch paramIdx {
|
||||
case 0:
|
||||
switch sudIdx {
|
||||
case 0:
|
||||
var foundKey bool
|
||||
code := p.Param(1) // CSI u has a default value of 1
|
||||
key, foundKey = kittyKeyMap[code]
|
||||
if !foundKey {
|
||||
r := rune(code)
|
||||
if !utf8.ValidRune(r) {
|
||||
r = utf8.RuneError
|
||||
}
|
||||
|
||||
key.Code = r
|
||||
}
|
||||
|
||||
case 2:
|
||||
// shifted key + base key
|
||||
if b := rune(p.Param(1)); unicode.IsPrint(b) {
|
||||
// XXX: When alternate key reporting is enabled, the protocol
|
||||
// can return 3 things, the unicode codepoint of the key,
|
||||
// the shifted codepoint of the key, and the standard
|
||||
// PC-101 key layout codepoint.
|
||||
// This is useful to create an unambiguous mapping of keys
|
||||
// when using a different language layout.
|
||||
key.BaseCode = b
|
||||
}
|
||||
fallthrough
|
||||
|
||||
case 1:
|
||||
// shifted key
|
||||
if s := rune(p.Param(1)); unicode.IsPrint(s) {
|
||||
// XXX: We swap keys here because we want the shifted key
|
||||
// to be the Rune that is returned by the event.
|
||||
// For example, shift+a should produce "A" not "a".
|
||||
// In such a case, we set AltRune to the original key "a"
|
||||
// and Rune to "A".
|
||||
key.ShiftedCode = s
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
switch sudIdx {
|
||||
case 0:
|
||||
mod := p.Param(1)
|
||||
if mod > 1 {
|
||||
key.Mod = fromKittyMod(mod - 1)
|
||||
if key.Mod > ModShift {
|
||||
// XXX: We need to clear the text if we have a modifier key
|
||||
// other than a [ModShift] key.
|
||||
key.Text = ""
|
||||
}
|
||||
}
|
||||
|
||||
case 1:
|
||||
switch p.Param(1) {
|
||||
case 2:
|
||||
key.IsRepeat = true
|
||||
case 3:
|
||||
isRelease = true
|
||||
}
|
||||
case 2:
|
||||
}
|
||||
case 2:
|
||||
if code := p.Param(0); code != 0 {
|
||||
key.Text += string(rune(code))
|
||||
}
|
||||
}
|
||||
|
||||
sudIdx++
|
||||
if !p.HasMore() {
|
||||
paramIdx++
|
||||
sudIdx = 0
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:nestif
|
||||
if len(key.Text) == 0 && unicode.IsPrint(key.Code) &&
|
||||
(key.Mod <= ModShift || key.Mod == ModCapsLock || key.Mod == ModShift|ModCapsLock) {
|
||||
if key.Mod == 0 {
|
||||
key.Text = string(key.Code)
|
||||
} else {
|
||||
desiredCase := unicode.ToLower
|
||||
if key.Mod.Contains(ModShift) || key.Mod.Contains(ModCapsLock) {
|
||||
desiredCase = unicode.ToUpper
|
||||
}
|
||||
if key.ShiftedCode != 0 {
|
||||
key.Text = string(key.ShiftedCode)
|
||||
} else {
|
||||
key.Text = string(desiredCase(key.Code))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isRelease {
|
||||
return KeyReleaseEvent(key)
|
||||
}
|
||||
|
||||
return KeyPressEvent(key)
|
||||
}
|
||||
|
||||
// parseKittyKeyboardExt parses a Kitty Keyboard Protocol sequence extensions
|
||||
// for non CSI u sequences. This includes things like CSI A, SS3 A and others,
|
||||
// and CSI ~.
|
||||
func parseKittyKeyboardExt(params ansi.Params, k KeyPressEvent) Event {
|
||||
// Handle Kitty keyboard protocol
|
||||
if len(params) > 2 && // We have at least 3 parameters
|
||||
params[0].Param(1) == 1 && // The first parameter is 1 (defaults to 1)
|
||||
params[1].HasMore() { // The second parameter is a subparameter (separated by a ":")
|
||||
switch params[2].Param(1) { // The third parameter is the event type (defaults to 1)
|
||||
case 2:
|
||||
k.IsRepeat = true
|
||||
case 3:
|
||||
return KeyReleaseEvent(k)
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
37
packages/client/tui/input/mod.go
Normal file
37
packages/client/tui/input/mod.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package input
|
||||
|
||||
// KeyMod represents modifier keys.
|
||||
type KeyMod int
|
||||
|
||||
// Modifier keys.
|
||||
const (
|
||||
ModShift KeyMod = 1 << iota
|
||||
ModAlt
|
||||
ModCtrl
|
||||
ModMeta
|
||||
|
||||
// These modifiers are used with the Kitty protocol.
|
||||
// XXX: Meta and Super are swapped in the Kitty protocol,
|
||||
// this is to preserve compatibility with XTerm modifiers.
|
||||
|
||||
ModHyper
|
||||
ModSuper // Windows/Command keys
|
||||
|
||||
// These are key lock states.
|
||||
|
||||
ModCapsLock
|
||||
ModNumLock
|
||||
ModScrollLock // Defined in Windows API only
|
||||
)
|
||||
|
||||
// Contains reports whether m contains the given modifiers.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// m := ModAlt | ModCtrl
|
||||
// m.Contains(ModCtrl) // true
|
||||
// m.Contains(ModAlt | ModCtrl) // true
|
||||
// m.Contains(ModAlt | ModCtrl | ModShift) // false
|
||||
func (m KeyMod) Contains(mods KeyMod) bool {
|
||||
return m&mods == mods
|
||||
}
|
||||
14
packages/client/tui/input/mode.go
Normal file
14
packages/client/tui/input/mode.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package input
|
||||
|
||||
import "github.com/charmbracelet/x/ansi"
|
||||
|
||||
// ModeReportEvent is a message that represents a mode report event (DECRPM).
|
||||
//
|
||||
// See: https://vt100.net/docs/vt510-rm/DECRPM.html
|
||||
type ModeReportEvent struct {
|
||||
// Mode is the mode number.
|
||||
Mode ansi.Mode
|
||||
|
||||
// Value is the mode value.
|
||||
Value ansi.ModeSetting
|
||||
}
|
||||
292
packages/client/tui/input/mouse.go
Normal file
292
packages/client/tui/input/mouse.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// MouseButton represents the button that was pressed during a mouse message.
|
||||
type MouseButton = ansi.MouseButton
|
||||
|
||||
// Mouse event buttons
|
||||
//
|
||||
// This is based on X11 mouse button codes.
|
||||
//
|
||||
// 1 = left button
|
||||
// 2 = middle button (pressing the scroll wheel)
|
||||
// 3 = right button
|
||||
// 4 = turn scroll wheel up
|
||||
// 5 = turn scroll wheel down
|
||||
// 6 = push scroll wheel left
|
||||
// 7 = push scroll wheel right
|
||||
// 8 = 4th button (aka browser backward button)
|
||||
// 9 = 5th button (aka browser forward button)
|
||||
// 10
|
||||
// 11
|
||||
//
|
||||
// Other buttons are not supported.
|
||||
const (
|
||||
MouseNone = ansi.MouseNone
|
||||
MouseLeft = ansi.MouseLeft
|
||||
MouseMiddle = ansi.MouseMiddle
|
||||
MouseRight = ansi.MouseRight
|
||||
MouseWheelUp = ansi.MouseWheelUp
|
||||
MouseWheelDown = ansi.MouseWheelDown
|
||||
MouseWheelLeft = ansi.MouseWheelLeft
|
||||
MouseWheelRight = ansi.MouseWheelRight
|
||||
MouseBackward = ansi.MouseBackward
|
||||
MouseForward = ansi.MouseForward
|
||||
MouseButton10 = ansi.MouseButton10
|
||||
MouseButton11 = ansi.MouseButton11
|
||||
)
|
||||
|
||||
// MouseEvent represents a mouse message. This is a generic mouse message that
|
||||
// can represent any kind of mouse event.
|
||||
type MouseEvent interface {
|
||||
fmt.Stringer
|
||||
|
||||
// Mouse returns the underlying mouse event.
|
||||
Mouse() Mouse
|
||||
}
|
||||
|
||||
// Mouse represents a Mouse message. Use [MouseEvent] to represent all mouse
|
||||
// messages.
|
||||
//
|
||||
// The X and Y coordinates are zero-based, with (0,0) being the upper left
|
||||
// corner of the terminal.
|
||||
//
|
||||
// // Catch all mouse events
|
||||
// switch Event := Event.(type) {
|
||||
// case MouseEvent:
|
||||
// m := Event.Mouse()
|
||||
// fmt.Println("Mouse event:", m.X, m.Y, m)
|
||||
// }
|
||||
//
|
||||
// // Only catch mouse click events
|
||||
// switch Event := Event.(type) {
|
||||
// case MouseClickEvent:
|
||||
// fmt.Println("Mouse click event:", Event.X, Event.Y, Event)
|
||||
// }
|
||||
type Mouse struct {
|
||||
X, Y int
|
||||
Button MouseButton
|
||||
Mod KeyMod
|
||||
}
|
||||
|
||||
// String returns a string representation of the mouse message.
|
||||
func (m Mouse) String() (s string) {
|
||||
if m.Mod.Contains(ModCtrl) {
|
||||
s += "ctrl+"
|
||||
}
|
||||
if m.Mod.Contains(ModAlt) {
|
||||
s += "alt+"
|
||||
}
|
||||
if m.Mod.Contains(ModShift) {
|
||||
s += "shift+"
|
||||
}
|
||||
|
||||
str := m.Button.String()
|
||||
if str == "" {
|
||||
s += "unknown"
|
||||
} else if str != "none" { // motion events don't have a button
|
||||
s += str
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// MouseClickEvent represents a mouse button click event.
|
||||
type MouseClickEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse click event.
|
||||
func (e MouseClickEvent) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseClickEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseReleaseEvent represents a mouse button release event.
|
||||
type MouseReleaseEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse release event.
|
||||
func (e MouseReleaseEvent) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseReleaseEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseWheelEvent represents a mouse wheel message event.
|
||||
type MouseWheelEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse wheel event.
|
||||
func (e MouseWheelEvent) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseWheelEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseMotionEvent represents a mouse motion event.
|
||||
type MouseMotionEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse motion event.
|
||||
func (e MouseMotionEvent) String() string {
|
||||
m := Mouse(e)
|
||||
if m.Button != 0 {
|
||||
return m.String() + "+motion"
|
||||
}
|
||||
return m.String() + "motion"
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseMotionEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events
|
||||
// look like:
|
||||
//
|
||||
// ESC [ < Cb ; Cx ; Cy (M or m)
|
||||
//
|
||||
// where:
|
||||
//
|
||||
// Cb is the encoded button code
|
||||
// Cx is the x-coordinate of the mouse
|
||||
// Cy is the y-coordinate of the mouse
|
||||
// M is for button press, m is for button release
|
||||
//
|
||||
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
|
||||
func parseSGRMouseEvent(cmd ansi.Cmd, params ansi.Params) Event {
|
||||
x, _, ok := params.Param(1, 1)
|
||||
if !ok {
|
||||
x = 1
|
||||
}
|
||||
y, _, ok := params.Param(2, 1)
|
||||
if !ok {
|
||||
y = 1
|
||||
}
|
||||
release := cmd.Final() == 'm'
|
||||
b, _, _ := params.Param(0, 0)
|
||||
mod, btn, _, isMotion := parseMouseButton(b)
|
||||
|
||||
// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
|
||||
x--
|
||||
y--
|
||||
|
||||
m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
|
||||
|
||||
// Wheel buttons don't have release events
|
||||
// Motion can be reported as a release event in some terminals (Windows Terminal)
|
||||
if isWheel(m.Button) {
|
||||
return MouseWheelEvent(m)
|
||||
} else if !isMotion && release {
|
||||
return MouseReleaseEvent(m)
|
||||
} else if isMotion {
|
||||
return MouseMotionEvent(m)
|
||||
}
|
||||
return MouseClickEvent(m)
|
||||
}
|
||||
|
||||
const x10MouseByteOffset = 32
|
||||
|
||||
// Parse X10-encoded mouse events; the simplest kind. The last release of X10
|
||||
// was December 1986, by the way. The original X10 mouse protocol limits the Cx
|
||||
// and Cy coordinates to 223 (=255-032).
|
||||
//
|
||||
// X10 mouse events look like:
|
||||
//
|
||||
// ESC [M Cb Cx Cy
|
||||
//
|
||||
// See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
|
||||
func parseX10MouseEvent(buf []byte) Event {
|
||||
v := buf[3:6]
|
||||
b := int(v[0])
|
||||
if b >= x10MouseByteOffset {
|
||||
// XXX: b < 32 should be impossible, but we're being defensive.
|
||||
b -= x10MouseByteOffset
|
||||
}
|
||||
|
||||
mod, btn, isRelease, isMotion := parseMouseButton(b)
|
||||
|
||||
// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
|
||||
x := int(v[1]) - x10MouseByteOffset - 1
|
||||
y := int(v[2]) - x10MouseByteOffset - 1
|
||||
|
||||
m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
|
||||
if isWheel(m.Button) {
|
||||
return MouseWheelEvent(m)
|
||||
} else if isMotion {
|
||||
return MouseMotionEvent(m)
|
||||
} else if isRelease {
|
||||
return MouseReleaseEvent(m)
|
||||
}
|
||||
return MouseClickEvent(m)
|
||||
}
|
||||
|
||||
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
|
||||
func parseMouseButton(b int) (mod KeyMod, btn MouseButton, isRelease bool, isMotion bool) {
|
||||
// mouse bit shifts
|
||||
const (
|
||||
bitShift = 0b0000_0100
|
||||
bitAlt = 0b0000_1000
|
||||
bitCtrl = 0b0001_0000
|
||||
bitMotion = 0b0010_0000
|
||||
bitWheel = 0b0100_0000
|
||||
bitAdd = 0b1000_0000 // additional buttons 8-11
|
||||
|
||||
bitsMask = 0b0000_0011
|
||||
)
|
||||
|
||||
// Modifiers
|
||||
if b&bitAlt != 0 {
|
||||
mod |= ModAlt
|
||||
}
|
||||
if b&bitCtrl != 0 {
|
||||
mod |= ModCtrl
|
||||
}
|
||||
if b&bitShift != 0 {
|
||||
mod |= ModShift
|
||||
}
|
||||
|
||||
if b&bitAdd != 0 {
|
||||
btn = MouseBackward + MouseButton(b&bitsMask)
|
||||
} else if b&bitWheel != 0 {
|
||||
btn = MouseWheelUp + MouseButton(b&bitsMask)
|
||||
} else {
|
||||
btn = MouseLeft + MouseButton(b&bitsMask)
|
||||
// X10 reports a button release as 0b0000_0011 (3)
|
||||
if b&bitsMask == bitsMask {
|
||||
btn = MouseNone
|
||||
isRelease = true
|
||||
}
|
||||
}
|
||||
|
||||
// Motion bit doesn't get reported for wheel events.
|
||||
if b&bitMotion != 0 && !isWheel(btn) {
|
||||
isMotion = true
|
||||
}
|
||||
|
||||
return //nolint:nakedret
|
||||
}
|
||||
|
||||
// isWheel returns true if the mouse event is a wheel event.
|
||||
func isWheel(btn MouseButton) bool {
|
||||
return btn >= MouseWheelUp && btn <= MouseWheelRight
|
||||
}
|
||||
481
packages/client/tui/input/mouse_test.go
Normal file
481
packages/client/tui/input/mouse_test.go
Normal file
@@ -0,0 +1,481 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/ansi/parser"
|
||||
)
|
||||
|
||||
func TestMouseEvent_String(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
event Event
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "unknown",
|
||||
event: MouseClickEvent{Button: MouseButton(0xff)},
|
||||
expected: "unknown",
|
||||
},
|
||||
{
|
||||
name: "left",
|
||||
event: MouseClickEvent{Button: MouseLeft},
|
||||
expected: "left",
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
event: MouseClickEvent{Button: MouseRight},
|
||||
expected: "right",
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
event: MouseClickEvent{Button: MouseMiddle},
|
||||
expected: "middle",
|
||||
},
|
||||
{
|
||||
name: "release",
|
||||
event: MouseReleaseEvent{Button: MouseNone},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "wheelup",
|
||||
event: MouseWheelEvent{Button: MouseWheelUp},
|
||||
expected: "wheelup",
|
||||
},
|
||||
{
|
||||
name: "wheeldown",
|
||||
event: MouseWheelEvent{Button: MouseWheelDown},
|
||||
expected: "wheeldown",
|
||||
},
|
||||
{
|
||||
name: "wheelleft",
|
||||
event: MouseWheelEvent{Button: MouseWheelLeft},
|
||||
expected: "wheelleft",
|
||||
},
|
||||
{
|
||||
name: "wheelright",
|
||||
event: MouseWheelEvent{Button: MouseWheelRight},
|
||||
expected: "wheelright",
|
||||
},
|
||||
{
|
||||
name: "motion",
|
||||
event: MouseMotionEvent{Button: MouseNone},
|
||||
expected: "motion",
|
||||
},
|
||||
{
|
||||
name: "shift+left",
|
||||
event: MouseReleaseEvent{Button: MouseLeft, Mod: ModShift},
|
||||
expected: "shift+left",
|
||||
},
|
||||
{
|
||||
name: "shift+left", event: MouseClickEvent{Button: MouseLeft, Mod: ModShift},
|
||||
expected: "shift+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+shift+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl | ModShift},
|
||||
expected: "ctrl+shift+left",
|
||||
},
|
||||
{
|
||||
name: "alt+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt},
|
||||
expected: "alt+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl},
|
||||
expected: "ctrl+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl},
|
||||
expected: "ctrl+alt+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+shift+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl | ModShift},
|
||||
expected: "ctrl+alt+shift+left",
|
||||
},
|
||||
{
|
||||
name: "ignore coordinates",
|
||||
event: MouseClickEvent{X: 100, Y: 200, Button: MouseLeft},
|
||||
expected: "left",
|
||||
},
|
||||
{
|
||||
name: "broken type",
|
||||
event: MouseClickEvent{Button: MouseButton(120)},
|
||||
expected: "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
for i := range tt {
|
||||
tc := tt[i]
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := fmt.Sprint(tc.event)
|
||||
|
||||
if tc.expected != actual {
|
||||
t.Fatalf("expected %q but got %q",
|
||||
tc.expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseX10MouseDownEvent(t *testing.T) {
|
||||
encode := func(b byte, x, y int) []byte {
|
||||
return []byte{
|
||||
'\x1b',
|
||||
'[',
|
||||
'M',
|
||||
byte(32) + b,
|
||||
byte(x + 32 + 1),
|
||||
byte(y + 32 + 1),
|
||||
}
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
buf []byte
|
||||
expected Event
|
||||
}{
|
||||
// Position.
|
||||
{
|
||||
name: "zero position",
|
||||
buf: encode(0b0000_0000, 0, 0),
|
||||
expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "max position",
|
||||
buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1.
|
||||
expected: MouseClickEvent{X: 222, Y: 222, Button: MouseLeft},
|
||||
},
|
||||
// Simple.
|
||||
{
|
||||
name: "left",
|
||||
buf: encode(0b0000_0000, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "left in motion",
|
||||
buf: encode(0b0010_0000, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
buf: encode(0b0000_0001, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "middle in motion",
|
||||
buf: encode(0b0010_0001, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
buf: encode(0b0000_0010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "right in motion",
|
||||
buf: encode(0b0010_0010, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "motion",
|
||||
buf: encode(0b0010_0011, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
|
||||
},
|
||||
{
|
||||
name: "wheel up",
|
||||
buf: encode(0b0100_0000, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
{
|
||||
name: "wheel down",
|
||||
buf: encode(0b0100_0001, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "wheel left",
|
||||
buf: encode(0b0100_0010, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
|
||||
},
|
||||
{
|
||||
name: "wheel right",
|
||||
buf: encode(0b0100_0011, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
|
||||
},
|
||||
{
|
||||
name: "release",
|
||||
buf: encode(0b0000_0011, 32, 16),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseNone},
|
||||
},
|
||||
{
|
||||
name: "backward",
|
||||
buf: encode(0b1000_0000, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
|
||||
},
|
||||
{
|
||||
name: "forward",
|
||||
buf: encode(0b1000_0001, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
|
||||
},
|
||||
{
|
||||
name: "button 10",
|
||||
buf: encode(0b1000_0010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton10},
|
||||
},
|
||||
{
|
||||
name: "button 11",
|
||||
buf: encode(0b1000_0011, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton11},
|
||||
},
|
||||
// Combinations.
|
||||
{
|
||||
name: "alt+right",
|
||||
buf: encode(0b0000_1010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+right",
|
||||
buf: encode(0b0001_0010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "left in motion",
|
||||
buf: encode(0b0010_0000, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "alt+right in motion",
|
||||
buf: encode(0b0010_1010, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+right in motion",
|
||||
buf: encode(0b0011_0010, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+right",
|
||||
buf: encode(0b0001_1010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+wheel up",
|
||||
buf: encode(0b0101_0000, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelUp},
|
||||
},
|
||||
{
|
||||
name: "alt+wheel down",
|
||||
buf: encode(0b0100_1001, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+wheel down",
|
||||
buf: encode(0b0101_1001, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
// Overflow position.
|
||||
{
|
||||
name: "overflow position",
|
||||
buf: encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1.
|
||||
expected: MouseMotionEvent{X: -6, Y: -33, Button: MouseLeft},
|
||||
},
|
||||
}
|
||||
|
||||
for i := range tt {
|
||||
tc := tt[i]
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := parseX10MouseEvent(tc.buf)
|
||||
|
||||
if tc.expected != actual {
|
||||
t.Fatalf("expected %#v but got %#v",
|
||||
tc.expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSGRMouseEvent(t *testing.T) {
|
||||
type csiSequence struct {
|
||||
params []ansi.Param
|
||||
cmd ansi.Cmd
|
||||
}
|
||||
encode := func(b, x, y int, r bool) *csiSequence {
|
||||
re := 'M'
|
||||
if r {
|
||||
re = 'm'
|
||||
}
|
||||
return &csiSequence{
|
||||
params: []ansi.Param{
|
||||
ansi.Param(b),
|
||||
ansi.Param(x + 1),
|
||||
ansi.Param(y + 1),
|
||||
},
|
||||
cmd: ansi.Cmd(re) | ('<' << parser.PrefixShift),
|
||||
}
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
buf *csiSequence
|
||||
expected Event
|
||||
}{
|
||||
// Position.
|
||||
{
|
||||
name: "zero position",
|
||||
buf: encode(0, 0, 0, false),
|
||||
expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "225 position",
|
||||
buf: encode(0, 225, 225, false),
|
||||
expected: MouseClickEvent{X: 225, Y: 225, Button: MouseLeft},
|
||||
},
|
||||
// Simple.
|
||||
{
|
||||
name: "left",
|
||||
buf: encode(0, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "left in motion",
|
||||
buf: encode(32, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "left",
|
||||
buf: encode(0, 32, 16, true),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
buf: encode(1, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "middle in motion",
|
||||
buf: encode(33, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
buf: encode(1, 32, 16, true),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
buf: encode(2, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
buf: encode(2, 32, 16, true),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "motion",
|
||||
buf: encode(35, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
|
||||
},
|
||||
{
|
||||
name: "wheel up",
|
||||
buf: encode(64, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
{
|
||||
name: "wheel down",
|
||||
buf: encode(65, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "wheel left",
|
||||
buf: encode(66, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
|
||||
},
|
||||
{
|
||||
name: "wheel right",
|
||||
buf: encode(67, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
|
||||
},
|
||||
{
|
||||
name: "backward",
|
||||
buf: encode(128, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
|
||||
},
|
||||
{
|
||||
name: "backward in motion",
|
||||
buf: encode(160, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseBackward},
|
||||
},
|
||||
{
|
||||
name: "forward",
|
||||
buf: encode(129, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
|
||||
},
|
||||
{
|
||||
name: "forward in motion",
|
||||
buf: encode(161, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseForward},
|
||||
},
|
||||
// Combinations.
|
||||
{
|
||||
name: "alt+right",
|
||||
buf: encode(10, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+right",
|
||||
buf: encode(18, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+right",
|
||||
buf: encode(26, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "alt+wheel",
|
||||
buf: encode(73, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+wheel",
|
||||
buf: encode(81, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+wheel",
|
||||
buf: encode(89, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+shift+wheel",
|
||||
buf: encode(93, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModShift | ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
}
|
||||
|
||||
for i := range tt {
|
||||
tc := tt[i]
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := parseSGRMouseEvent(tc.buf.cmd, tc.buf.params)
|
||||
if tc.expected != actual {
|
||||
t.Fatalf("expected %#v but got %#v",
|
||||
tc.expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1030
packages/client/tui/input/parse.go
Normal file
1030
packages/client/tui/input/parse.go
Normal file
File diff suppressed because it is too large
Load Diff
47
packages/client/tui/input/parse_test.go
Normal file
47
packages/client/tui/input/parse_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func TestParseSequence_Events(t *testing.T) {
|
||||
input := []byte("\x1b\x1b[Ztest\x00\x1b]10;rgb:1234/1234/1234\x07\x1b[27;2;27~\x1b[?1049;2$y\x1b[4;1$y")
|
||||
want := []Event{
|
||||
KeyPressEvent{Code: KeyTab, Mod: ModShift | ModAlt},
|
||||
KeyPressEvent{Code: 't', Text: "t"},
|
||||
KeyPressEvent{Code: 'e', Text: "e"},
|
||||
KeyPressEvent{Code: 's', Text: "s"},
|
||||
KeyPressEvent{Code: 't', Text: "t"},
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
|
||||
ForegroundColorEvent{color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}},
|
||||
KeyPressEvent{Code: KeyEscape, Mod: ModShift},
|
||||
ModeReportEvent{Mode: ansi.AltScreenSaveCursorMode, Value: ansi.ModeReset},
|
||||
ModeReportEvent{Mode: ansi.InsertReplaceMode, Value: ansi.ModeSet},
|
||||
}
|
||||
|
||||
var p Parser
|
||||
for i := 0; len(input) != 0; i++ {
|
||||
if i >= len(want) {
|
||||
t.Fatalf("reached end of want events")
|
||||
}
|
||||
n, got := p.parseSequence(input)
|
||||
if !reflect.DeepEqual(got, want[i]) {
|
||||
t.Errorf("got %#v (%T), want %#v (%T)", got, got, want[i], want[i])
|
||||
}
|
||||
input = input[n:]
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParseSequence(b *testing.B) {
|
||||
var p Parser
|
||||
input := []byte("\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~")
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
p.parseSequence(input)
|
||||
}
|
||||
}
|
||||
13
packages/client/tui/input/paste.go
Normal file
13
packages/client/tui/input/paste.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package input
|
||||
|
||||
// PasteEvent is an message that is emitted when a terminal receives pasted text
|
||||
// using bracketed-paste.
|
||||
type PasteEvent string
|
||||
|
||||
// PasteStartEvent is an message that is emitted when the terminal starts the
|
||||
// bracketed-paste text.
|
||||
type PasteStartEvent struct{}
|
||||
|
||||
// PasteEndEvent is an message that is emitted when the terminal ends the
|
||||
// bracketed-paste text.
|
||||
type PasteEndEvent struct{}
|
||||
389
packages/client/tui/input/table.go
Normal file
389
packages/client/tui/input/table.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// buildKeysTable builds a table of key sequences and their corresponding key
|
||||
// events based on the VT100/VT200, XTerm, and Urxvt terminal specs.
|
||||
func buildKeysTable(flags int, term string) map[string]Key {
|
||||
nul := Key{Code: KeySpace, Mod: ModCtrl} // ctrl+@ or ctrl+space
|
||||
if flags&FlagCtrlAt != 0 {
|
||||
nul = Key{Code: '@', Mod: ModCtrl}
|
||||
}
|
||||
|
||||
tab := Key{Code: KeyTab} // ctrl+i or tab
|
||||
if flags&FlagCtrlI != 0 {
|
||||
tab = Key{Code: 'i', Mod: ModCtrl}
|
||||
}
|
||||
|
||||
enter := Key{Code: KeyEnter} // ctrl+m or enter
|
||||
if flags&FlagCtrlM != 0 {
|
||||
enter = Key{Code: 'm', Mod: ModCtrl}
|
||||
}
|
||||
|
||||
esc := Key{Code: KeyEscape} // ctrl+[ or escape
|
||||
if flags&FlagCtrlOpenBracket != 0 {
|
||||
esc = Key{Code: '[', Mod: ModCtrl} // ctrl+[ or escape
|
||||
}
|
||||
|
||||
del := Key{Code: KeyBackspace}
|
||||
if flags&FlagBackspace != 0 {
|
||||
del.Code = KeyDelete
|
||||
}
|
||||
|
||||
find := Key{Code: KeyHome}
|
||||
if flags&FlagFind != 0 {
|
||||
find.Code = KeyFind
|
||||
}
|
||||
|
||||
sel := Key{Code: KeyEnd}
|
||||
if flags&FlagSelect != 0 {
|
||||
sel.Code = KeySelect
|
||||
}
|
||||
|
||||
// The following is a table of key sequences and their corresponding key
|
||||
// events based on the VT100/VT200 terminal specs.
|
||||
//
|
||||
// See: https://vt100.net/docs/vt100-ug/chapter3.html#S3.2
|
||||
// See: https://vt100.net/docs/vt220-rm/chapter3.html
|
||||
//
|
||||
// XXX: These keys may be overwritten by other options like XTerm or
|
||||
// Terminfo.
|
||||
table := map[string]Key{
|
||||
// C0 control characters
|
||||
string(byte(ansi.NUL)): nul,
|
||||
string(byte(ansi.SOH)): {Code: 'a', Mod: ModCtrl},
|
||||
string(byte(ansi.STX)): {Code: 'b', Mod: ModCtrl},
|
||||
string(byte(ansi.ETX)): {Code: 'c', Mod: ModCtrl},
|
||||
string(byte(ansi.EOT)): {Code: 'd', Mod: ModCtrl},
|
||||
string(byte(ansi.ENQ)): {Code: 'e', Mod: ModCtrl},
|
||||
string(byte(ansi.ACK)): {Code: 'f', Mod: ModCtrl},
|
||||
string(byte(ansi.BEL)): {Code: 'g', Mod: ModCtrl},
|
||||
string(byte(ansi.BS)): {Code: 'h', Mod: ModCtrl},
|
||||
string(byte(ansi.HT)): tab,
|
||||
string(byte(ansi.LF)): {Code: 'j', Mod: ModCtrl},
|
||||
string(byte(ansi.VT)): {Code: 'k', Mod: ModCtrl},
|
||||
string(byte(ansi.FF)): {Code: 'l', Mod: ModCtrl},
|
||||
string(byte(ansi.CR)): enter,
|
||||
string(byte(ansi.SO)): {Code: 'n', Mod: ModCtrl},
|
||||
string(byte(ansi.SI)): {Code: 'o', Mod: ModCtrl},
|
||||
string(byte(ansi.DLE)): {Code: 'p', Mod: ModCtrl},
|
||||
string(byte(ansi.DC1)): {Code: 'q', Mod: ModCtrl},
|
||||
string(byte(ansi.DC2)): {Code: 'r', Mod: ModCtrl},
|
||||
string(byte(ansi.DC3)): {Code: 's', Mod: ModCtrl},
|
||||
string(byte(ansi.DC4)): {Code: 't', Mod: ModCtrl},
|
||||
string(byte(ansi.NAK)): {Code: 'u', Mod: ModCtrl},
|
||||
string(byte(ansi.SYN)): {Code: 'v', Mod: ModCtrl},
|
||||
string(byte(ansi.ETB)): {Code: 'w', Mod: ModCtrl},
|
||||
string(byte(ansi.CAN)): {Code: 'x', Mod: ModCtrl},
|
||||
string(byte(ansi.EM)): {Code: 'y', Mod: ModCtrl},
|
||||
string(byte(ansi.SUB)): {Code: 'z', Mod: ModCtrl},
|
||||
string(byte(ansi.ESC)): esc,
|
||||
string(byte(ansi.FS)): {Code: '\\', Mod: ModCtrl},
|
||||
string(byte(ansi.GS)): {Code: ']', Mod: ModCtrl},
|
||||
string(byte(ansi.RS)): {Code: '^', Mod: ModCtrl},
|
||||
string(byte(ansi.US)): {Code: '_', Mod: ModCtrl},
|
||||
|
||||
// Special keys in G0
|
||||
string(byte(ansi.SP)): {Code: KeySpace, Text: " "},
|
||||
string(byte(ansi.DEL)): del,
|
||||
|
||||
// Special keys
|
||||
|
||||
"\x1b[Z": {Code: KeyTab, Mod: ModShift},
|
||||
|
||||
"\x1b[1~": find,
|
||||
"\x1b[2~": {Code: KeyInsert},
|
||||
"\x1b[3~": {Code: KeyDelete},
|
||||
"\x1b[4~": sel,
|
||||
"\x1b[5~": {Code: KeyPgUp},
|
||||
"\x1b[6~": {Code: KeyPgDown},
|
||||
"\x1b[7~": {Code: KeyHome},
|
||||
"\x1b[8~": {Code: KeyEnd},
|
||||
|
||||
// Normal mode
|
||||
"\x1b[A": {Code: KeyUp},
|
||||
"\x1b[B": {Code: KeyDown},
|
||||
"\x1b[C": {Code: KeyRight},
|
||||
"\x1b[D": {Code: KeyLeft},
|
||||
"\x1b[E": {Code: KeyBegin},
|
||||
"\x1b[F": {Code: KeyEnd},
|
||||
"\x1b[H": {Code: KeyHome},
|
||||
"\x1b[P": {Code: KeyF1},
|
||||
"\x1b[Q": {Code: KeyF2},
|
||||
"\x1b[R": {Code: KeyF3},
|
||||
"\x1b[S": {Code: KeyF4},
|
||||
|
||||
// Application Cursor Key Mode (DECCKM)
|
||||
"\x1bOA": {Code: KeyUp},
|
||||
"\x1bOB": {Code: KeyDown},
|
||||
"\x1bOC": {Code: KeyRight},
|
||||
"\x1bOD": {Code: KeyLeft},
|
||||
"\x1bOE": {Code: KeyBegin},
|
||||
"\x1bOF": {Code: KeyEnd},
|
||||
"\x1bOH": {Code: KeyHome},
|
||||
"\x1bOP": {Code: KeyF1},
|
||||
"\x1bOQ": {Code: KeyF2},
|
||||
"\x1bOR": {Code: KeyF3},
|
||||
"\x1bOS": {Code: KeyF4},
|
||||
|
||||
// Keypad Application Mode (DECKPAM)
|
||||
|
||||
"\x1bOM": {Code: KeyKpEnter},
|
||||
"\x1bOX": {Code: KeyKpEqual},
|
||||
"\x1bOj": {Code: KeyKpMultiply},
|
||||
"\x1bOk": {Code: KeyKpPlus},
|
||||
"\x1bOl": {Code: KeyKpComma},
|
||||
"\x1bOm": {Code: KeyKpMinus},
|
||||
"\x1bOn": {Code: KeyKpDecimal},
|
||||
"\x1bOo": {Code: KeyKpDivide},
|
||||
"\x1bOp": {Code: KeyKp0},
|
||||
"\x1bOq": {Code: KeyKp1},
|
||||
"\x1bOr": {Code: KeyKp2},
|
||||
"\x1bOs": {Code: KeyKp3},
|
||||
"\x1bOt": {Code: KeyKp4},
|
||||
"\x1bOu": {Code: KeyKp5},
|
||||
"\x1bOv": {Code: KeyKp6},
|
||||
"\x1bOw": {Code: KeyKp7},
|
||||
"\x1bOx": {Code: KeyKp8},
|
||||
"\x1bOy": {Code: KeyKp9},
|
||||
|
||||
// Function keys
|
||||
|
||||
"\x1b[11~": {Code: KeyF1},
|
||||
"\x1b[12~": {Code: KeyF2},
|
||||
"\x1b[13~": {Code: KeyF3},
|
||||
"\x1b[14~": {Code: KeyF4},
|
||||
"\x1b[15~": {Code: KeyF5},
|
||||
"\x1b[17~": {Code: KeyF6},
|
||||
"\x1b[18~": {Code: KeyF7},
|
||||
"\x1b[19~": {Code: KeyF8},
|
||||
"\x1b[20~": {Code: KeyF9},
|
||||
"\x1b[21~": {Code: KeyF10},
|
||||
"\x1b[23~": {Code: KeyF11},
|
||||
"\x1b[24~": {Code: KeyF12},
|
||||
"\x1b[25~": {Code: KeyF13},
|
||||
"\x1b[26~": {Code: KeyF14},
|
||||
"\x1b[28~": {Code: KeyF15},
|
||||
"\x1b[29~": {Code: KeyF16},
|
||||
"\x1b[31~": {Code: KeyF17},
|
||||
"\x1b[32~": {Code: KeyF18},
|
||||
"\x1b[33~": {Code: KeyF19},
|
||||
"\x1b[34~": {Code: KeyF20},
|
||||
}
|
||||
|
||||
// CSI ~ sequence keys
|
||||
csiTildeKeys := map[string]Key{
|
||||
"1": find, "2": {Code: KeyInsert},
|
||||
"3": {Code: KeyDelete}, "4": sel,
|
||||
"5": {Code: KeyPgUp}, "6": {Code: KeyPgDown},
|
||||
"7": {Code: KeyHome}, "8": {Code: KeyEnd},
|
||||
// There are no 9 and 10 keys
|
||||
"11": {Code: KeyF1}, "12": {Code: KeyF2},
|
||||
"13": {Code: KeyF3}, "14": {Code: KeyF4},
|
||||
"15": {Code: KeyF5}, "17": {Code: KeyF6},
|
||||
"18": {Code: KeyF7}, "19": {Code: KeyF8},
|
||||
"20": {Code: KeyF9}, "21": {Code: KeyF10},
|
||||
"23": {Code: KeyF11}, "24": {Code: KeyF12},
|
||||
"25": {Code: KeyF13}, "26": {Code: KeyF14},
|
||||
"28": {Code: KeyF15}, "29": {Code: KeyF16},
|
||||
"31": {Code: KeyF17}, "32": {Code: KeyF18},
|
||||
"33": {Code: KeyF19}, "34": {Code: KeyF20},
|
||||
}
|
||||
|
||||
// URxvt keys
|
||||
// See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
|
||||
table["\x1b[a"] = Key{Code: KeyUp, Mod: ModShift}
|
||||
table["\x1b[b"] = Key{Code: KeyDown, Mod: ModShift}
|
||||
table["\x1b[c"] = Key{Code: KeyRight, Mod: ModShift}
|
||||
table["\x1b[d"] = Key{Code: KeyLeft, Mod: ModShift}
|
||||
table["\x1bOa"] = Key{Code: KeyUp, Mod: ModCtrl}
|
||||
table["\x1bOb"] = Key{Code: KeyDown, Mod: ModCtrl}
|
||||
table["\x1bOc"] = Key{Code: KeyRight, Mod: ModCtrl}
|
||||
table["\x1bOd"] = Key{Code: KeyLeft, Mod: ModCtrl}
|
||||
//nolint:godox
|
||||
// TODO: invistigate if shift-ctrl arrow keys collide with DECCKM keys i.e.
|
||||
// "\x1bOA", "\x1bOB", "\x1bOC", "\x1bOD"
|
||||
|
||||
// URxvt modifier CSI ~ keys
|
||||
for k, v := range csiTildeKeys {
|
||||
key := v
|
||||
// Normal (no modifier) already defined part of VT100/VT200
|
||||
// Shift modifier
|
||||
key.Mod = ModShift
|
||||
table["\x1b["+k+"$"] = key
|
||||
// Ctrl modifier
|
||||
key.Mod = ModCtrl
|
||||
table["\x1b["+k+"^"] = key
|
||||
// Shift-Ctrl modifier
|
||||
key.Mod = ModShift | ModCtrl
|
||||
table["\x1b["+k+"@"] = key
|
||||
}
|
||||
|
||||
// URxvt F keys
|
||||
// Note: Shift + F1-F10 generates F11-F20.
|
||||
// This means Shift + F1 and Shift + F2 will generate F11 and F12, the same
|
||||
// applies to Ctrl + Shift F1 & F2.
|
||||
//
|
||||
// P.S. Don't like this? Blame URxvt, configure your terminal to use
|
||||
// different escapes like XTerm, or switch to a better terminal ¯\_(ツ)_/¯
|
||||
//
|
||||
// See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
|
||||
table["\x1b[23$"] = Key{Code: KeyF11, Mod: ModShift}
|
||||
table["\x1b[24$"] = Key{Code: KeyF12, Mod: ModShift}
|
||||
table["\x1b[25$"] = Key{Code: KeyF13, Mod: ModShift}
|
||||
table["\x1b[26$"] = Key{Code: KeyF14, Mod: ModShift}
|
||||
table["\x1b[28$"] = Key{Code: KeyF15, Mod: ModShift}
|
||||
table["\x1b[29$"] = Key{Code: KeyF16, Mod: ModShift}
|
||||
table["\x1b[31$"] = Key{Code: KeyF17, Mod: ModShift}
|
||||
table["\x1b[32$"] = Key{Code: KeyF18, Mod: ModShift}
|
||||
table["\x1b[33$"] = Key{Code: KeyF19, Mod: ModShift}
|
||||
table["\x1b[34$"] = Key{Code: KeyF20, Mod: ModShift}
|
||||
table["\x1b[11^"] = Key{Code: KeyF1, Mod: ModCtrl}
|
||||
table["\x1b[12^"] = Key{Code: KeyF2, Mod: ModCtrl}
|
||||
table["\x1b[13^"] = Key{Code: KeyF3, Mod: ModCtrl}
|
||||
table["\x1b[14^"] = Key{Code: KeyF4, Mod: ModCtrl}
|
||||
table["\x1b[15^"] = Key{Code: KeyF5, Mod: ModCtrl}
|
||||
table["\x1b[17^"] = Key{Code: KeyF6, Mod: ModCtrl}
|
||||
table["\x1b[18^"] = Key{Code: KeyF7, Mod: ModCtrl}
|
||||
table["\x1b[19^"] = Key{Code: KeyF8, Mod: ModCtrl}
|
||||
table["\x1b[20^"] = Key{Code: KeyF9, Mod: ModCtrl}
|
||||
table["\x1b[21^"] = Key{Code: KeyF10, Mod: ModCtrl}
|
||||
table["\x1b[23^"] = Key{Code: KeyF11, Mod: ModCtrl}
|
||||
table["\x1b[24^"] = Key{Code: KeyF12, Mod: ModCtrl}
|
||||
table["\x1b[25^"] = Key{Code: KeyF13, Mod: ModCtrl}
|
||||
table["\x1b[26^"] = Key{Code: KeyF14, Mod: ModCtrl}
|
||||
table["\x1b[28^"] = Key{Code: KeyF15, Mod: ModCtrl}
|
||||
table["\x1b[29^"] = Key{Code: KeyF16, Mod: ModCtrl}
|
||||
table["\x1b[31^"] = Key{Code: KeyF17, Mod: ModCtrl}
|
||||
table["\x1b[32^"] = Key{Code: KeyF18, Mod: ModCtrl}
|
||||
table["\x1b[33^"] = Key{Code: KeyF19, Mod: ModCtrl}
|
||||
table["\x1b[34^"] = Key{Code: KeyF20, Mod: ModCtrl}
|
||||
table["\x1b[23@"] = Key{Code: KeyF11, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[24@"] = Key{Code: KeyF12, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[25@"] = Key{Code: KeyF13, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[26@"] = Key{Code: KeyF14, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[28@"] = Key{Code: KeyF15, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[29@"] = Key{Code: KeyF16, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[31@"] = Key{Code: KeyF17, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[32@"] = Key{Code: KeyF18, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[33@"] = Key{Code: KeyF19, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[34@"] = Key{Code: KeyF20, Mod: ModShift | ModCtrl}
|
||||
|
||||
// Register Alt + <key> combinations
|
||||
// XXX: this must come after URxvt but before XTerm keys to register URxvt
|
||||
// keys with alt modifier
|
||||
tmap := map[string]Key{}
|
||||
for seq, key := range table {
|
||||
key := key
|
||||
key.Mod |= ModAlt
|
||||
key.Text = "" // Clear runes
|
||||
tmap["\x1b"+seq] = key
|
||||
}
|
||||
maps.Copy(table, tmap)
|
||||
|
||||
// XTerm modifiers
|
||||
// These are offset by 1 to be compatible with our Mod type.
|
||||
// See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-PC-Style-Function-Keys
|
||||
modifiers := []KeyMod{
|
||||
ModShift, // 1
|
||||
ModAlt, // 2
|
||||
ModShift | ModAlt, // 3
|
||||
ModCtrl, // 4
|
||||
ModShift | ModCtrl, // 5
|
||||
ModAlt | ModCtrl, // 6
|
||||
ModShift | ModAlt | ModCtrl, // 7
|
||||
ModMeta, // 8
|
||||
ModMeta | ModShift, // 9
|
||||
ModMeta | ModAlt, // 10
|
||||
ModMeta | ModShift | ModAlt, // 11
|
||||
ModMeta | ModCtrl, // 12
|
||||
ModMeta | ModShift | ModCtrl, // 13
|
||||
ModMeta | ModAlt | ModCtrl, // 14
|
||||
ModMeta | ModShift | ModAlt | ModCtrl, // 15
|
||||
}
|
||||
|
||||
// SS3 keypad function keys
|
||||
ss3FuncKeys := map[string]Key{
|
||||
// These are defined in XTerm
|
||||
// Taken from Foot keymap.h and XTerm modifyOtherKeys
|
||||
// https://codeberg.org/dnkl/foot/src/branch/master/keymap.h
|
||||
"M": {Code: KeyKpEnter}, "X": {Code: KeyKpEqual},
|
||||
"j": {Code: KeyKpMultiply}, "k": {Code: KeyKpPlus},
|
||||
"l": {Code: KeyKpComma}, "m": {Code: KeyKpMinus},
|
||||
"n": {Code: KeyKpDecimal}, "o": {Code: KeyKpDivide},
|
||||
"p": {Code: KeyKp0}, "q": {Code: KeyKp1},
|
||||
"r": {Code: KeyKp2}, "s": {Code: KeyKp3},
|
||||
"t": {Code: KeyKp4}, "u": {Code: KeyKp5},
|
||||
"v": {Code: KeyKp6}, "w": {Code: KeyKp7},
|
||||
"x": {Code: KeyKp8}, "y": {Code: KeyKp9},
|
||||
}
|
||||
|
||||
// XTerm keys
|
||||
csiFuncKeys := map[string]Key{
|
||||
"A": {Code: KeyUp}, "B": {Code: KeyDown},
|
||||
"C": {Code: KeyRight}, "D": {Code: KeyLeft},
|
||||
"E": {Code: KeyBegin}, "F": {Code: KeyEnd},
|
||||
"H": {Code: KeyHome}, "P": {Code: KeyF1},
|
||||
"Q": {Code: KeyF2}, "R": {Code: KeyF3},
|
||||
"S": {Code: KeyF4},
|
||||
}
|
||||
|
||||
// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
|
||||
modifyOtherKeys := map[int]Key{
|
||||
ansi.BS: {Code: KeyBackspace},
|
||||
ansi.HT: {Code: KeyTab},
|
||||
ansi.CR: {Code: KeyEnter},
|
||||
ansi.ESC: {Code: KeyEscape},
|
||||
ansi.DEL: {Code: KeyBackspace},
|
||||
}
|
||||
|
||||
for _, m := range modifiers {
|
||||
// XTerm modifier offset +1
|
||||
xtermMod := strconv.Itoa(int(m) + 1)
|
||||
|
||||
// CSI 1 ; <modifier> <func>
|
||||
for k, v := range csiFuncKeys {
|
||||
// Functions always have a leading 1 param
|
||||
seq := "\x1b[1;" + xtermMod + k
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
// SS3 <modifier> <func>
|
||||
for k, v := range ss3FuncKeys {
|
||||
seq := "\x1bO" + xtermMod + k
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
// CSI <number> ; <modifier> ~
|
||||
for k, v := range csiTildeKeys {
|
||||
seq := "\x1b[" + k + ";" + xtermMod + "~"
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
// CSI 27 ; <modifier> ; <code> ~
|
||||
for k, v := range modifyOtherKeys {
|
||||
code := strconv.Itoa(k)
|
||||
seq := "\x1b[27;" + xtermMod + ";" + code + "~"
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
}
|
||||
|
||||
// Register terminfo keys
|
||||
// XXX: this might override keys already registered in table
|
||||
if flags&FlagTerminfo != 0 {
|
||||
titable := buildTerminfoKeys(flags, term)
|
||||
maps.Copy(table, titable)
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
54
packages/client/tui/input/termcap.go
Normal file
54
packages/client/tui/input/termcap.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CapabilityEvent represents a Termcap/Terminfo response event. Termcap
|
||||
// responses are generated by the terminal in response to RequestTermcap
|
||||
// (XTGETTCAP) requests.
|
||||
//
|
||||
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
|
||||
type CapabilityEvent string
|
||||
|
||||
func parseTermcap(data []byte) CapabilityEvent {
|
||||
// XTGETTCAP
|
||||
if len(data) == 0 {
|
||||
return CapabilityEvent("")
|
||||
}
|
||||
|
||||
var tc strings.Builder
|
||||
split := bytes.Split(data, []byte{';'})
|
||||
for _, s := range split {
|
||||
parts := bytes.SplitN(s, []byte{'='}, 2)
|
||||
if len(parts) == 0 {
|
||||
return CapabilityEvent("")
|
||||
}
|
||||
|
||||
name, err := hex.DecodeString(string(parts[0]))
|
||||
if err != nil || len(name) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var value []byte
|
||||
if len(parts) > 1 {
|
||||
value, err = hex.DecodeString(string(parts[1]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if tc.Len() > 0 {
|
||||
tc.WriteByte(';')
|
||||
}
|
||||
tc.WriteString(string(name))
|
||||
if len(value) > 0 {
|
||||
tc.WriteByte('=')
|
||||
tc.WriteString(string(value))
|
||||
}
|
||||
}
|
||||
|
||||
return CapabilityEvent(tc.String())
|
||||
}
|
||||
277
packages/client/tui/input/terminfo.go
Normal file
277
packages/client/tui/input/terminfo.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/xo/terminfo"
|
||||
)
|
||||
|
||||
func buildTerminfoKeys(flags int, term string) map[string]Key {
|
||||
table := make(map[string]Key)
|
||||
ti, _ := terminfo.Load(term)
|
||||
if ti == nil {
|
||||
return table
|
||||
}
|
||||
|
||||
tiTable := defaultTerminfoKeys(flags)
|
||||
|
||||
// Default keys
|
||||
for name, seq := range ti.StringCapsShort() {
|
||||
if !strings.HasPrefix(name, "k") || len(seq) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if k, ok := tiTable[name]; ok {
|
||||
table[string(seq)] = k
|
||||
}
|
||||
}
|
||||
|
||||
// Extended keys
|
||||
for name, seq := range ti.ExtStringCapsShort() {
|
||||
if !strings.HasPrefix(name, "k") || len(seq) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if k, ok := tiTable[name]; ok {
|
||||
table[string(seq)] = k
|
||||
}
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
// This returns a map of terminfo keys to key events. It's a mix of ncurses
|
||||
// terminfo default and user-defined key capabilities.
|
||||
// Upper-case caps that are defined in the default terminfo database are
|
||||
// - kNXT
|
||||
// - kPRV
|
||||
// - kHOM
|
||||
// - kEND
|
||||
// - kDC
|
||||
// - kIC
|
||||
// - kLFT
|
||||
// - kRIT
|
||||
//
|
||||
// See https://man7.org/linux/man-pages/man5/terminfo.5.html
|
||||
// See https://github.com/mirror/ncurses/blob/master/include/Caps-ncurses
|
||||
func defaultTerminfoKeys(flags int) map[string]Key {
|
||||
keys := map[string]Key{
|
||||
"kcuu1": {Code: KeyUp},
|
||||
"kUP": {Code: KeyUp, Mod: ModShift},
|
||||
"kUP3": {Code: KeyUp, Mod: ModAlt},
|
||||
"kUP4": {Code: KeyUp, Mod: ModShift | ModAlt},
|
||||
"kUP5": {Code: KeyUp, Mod: ModCtrl},
|
||||
"kUP6": {Code: KeyUp, Mod: ModShift | ModCtrl},
|
||||
"kUP7": {Code: KeyUp, Mod: ModAlt | ModCtrl},
|
||||
"kUP8": {Code: KeyUp, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kcud1": {Code: KeyDown},
|
||||
"kDN": {Code: KeyDown, Mod: ModShift},
|
||||
"kDN3": {Code: KeyDown, Mod: ModAlt},
|
||||
"kDN4": {Code: KeyDown, Mod: ModShift | ModAlt},
|
||||
"kDN5": {Code: KeyDown, Mod: ModCtrl},
|
||||
"kDN7": {Code: KeyDown, Mod: ModAlt | ModCtrl},
|
||||
"kDN6": {Code: KeyDown, Mod: ModShift | ModCtrl},
|
||||
"kDN8": {Code: KeyDown, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kcub1": {Code: KeyLeft},
|
||||
"kLFT": {Code: KeyLeft, Mod: ModShift},
|
||||
"kLFT3": {Code: KeyLeft, Mod: ModAlt},
|
||||
"kLFT4": {Code: KeyLeft, Mod: ModShift | ModAlt},
|
||||
"kLFT5": {Code: KeyLeft, Mod: ModCtrl},
|
||||
"kLFT6": {Code: KeyLeft, Mod: ModShift | ModCtrl},
|
||||
"kLFT7": {Code: KeyLeft, Mod: ModAlt | ModCtrl},
|
||||
"kLFT8": {Code: KeyLeft, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kcuf1": {Code: KeyRight},
|
||||
"kRIT": {Code: KeyRight, Mod: ModShift},
|
||||
"kRIT3": {Code: KeyRight, Mod: ModAlt},
|
||||
"kRIT4": {Code: KeyRight, Mod: ModShift | ModAlt},
|
||||
"kRIT5": {Code: KeyRight, Mod: ModCtrl},
|
||||
"kRIT6": {Code: KeyRight, Mod: ModShift | ModCtrl},
|
||||
"kRIT7": {Code: KeyRight, Mod: ModAlt | ModCtrl},
|
||||
"kRIT8": {Code: KeyRight, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kich1": {Code: KeyInsert},
|
||||
"kIC": {Code: KeyInsert, Mod: ModShift},
|
||||
"kIC3": {Code: KeyInsert, Mod: ModAlt},
|
||||
"kIC4": {Code: KeyInsert, Mod: ModShift | ModAlt},
|
||||
"kIC5": {Code: KeyInsert, Mod: ModCtrl},
|
||||
"kIC6": {Code: KeyInsert, Mod: ModShift | ModCtrl},
|
||||
"kIC7": {Code: KeyInsert, Mod: ModAlt | ModCtrl},
|
||||
"kIC8": {Code: KeyInsert, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kdch1": {Code: KeyDelete},
|
||||
"kDC": {Code: KeyDelete, Mod: ModShift},
|
||||
"kDC3": {Code: KeyDelete, Mod: ModAlt},
|
||||
"kDC4": {Code: KeyDelete, Mod: ModShift | ModAlt},
|
||||
"kDC5": {Code: KeyDelete, Mod: ModCtrl},
|
||||
"kDC6": {Code: KeyDelete, Mod: ModShift | ModCtrl},
|
||||
"kDC7": {Code: KeyDelete, Mod: ModAlt | ModCtrl},
|
||||
"kDC8": {Code: KeyDelete, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"khome": {Code: KeyHome},
|
||||
"kHOM": {Code: KeyHome, Mod: ModShift},
|
||||
"kHOM3": {Code: KeyHome, Mod: ModAlt},
|
||||
"kHOM4": {Code: KeyHome, Mod: ModShift | ModAlt},
|
||||
"kHOM5": {Code: KeyHome, Mod: ModCtrl},
|
||||
"kHOM6": {Code: KeyHome, Mod: ModShift | ModCtrl},
|
||||
"kHOM7": {Code: KeyHome, Mod: ModAlt | ModCtrl},
|
||||
"kHOM8": {Code: KeyHome, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kend": {Code: KeyEnd},
|
||||
"kEND": {Code: KeyEnd, Mod: ModShift},
|
||||
"kEND3": {Code: KeyEnd, Mod: ModAlt},
|
||||
"kEND4": {Code: KeyEnd, Mod: ModShift | ModAlt},
|
||||
"kEND5": {Code: KeyEnd, Mod: ModCtrl},
|
||||
"kEND6": {Code: KeyEnd, Mod: ModShift | ModCtrl},
|
||||
"kEND7": {Code: KeyEnd, Mod: ModAlt | ModCtrl},
|
||||
"kEND8": {Code: KeyEnd, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kpp": {Code: KeyPgUp},
|
||||
"kprv": {Code: KeyPgUp},
|
||||
"kPRV": {Code: KeyPgUp, Mod: ModShift},
|
||||
"kPRV3": {Code: KeyPgUp, Mod: ModAlt},
|
||||
"kPRV4": {Code: KeyPgUp, Mod: ModShift | ModAlt},
|
||||
"kPRV5": {Code: KeyPgUp, Mod: ModCtrl},
|
||||
"kPRV6": {Code: KeyPgUp, Mod: ModShift | ModCtrl},
|
||||
"kPRV7": {Code: KeyPgUp, Mod: ModAlt | ModCtrl},
|
||||
"kPRV8": {Code: KeyPgUp, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"knp": {Code: KeyPgDown},
|
||||
"knxt": {Code: KeyPgDown},
|
||||
"kNXT": {Code: KeyPgDown, Mod: ModShift},
|
||||
"kNXT3": {Code: KeyPgDown, Mod: ModAlt},
|
||||
"kNXT4": {Code: KeyPgDown, Mod: ModShift | ModAlt},
|
||||
"kNXT5": {Code: KeyPgDown, Mod: ModCtrl},
|
||||
"kNXT6": {Code: KeyPgDown, Mod: ModShift | ModCtrl},
|
||||
"kNXT7": {Code: KeyPgDown, Mod: ModAlt | ModCtrl},
|
||||
"kNXT8": {Code: KeyPgDown, Mod: ModShift | ModAlt | ModCtrl},
|
||||
|
||||
"kbs": {Code: KeyBackspace},
|
||||
"kcbt": {Code: KeyTab, Mod: ModShift},
|
||||
|
||||
// Function keys
|
||||
// This only includes the first 12 function keys. The rest are treated
|
||||
// as modifiers of the first 12.
|
||||
// Take a look at XTerm modifyFunctionKeys
|
||||
//
|
||||
// XXX: To use unambiguous function keys, use fixterms or kitty clipboard.
|
||||
//
|
||||
// See https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyFunctionKeys
|
||||
// See https://invisible-island.net/xterm/terminfo.html
|
||||
|
||||
"kf1": {Code: KeyF1},
|
||||
"kf2": {Code: KeyF2},
|
||||
"kf3": {Code: KeyF3},
|
||||
"kf4": {Code: KeyF4},
|
||||
"kf5": {Code: KeyF5},
|
||||
"kf6": {Code: KeyF6},
|
||||
"kf7": {Code: KeyF7},
|
||||
"kf8": {Code: KeyF8},
|
||||
"kf9": {Code: KeyF9},
|
||||
"kf10": {Code: KeyF10},
|
||||
"kf11": {Code: KeyF11},
|
||||
"kf12": {Code: KeyF12},
|
||||
"kf13": {Code: KeyF1, Mod: ModShift},
|
||||
"kf14": {Code: KeyF2, Mod: ModShift},
|
||||
"kf15": {Code: KeyF3, Mod: ModShift},
|
||||
"kf16": {Code: KeyF4, Mod: ModShift},
|
||||
"kf17": {Code: KeyF5, Mod: ModShift},
|
||||
"kf18": {Code: KeyF6, Mod: ModShift},
|
||||
"kf19": {Code: KeyF7, Mod: ModShift},
|
||||
"kf20": {Code: KeyF8, Mod: ModShift},
|
||||
"kf21": {Code: KeyF9, Mod: ModShift},
|
||||
"kf22": {Code: KeyF10, Mod: ModShift},
|
||||
"kf23": {Code: KeyF11, Mod: ModShift},
|
||||
"kf24": {Code: KeyF12, Mod: ModShift},
|
||||
"kf25": {Code: KeyF1, Mod: ModCtrl},
|
||||
"kf26": {Code: KeyF2, Mod: ModCtrl},
|
||||
"kf27": {Code: KeyF3, Mod: ModCtrl},
|
||||
"kf28": {Code: KeyF4, Mod: ModCtrl},
|
||||
"kf29": {Code: KeyF5, Mod: ModCtrl},
|
||||
"kf30": {Code: KeyF6, Mod: ModCtrl},
|
||||
"kf31": {Code: KeyF7, Mod: ModCtrl},
|
||||
"kf32": {Code: KeyF8, Mod: ModCtrl},
|
||||
"kf33": {Code: KeyF9, Mod: ModCtrl},
|
||||
"kf34": {Code: KeyF10, Mod: ModCtrl},
|
||||
"kf35": {Code: KeyF11, Mod: ModCtrl},
|
||||
"kf36": {Code: KeyF12, Mod: ModCtrl},
|
||||
"kf37": {Code: KeyF1, Mod: ModShift | ModCtrl},
|
||||
"kf38": {Code: KeyF2, Mod: ModShift | ModCtrl},
|
||||
"kf39": {Code: KeyF3, Mod: ModShift | ModCtrl},
|
||||
"kf40": {Code: KeyF4, Mod: ModShift | ModCtrl},
|
||||
"kf41": {Code: KeyF5, Mod: ModShift | ModCtrl},
|
||||
"kf42": {Code: KeyF6, Mod: ModShift | ModCtrl},
|
||||
"kf43": {Code: KeyF7, Mod: ModShift | ModCtrl},
|
||||
"kf44": {Code: KeyF8, Mod: ModShift | ModCtrl},
|
||||
"kf45": {Code: KeyF9, Mod: ModShift | ModCtrl},
|
||||
"kf46": {Code: KeyF10, Mod: ModShift | ModCtrl},
|
||||
"kf47": {Code: KeyF11, Mod: ModShift | ModCtrl},
|
||||
"kf48": {Code: KeyF12, Mod: ModShift | ModCtrl},
|
||||
"kf49": {Code: KeyF1, Mod: ModAlt},
|
||||
"kf50": {Code: KeyF2, Mod: ModAlt},
|
||||
"kf51": {Code: KeyF3, Mod: ModAlt},
|
||||
"kf52": {Code: KeyF4, Mod: ModAlt},
|
||||
"kf53": {Code: KeyF5, Mod: ModAlt},
|
||||
"kf54": {Code: KeyF6, Mod: ModAlt},
|
||||
"kf55": {Code: KeyF7, Mod: ModAlt},
|
||||
"kf56": {Code: KeyF8, Mod: ModAlt},
|
||||
"kf57": {Code: KeyF9, Mod: ModAlt},
|
||||
"kf58": {Code: KeyF10, Mod: ModAlt},
|
||||
"kf59": {Code: KeyF11, Mod: ModAlt},
|
||||
"kf60": {Code: KeyF12, Mod: ModAlt},
|
||||
"kf61": {Code: KeyF1, Mod: ModShift | ModAlt},
|
||||
"kf62": {Code: KeyF2, Mod: ModShift | ModAlt},
|
||||
"kf63": {Code: KeyF3, Mod: ModShift | ModAlt},
|
||||
}
|
||||
|
||||
// Preserve F keys from F13 to F63 instead of using them for F-keys
|
||||
// modifiers.
|
||||
if flags&FlagFKeys != 0 {
|
||||
keys["kf13"] = Key{Code: KeyF13}
|
||||
keys["kf14"] = Key{Code: KeyF14}
|
||||
keys["kf15"] = Key{Code: KeyF15}
|
||||
keys["kf16"] = Key{Code: KeyF16}
|
||||
keys["kf17"] = Key{Code: KeyF17}
|
||||
keys["kf18"] = Key{Code: KeyF18}
|
||||
keys["kf19"] = Key{Code: KeyF19}
|
||||
keys["kf20"] = Key{Code: KeyF20}
|
||||
keys["kf21"] = Key{Code: KeyF21}
|
||||
keys["kf22"] = Key{Code: KeyF22}
|
||||
keys["kf23"] = Key{Code: KeyF23}
|
||||
keys["kf24"] = Key{Code: KeyF24}
|
||||
keys["kf25"] = Key{Code: KeyF25}
|
||||
keys["kf26"] = Key{Code: KeyF26}
|
||||
keys["kf27"] = Key{Code: KeyF27}
|
||||
keys["kf28"] = Key{Code: KeyF28}
|
||||
keys["kf29"] = Key{Code: KeyF29}
|
||||
keys["kf30"] = Key{Code: KeyF30}
|
||||
keys["kf31"] = Key{Code: KeyF31}
|
||||
keys["kf32"] = Key{Code: KeyF32}
|
||||
keys["kf33"] = Key{Code: KeyF33}
|
||||
keys["kf34"] = Key{Code: KeyF34}
|
||||
keys["kf35"] = Key{Code: KeyF35}
|
||||
keys["kf36"] = Key{Code: KeyF36}
|
||||
keys["kf37"] = Key{Code: KeyF37}
|
||||
keys["kf38"] = Key{Code: KeyF38}
|
||||
keys["kf39"] = Key{Code: KeyF39}
|
||||
keys["kf40"] = Key{Code: KeyF40}
|
||||
keys["kf41"] = Key{Code: KeyF41}
|
||||
keys["kf42"] = Key{Code: KeyF42}
|
||||
keys["kf43"] = Key{Code: KeyF43}
|
||||
keys["kf44"] = Key{Code: KeyF44}
|
||||
keys["kf45"] = Key{Code: KeyF45}
|
||||
keys["kf46"] = Key{Code: KeyF46}
|
||||
keys["kf47"] = Key{Code: KeyF47}
|
||||
keys["kf48"] = Key{Code: KeyF48}
|
||||
keys["kf49"] = Key{Code: KeyF49}
|
||||
keys["kf50"] = Key{Code: KeyF50}
|
||||
keys["kf51"] = Key{Code: KeyF51}
|
||||
keys["kf52"] = Key{Code: KeyF52}
|
||||
keys["kf53"] = Key{Code: KeyF53}
|
||||
keys["kf54"] = Key{Code: KeyF54}
|
||||
keys["kf55"] = Key{Code: KeyF55}
|
||||
keys["kf56"] = Key{Code: KeyF56}
|
||||
keys["kf57"] = Key{Code: KeyF57}
|
||||
keys["kf58"] = Key{Code: KeyF58}
|
||||
keys["kf59"] = Key{Code: KeyF59}
|
||||
keys["kf60"] = Key{Code: KeyF60}
|
||||
keys["kf61"] = Key{Code: KeyF61}
|
||||
keys["kf62"] = Key{Code: KeyF62}
|
||||
keys["kf63"] = Key{Code: KeyF63}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
47
packages/client/tui/input/xterm.go
Normal file
47
packages/client/tui/input/xterm.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func parseXTermModifyOtherKeys(params ansi.Params) Event {
|
||||
// XTerm modify other keys starts with ESC [ 27 ; <modifier> ; <code> ~
|
||||
xmod, _, _ := params.Param(1, 1)
|
||||
xrune, _, _ := params.Param(2, 1)
|
||||
mod := KeyMod(xmod - 1)
|
||||
r := rune(xrune)
|
||||
|
||||
switch r {
|
||||
case ansi.BS:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyBackspace}
|
||||
case ansi.HT:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyTab}
|
||||
case ansi.CR:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyEnter}
|
||||
case ansi.ESC:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyEscape}
|
||||
case ansi.DEL:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyBackspace}
|
||||
}
|
||||
|
||||
// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
|
||||
k := KeyPressEvent{Code: r, Mod: mod}
|
||||
if k.Mod <= ModShift {
|
||||
k.Text = string(r)
|
||||
}
|
||||
|
||||
return k
|
||||
}
|
||||
|
||||
// TerminalVersionEvent is a message that represents the terminal version.
|
||||
type TerminalVersionEvent string
|
||||
|
||||
// ModifyOtherKeysEvent represents a modifyOtherKeys event.
|
||||
//
|
||||
// 0: disable
|
||||
// 1: enable mode 1
|
||||
// 2: enable mode 2
|
||||
//
|
||||
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_
|
||||
// See: https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyOtherKeys
|
||||
type ModifyOtherKeysEvent uint8
|
||||
41
packages/client/tui/internal/api/api.go
Normal file
41
packages/client/tui/internal/api/api.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Path string `json:"path"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, program *tea.Program, client *opencode.Client) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
var req Request
|
||||
if err := client.Get(ctx, "/tui/control/next", nil, &req); err != nil {
|
||||
log.Printf("Error getting next request: %v", err)
|
||||
continue
|
||||
}
|
||||
program.Send(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Reply(ctx context.Context, client *opencode.Client, response interface{}) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := client.Post(ctx, "/tui/control/response", response, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
588
packages/client/tui/internal/app/app.go
Normal file
588
packages/client/tui/internal/app/app.go
Normal file
@@ -0,0 +1,588 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/id"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Info opencode.MessageUnion
|
||||
Parts []opencode.PartUnion
|
||||
}
|
||||
|
||||
type App struct {
|
||||
Info opencode.App
|
||||
Modes []opencode.Mode
|
||||
Providers []opencode.Provider
|
||||
Version string
|
||||
StatePath string
|
||||
Config *opencode.Config
|
||||
Client *opencode.Client
|
||||
State *State
|
||||
ModeIndex int
|
||||
Mode *opencode.Mode
|
||||
Provider *opencode.Provider
|
||||
Model *opencode.Model
|
||||
Session *opencode.Session
|
||||
Messages []Message
|
||||
Commands commands.CommandRegistry
|
||||
InitialModel *string
|
||||
InitialPrompt *string
|
||||
IntitialMode *string
|
||||
compactCancel context.CancelFunc
|
||||
IsLeaderSequence bool
|
||||
}
|
||||
|
||||
type SessionCreatedMsg = struct {
|
||||
Session *opencode.Session
|
||||
}
|
||||
type SessionSelectedMsg = *opencode.Session
|
||||
type MessageRevertedMsg struct {
|
||||
Session opencode.Session
|
||||
Message Message
|
||||
}
|
||||
type SessionUnrevertedMsg struct {
|
||||
Session opencode.Session
|
||||
}
|
||||
type SessionLoadedMsg struct{}
|
||||
type ModelSelectedMsg struct {
|
||||
Provider opencode.Provider
|
||||
Model opencode.Model
|
||||
}
|
||||
type SessionClearedMsg struct{}
|
||||
type CompactSessionMsg struct{}
|
||||
type SendPrompt = Prompt
|
||||
type SetEditorContentMsg struct {
|
||||
Text string
|
||||
}
|
||||
type FileRenderedMsg struct {
|
||||
FilePath string
|
||||
}
|
||||
|
||||
func New(
|
||||
ctx context.Context,
|
||||
version string,
|
||||
appInfo opencode.App,
|
||||
modes []opencode.Mode,
|
||||
httpClient *opencode.Client,
|
||||
initialModel *string,
|
||||
initialPrompt *string,
|
||||
initialMode *string,
|
||||
) (*App, error) {
|
||||
util.RootPath = appInfo.Path.Root
|
||||
util.CwdPath = appInfo.Path.Cwd
|
||||
|
||||
configInfo, err := httpClient.Config.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if configInfo.Keybinds.Leader == "" {
|
||||
configInfo.Keybinds.Leader = "ctrl+x"
|
||||
}
|
||||
|
||||
appStatePath := filepath.Join(appInfo.Path.State, "tui")
|
||||
appState, err := LoadState(appStatePath)
|
||||
if err != nil {
|
||||
appState = NewState()
|
||||
SaveState(appStatePath, appState)
|
||||
}
|
||||
|
||||
if appState.ModeModel == nil {
|
||||
appState.ModeModel = make(map[string]ModeModel)
|
||||
}
|
||||
|
||||
if configInfo.Theme != "" {
|
||||
appState.Theme = configInfo.Theme
|
||||
}
|
||||
|
||||
themeEnv := os.Getenv("OPENCODE_THEME")
|
||||
if themeEnv != "" {
|
||||
appState.Theme = themeEnv
|
||||
}
|
||||
|
||||
var modeIndex int
|
||||
var mode *opencode.Mode
|
||||
modeName := "build"
|
||||
if appState.Mode != "" {
|
||||
modeName = appState.Mode
|
||||
}
|
||||
if initialMode != nil && *initialMode != "" {
|
||||
modeName = *initialMode
|
||||
}
|
||||
for i, m := range modes {
|
||||
if m.Name == modeName {
|
||||
modeIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
mode = &modes[modeIndex]
|
||||
|
||||
if mode.Model.ModelID != "" {
|
||||
appState.ModeModel[mode.Name] = ModeModel{
|
||||
ProviderID: mode.Model.ProviderID,
|
||||
ModelID: mode.Model.ModelID,
|
||||
}
|
||||
}
|
||||
|
||||
if err := theme.LoadThemesFromDirectories(
|
||||
appInfo.Path.Config,
|
||||
appInfo.Path.Root,
|
||||
appInfo.Path.Cwd,
|
||||
); err != nil {
|
||||
slog.Warn("Failed to load themes from directories", "error", err)
|
||||
}
|
||||
|
||||
if appState.Theme != "" {
|
||||
if appState.Theme == "system" && styles.Terminal != nil {
|
||||
theme.UpdateSystemTheme(
|
||||
styles.Terminal.Background,
|
||||
styles.Terminal.BackgroundIsDark,
|
||||
)
|
||||
}
|
||||
theme.SetTheme(appState.Theme)
|
||||
}
|
||||
|
||||
slog.Debug("Loaded config", "config", configInfo)
|
||||
|
||||
app := &App{
|
||||
Info: appInfo,
|
||||
Modes: modes,
|
||||
Version: version,
|
||||
StatePath: appStatePath,
|
||||
Config: configInfo,
|
||||
State: appState,
|
||||
Client: httpClient,
|
||||
ModeIndex: modeIndex,
|
||||
Mode: mode,
|
||||
Session: &opencode.Session{},
|
||||
Messages: []Message{},
|
||||
Commands: commands.LoadFromConfig(configInfo),
|
||||
InitialModel: initialModel,
|
||||
InitialPrompt: initialPrompt,
|
||||
IntitialMode: initialMode,
|
||||
}
|
||||
|
||||
if app.Version != "dev" {
|
||||
delete(app.Commands, commands.MessagesUndoCommand)
|
||||
delete(app.Commands, commands.MessagesRedoCommand)
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *App) Keybind(commandName commands.CommandName) string {
|
||||
command := a.Commands[commandName]
|
||||
kb := command.Keybindings[0]
|
||||
key := kb.Key
|
||||
if kb.RequiresLeader {
|
||||
key = a.Config.Keybinds.Leader + " " + kb.Key
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func (a *App) Key(commandName commands.CommandName) string {
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
|
||||
muted := styles.NewStyle().
|
||||
Background(t.Background()).
|
||||
Foreground(t.TextMuted()).
|
||||
Faint(true).
|
||||
Render
|
||||
command := a.Commands[commandName]
|
||||
key := a.Keybind(commandName)
|
||||
return base(key) + muted(" "+command.Description)
|
||||
}
|
||||
|
||||
func SetClipboard(text string) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
clipboard.Write(clipboard.FmtText, []byte(text))
|
||||
return nil
|
||||
})
|
||||
// try to set the clipboard using OSC52 for terminals that support it
|
||||
cmds = append(cmds, tea.SetClipboard(text))
|
||||
return tea.Sequence(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
|
||||
if forward {
|
||||
a.ModeIndex++
|
||||
if a.ModeIndex >= len(a.Modes) {
|
||||
a.ModeIndex = 0
|
||||
}
|
||||
} else {
|
||||
a.ModeIndex--
|
||||
if a.ModeIndex < 0 {
|
||||
a.ModeIndex = len(a.Modes) - 1
|
||||
}
|
||||
}
|
||||
a.Mode = &a.Modes[a.ModeIndex]
|
||||
|
||||
modelID := a.Mode.Model.ModelID
|
||||
providerID := a.Mode.Model.ProviderID
|
||||
if modelID == "" {
|
||||
if model, ok := a.State.ModeModel[a.Mode.Name]; ok {
|
||||
modelID = model.ModelID
|
||||
providerID = model.ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
if modelID != "" {
|
||||
for _, provider := range a.Providers {
|
||||
if provider.ID == providerID {
|
||||
a.Provider = &provider
|
||||
for _, model := range provider.Models {
|
||||
if model.ID == modelID {
|
||||
a.Model = &model
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.State.Mode = a.Mode.Name
|
||||
return a, a.SaveState()
|
||||
}
|
||||
|
||||
func (a *App) SwitchMode() (*App, tea.Cmd) {
|
||||
return a.cycleMode(true)
|
||||
}
|
||||
|
||||
func (a *App) SwitchModeReverse() (*App, tea.Cmd) {
|
||||
return a.cycleMode(false)
|
||||
}
|
||||
|
||||
func (a *App) InitializeProvider() tea.Cmd {
|
||||
providersResponse, err := a.Client.App.Providers(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("Failed to list providers", "error", err)
|
||||
// TODO: notify user
|
||||
return nil
|
||||
}
|
||||
providers := providersResponse.Providers
|
||||
var defaultProvider *opencode.Provider
|
||||
var defaultModel *opencode.Model
|
||||
|
||||
var anthropic *opencode.Provider
|
||||
for _, provider := range providers {
|
||||
if provider.ID == "anthropic" {
|
||||
anthropic = &provider
|
||||
}
|
||||
}
|
||||
|
||||
// default to anthropic if available
|
||||
if anthropic != nil {
|
||||
defaultProvider = anthropic
|
||||
defaultModel = getDefaultModel(providersResponse, *anthropic)
|
||||
}
|
||||
|
||||
for _, provider := range providers {
|
||||
if defaultProvider == nil || defaultModel == nil {
|
||||
defaultProvider = &provider
|
||||
defaultModel = getDefaultModel(providersResponse, provider)
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
slog.Error("No providers configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
a.Providers = providers
|
||||
|
||||
// retains backwards compatibility with old state format
|
||||
if model, ok := a.State.ModeModel[a.State.Mode]; ok {
|
||||
a.State.Provider = model.ProviderID
|
||||
a.State.Model = model.ModelID
|
||||
}
|
||||
|
||||
var currentProvider *opencode.Provider
|
||||
var currentModel *opencode.Model
|
||||
for _, provider := range providers {
|
||||
if provider.ID == a.State.Provider {
|
||||
currentProvider = &provider
|
||||
|
||||
for _, model := range provider.Models {
|
||||
if model.ID == a.State.Model {
|
||||
currentModel = &model
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentProvider == nil || currentModel == nil {
|
||||
currentProvider = defaultProvider
|
||||
currentModel = defaultModel
|
||||
}
|
||||
|
||||
var initialProvider *opencode.Provider
|
||||
var initialModel *opencode.Model
|
||||
if a.InitialModel != nil && *a.InitialModel != "" {
|
||||
splits := strings.Split(*a.InitialModel, "/")
|
||||
for _, provider := range providers {
|
||||
if provider.ID == splits[0] {
|
||||
initialProvider = &provider
|
||||
for _, model := range provider.Models {
|
||||
modelID := strings.Join(splits[1:], "/")
|
||||
if model.ID == modelID {
|
||||
initialModel = &model
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if initialProvider != nil && initialModel != nil {
|
||||
currentProvider = initialProvider
|
||||
currentModel = initialModel
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{
|
||||
Provider: *currentProvider,
|
||||
Model: *currentModel,
|
||||
}))
|
||||
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
|
||||
cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
|
||||
}
|
||||
return tea.Sequence(cmds...)
|
||||
}
|
||||
|
||||
func getDefaultModel(
|
||||
response *opencode.AppProvidersResponse,
|
||||
provider opencode.Provider,
|
||||
) *opencode.Model {
|
||||
if match, ok := response.Default[provider.ID]; ok {
|
||||
model := provider.Models[match]
|
||||
return &model
|
||||
} else {
|
||||
for _, model := range provider.Models {
|
||||
return &model
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) IsBusy() bool {
|
||||
if len(a.Messages) == 0 {
|
||||
return false
|
||||
}
|
||||
lastMessage := a.Messages[len(a.Messages)-1]
|
||||
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
|
||||
return casted.Time.Completed == 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) SaveState() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := SaveState(a.StatePath, a.State)
|
||||
if err != nil {
|
||||
slog.Error("Failed to save state", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
||||
cmds := []tea.Cmd{}
|
||||
|
||||
session, err := a.CreateSession(ctx)
|
||||
if err != nil {
|
||||
// status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
a.Session = session
|
||||
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
|
||||
|
||||
go func() {
|
||||
_, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
|
||||
MessageID: opencode.F(id.Ascending(id.Message)),
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize project", "error", err)
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
||||
if a.compactCancel != nil {
|
||||
a.compactCancel()
|
||||
}
|
||||
|
||||
compactCtx, cancel := context.WithCancel(ctx)
|
||||
a.compactCancel = cancel
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
a.compactCancel = nil
|
||||
}()
|
||||
|
||||
_, err := a.Client.Session.Summarize(
|
||||
compactCtx,
|
||||
a.Session.ID,
|
||||
opencode.SessionSummarizeParams{
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if compactCtx.Err() != context.Canceled {
|
||||
slog.Error("Failed to compact session", "error", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) MarkProjectInitialized(ctx context.Context) error {
|
||||
_, err := a.Client.App.Init(ctx)
|
||||
if err != nil {
|
||||
slog.Error("Failed to mark project as initialized", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
|
||||
session, err := a.Client.Session.New(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
if a.Session.ID == "" {
|
||||
session, err := a.CreateSession(ctx)
|
||||
if err != nil {
|
||||
return a, toast.NewErrorToast(err.Error())
|
||||
}
|
||||
a.Session = session
|
||||
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
|
||||
}
|
||||
|
||||
messageID := id.Ascending(id.Message)
|
||||
message := prompt.ToMessage(messageID, a.Session.ID)
|
||||
|
||||
a.Messages = append(a.Messages, message)
|
||||
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
Mode: opencode.F(a.Mode.Name),
|
||||
MessageID: opencode.F(messageID),
|
||||
Parts: opencode.F(message.ToSessionChatParams()),
|
||||
})
|
||||
if err != nil {
|
||||
errormsg := fmt.Sprintf("failed to send message: %v", err)
|
||||
slog.Error(errormsg)
|
||||
return toast.NewErrorToast(errormsg)()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// The actual response will come through SSE
|
||||
// For now, just return success
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
||||
// Cancel any running compact operation
|
||||
if a.compactCancel != nil {
|
||||
a.compactCancel()
|
||||
a.compactCancel = nil
|
||||
}
|
||||
|
||||
_, err := a.Client.Session.Abort(ctx, sessionID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to cancel session", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
|
||||
response, err := a.Client.Session.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response == nil {
|
||||
return []opencode.Session{}, nil
|
||||
}
|
||||
sessions := *response
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
_, err := a.Client.Session.Delete(ctx, sessionID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to delete session", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
|
||||
response, err := a.Client.Session.Messages(ctx, sessionId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response == nil {
|
||||
return []Message{}, nil
|
||||
}
|
||||
messages := []Message{}
|
||||
for _, message := range *response {
|
||||
msg := Message{
|
||||
Info: message.Info.AsUnion(),
|
||||
Parts: []opencode.PartUnion{},
|
||||
}
|
||||
for _, part := range message.Parts {
|
||||
msg.Parts = append(msg.Parts, part.AsUnion())
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
|
||||
response, err := a.Client.App.Providers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response == nil {
|
||||
return []opencode.Provider{}, nil
|
||||
}
|
||||
|
||||
providers := *response
|
||||
return providers.Providers, nil
|
||||
}
|
||||
|
||||
// func (a *App) loadCustomKeybinds() {
|
||||
//
|
||||
// }
|
||||
303
packages/client/tui/internal/app/prompt.go
Normal file
303
packages/client/tui/internal/app/prompt.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/attachment"
|
||||
"github.com/sst/opencode/internal/id"
|
||||
)
|
||||
|
||||
type Prompt struct {
|
||||
Text string `toml:"text"`
|
||||
Attachments []*attachment.Attachment `toml:"attachments"`
|
||||
}
|
||||
|
||||
func (p Prompt) ToMessage(
|
||||
messageID string,
|
||||
sessionID string,
|
||||
) Message {
|
||||
message := opencode.UserMessage{
|
||||
ID: messageID,
|
||||
SessionID: sessionID,
|
||||
Role: opencode.UserMessageRoleUser,
|
||||
Time: opencode.UserMessageTime{
|
||||
Created: float64(time.Now().UnixMilli()),
|
||||
},
|
||||
}
|
||||
|
||||
text := p.Text
|
||||
textAttachments := []*attachment.Attachment{}
|
||||
for _, attachment := range p.Attachments {
|
||||
if attachment.Type == "text" {
|
||||
textAttachments = append(textAttachments, attachment)
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(textAttachments)-1; i++ {
|
||||
for j := i + 1; j < len(textAttachments); j++ {
|
||||
if textAttachments[i].StartIndex < textAttachments[j].StartIndex {
|
||||
textAttachments[i], textAttachments[j] = textAttachments[j], textAttachments[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, att := range textAttachments {
|
||||
if source, ok := att.GetTextSource(); ok {
|
||||
text = text[:att.StartIndex] + source.Value + text[att.EndIndex:]
|
||||
}
|
||||
}
|
||||
|
||||
parts := []opencode.PartUnion{opencode.TextPart{
|
||||
ID: id.Ascending(id.Part),
|
||||
MessageID: messageID,
|
||||
SessionID: sessionID,
|
||||
Type: opencode.TextPartTypeText,
|
||||
Text: text,
|
||||
}}
|
||||
for _, attachment := range p.Attachments {
|
||||
text := opencode.FilePartSourceText{
|
||||
Start: int64(attachment.StartIndex),
|
||||
End: int64(attachment.EndIndex),
|
||||
Value: attachment.Display,
|
||||
}
|
||||
source := &opencode.FilePartSource{}
|
||||
switch attachment.Type {
|
||||
case "text":
|
||||
continue
|
||||
case "file":
|
||||
if fileSource, ok := attachment.GetFileSource(); ok {
|
||||
source = &opencode.FilePartSource{
|
||||
Text: text,
|
||||
Path: fileSource.Path,
|
||||
Type: opencode.FilePartSourceTypeFile,
|
||||
}
|
||||
}
|
||||
case "symbol":
|
||||
if symbolSource, ok := attachment.GetSymbolSource(); ok {
|
||||
source = &opencode.FilePartSource{
|
||||
Text: text,
|
||||
Path: symbolSource.Path,
|
||||
Type: opencode.FilePartSourceTypeSymbol,
|
||||
Kind: int64(symbolSource.Kind),
|
||||
Name: symbolSource.Name,
|
||||
Range: opencode.SymbolSourceRange{
|
||||
Start: opencode.SymbolSourceRangeStart{
|
||||
Line: float64(symbolSource.Range.Start.Line),
|
||||
Character: float64(symbolSource.Range.Start.Char),
|
||||
},
|
||||
End: opencode.SymbolSourceRangeEnd{
|
||||
Line: float64(symbolSource.Range.End.Line),
|
||||
Character: float64(symbolSource.Range.End.Char),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
parts = append(parts, opencode.FilePart{
|
||||
ID: id.Ascending(id.Part),
|
||||
MessageID: messageID,
|
||||
SessionID: sessionID,
|
||||
Type: opencode.FilePartTypeFile,
|
||||
Filename: attachment.Filename,
|
||||
Mime: attachment.MediaType,
|
||||
URL: attachment.URL,
|
||||
Source: *source,
|
||||
})
|
||||
}
|
||||
return Message{
|
||||
Info: message,
|
||||
Parts: parts,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Message) ToPrompt() (*Prompt, error) {
|
||||
switch m.Info.(type) {
|
||||
case opencode.UserMessage:
|
||||
text := ""
|
||||
attachments := []*attachment.Attachment{}
|
||||
for _, part := range m.Parts {
|
||||
switch p := part.(type) {
|
||||
case opencode.TextPart:
|
||||
if p.Synthetic {
|
||||
continue
|
||||
}
|
||||
text += p.Text + " "
|
||||
case opencode.FilePart:
|
||||
switch p.Source.Type {
|
||||
case "file":
|
||||
attachments = append(attachments, &attachment.Attachment{
|
||||
ID: p.ID,
|
||||
Type: "file",
|
||||
Display: p.Source.Text.Value,
|
||||
URL: p.URL,
|
||||
Filename: p.Filename,
|
||||
MediaType: p.Mime,
|
||||
StartIndex: int(p.Source.Text.Start),
|
||||
EndIndex: int(p.Source.Text.End),
|
||||
Source: &attachment.FileSource{
|
||||
Path: p.Source.Path,
|
||||
Mime: p.Mime,
|
||||
},
|
||||
})
|
||||
case "symbol":
|
||||
r := p.Source.Range.(opencode.SymbolSourceRange)
|
||||
attachments = append(attachments, &attachment.Attachment{
|
||||
ID: p.ID,
|
||||
Type: "symbol",
|
||||
Display: p.Source.Text.Value,
|
||||
URL: p.URL,
|
||||
Filename: p.Filename,
|
||||
MediaType: p.Mime,
|
||||
StartIndex: int(p.Source.Text.Start),
|
||||
EndIndex: int(p.Source.Text.End),
|
||||
Source: &attachment.SymbolSource{
|
||||
Path: p.Source.Path,
|
||||
Name: p.Source.Name,
|
||||
Kind: int(p.Source.Kind),
|
||||
Range: attachment.SymbolRange{
|
||||
Start: attachment.Position{
|
||||
Line: int(r.Start.Line),
|
||||
Char: int(r.Start.Character),
|
||||
},
|
||||
End: attachment.Position{
|
||||
Line: int(r.End.Line),
|
||||
Char: int(r.End.Character),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return &Prompt{
|
||||
Text: text,
|
||||
Attachments: attachments,
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.New("unknown message type")
|
||||
}
|
||||
|
||||
func (m Message) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
|
||||
parts := []opencode.SessionChatParamsPartUnion{}
|
||||
for _, part := range m.Parts {
|
||||
switch p := part.(type) {
|
||||
case opencode.TextPart:
|
||||
parts = append(parts, opencode.TextPartInputParam{
|
||||
ID: opencode.F(p.ID),
|
||||
Type: opencode.F(opencode.TextPartInputTypeText),
|
||||
Text: opencode.F(p.Text),
|
||||
Synthetic: opencode.F(p.Synthetic),
|
||||
Time: opencode.F(opencode.TextPartInputTimeParam{
|
||||
Start: opencode.F(p.Time.Start),
|
||||
End: opencode.F(p.Time.End),
|
||||
}),
|
||||
})
|
||||
case opencode.FilePart:
|
||||
var source opencode.FilePartSourceUnionParam
|
||||
switch p.Source.Type {
|
||||
case "file":
|
||||
source = opencode.FileSourceParam{
|
||||
Type: opencode.F(opencode.FileSourceTypeFile),
|
||||
Path: opencode.F(p.Source.Path),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Start: opencode.F(int64(p.Source.Text.Start)),
|
||||
End: opencode.F(int64(p.Source.Text.End)),
|
||||
Value: opencode.F(p.Source.Text.Value),
|
||||
}),
|
||||
}
|
||||
case "symbol":
|
||||
source = opencode.SymbolSourceParam{
|
||||
Type: opencode.F(opencode.SymbolSourceTypeSymbol),
|
||||
Path: opencode.F(p.Source.Path),
|
||||
Name: opencode.F(p.Source.Name),
|
||||
Kind: opencode.F(p.Source.Kind),
|
||||
Range: opencode.F(opencode.SymbolSourceRangeParam{
|
||||
Start: opencode.F(opencode.SymbolSourceRangeStartParam{
|
||||
Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Line)),
|
||||
Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Character)),
|
||||
}),
|
||||
End: opencode.F(opencode.SymbolSourceRangeEndParam{
|
||||
Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Line)),
|
||||
Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Character)),
|
||||
}),
|
||||
}),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Value: opencode.F(p.Source.Text.Value),
|
||||
Start: opencode.F(p.Source.Text.Start),
|
||||
End: opencode.F(p.Source.Text.End),
|
||||
}),
|
||||
}
|
||||
}
|
||||
parts = append(parts, opencode.FilePartInputParam{
|
||||
ID: opencode.F(p.ID),
|
||||
Type: opencode.F(opencode.FilePartInputTypeFile),
|
||||
Mime: opencode.F(p.Mime),
|
||||
URL: opencode.F(p.URL),
|
||||
Filename: opencode.F(p.Filename),
|
||||
Source: opencode.F(source),
|
||||
})
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func (p Prompt) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
|
||||
parts := []opencode.SessionChatParamsPartUnion{
|
||||
opencode.TextPartInputParam{
|
||||
Type: opencode.F(opencode.TextPartInputTypeText),
|
||||
Text: opencode.F(p.Text),
|
||||
},
|
||||
}
|
||||
for _, att := range p.Attachments {
|
||||
filePart := opencode.FilePartInputParam{
|
||||
Type: opencode.F(opencode.FilePartInputTypeFile),
|
||||
Mime: opencode.F(att.MediaType),
|
||||
URL: opencode.F(att.URL),
|
||||
Filename: opencode.F(att.Filename),
|
||||
}
|
||||
switch att.Type {
|
||||
case "file":
|
||||
if fs, ok := att.GetFileSource(); ok {
|
||||
filePart.Source = opencode.F(
|
||||
opencode.FilePartSourceUnionParam(opencode.FileSourceParam{
|
||||
Type: opencode.F(opencode.FileSourceTypeFile),
|
||||
Path: opencode.F(fs.Path),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Start: opencode.F(int64(att.StartIndex)),
|
||||
End: opencode.F(int64(att.EndIndex)),
|
||||
Value: opencode.F(att.Display),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
case "symbol":
|
||||
if ss, ok := att.GetSymbolSource(); ok {
|
||||
filePart.Source = opencode.F(
|
||||
opencode.FilePartSourceUnionParam(opencode.SymbolSourceParam{
|
||||
Type: opencode.F(opencode.SymbolSourceTypeSymbol),
|
||||
Path: opencode.F(ss.Path),
|
||||
Name: opencode.F(ss.Name),
|
||||
Kind: opencode.F(int64(ss.Kind)),
|
||||
Range: opencode.F(opencode.SymbolSourceRangeParam{
|
||||
Start: opencode.F(opencode.SymbolSourceRangeStartParam{
|
||||
Line: opencode.F(float64(ss.Range.Start.Line)),
|
||||
Character: opencode.F(float64(ss.Range.Start.Char)),
|
||||
}),
|
||||
End: opencode.F(opencode.SymbolSourceRangeEndParam{
|
||||
Line: opencode.F(float64(ss.Range.End.Line)),
|
||||
Character: opencode.F(float64(ss.Range.End.Char)),
|
||||
}),
|
||||
}),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Start: opencode.F(int64(att.StartIndex)),
|
||||
End: opencode.F(int64(att.EndIndex)),
|
||||
Value: opencode.F(att.Display),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
parts = append(parts, filePart)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
123
packages/client/tui/internal/app/state.go
Normal file
123
packages/client/tui/internal/app/state.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type ModelUsage struct {
|
||||
ProviderID string `toml:"provider_id"`
|
||||
ModelID string `toml:"model_id"`
|
||||
LastUsed time.Time `toml:"last_used"`
|
||||
}
|
||||
|
||||
type ModeModel struct {
|
||||
ProviderID string `toml:"provider_id"`
|
||||
ModelID string `toml:"model_id"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Theme string `toml:"theme"`
|
||||
ModeModel map[string]ModeModel `toml:"mode_model"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
Mode string `toml:"mode"`
|
||||
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
|
||||
MessagesRight bool `toml:"messages_right"`
|
||||
SplitDiff bool `toml:"split_diff"`
|
||||
MessageHistory []Prompt `toml:"message_history"`
|
||||
}
|
||||
|
||||
func NewState() *State {
|
||||
return &State{
|
||||
Theme: "opencode",
|
||||
Mode: "build",
|
||||
ModeModel: make(map[string]ModeModel),
|
||||
RecentlyUsedModels: make([]ModelUsage, 0),
|
||||
MessageHistory: make([]Prompt, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateModelUsage updates the recently used models list with the specified model
|
||||
func (s *State) UpdateModelUsage(providerID, modelID string) {
|
||||
now := time.Now()
|
||||
|
||||
// Check if this model is already in the list
|
||||
for i, usage := range s.RecentlyUsedModels {
|
||||
if usage.ProviderID == providerID && usage.ModelID == modelID {
|
||||
s.RecentlyUsedModels[i].LastUsed = now
|
||||
usage := s.RecentlyUsedModels[i]
|
||||
copy(s.RecentlyUsedModels[1:i+1], s.RecentlyUsedModels[0:i])
|
||||
s.RecentlyUsedModels[0] = usage
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newUsage := ModelUsage{
|
||||
ProviderID: providerID,
|
||||
ModelID: modelID,
|
||||
LastUsed: now,
|
||||
}
|
||||
|
||||
// Prepend to slice and limit to last 50 entries
|
||||
s.RecentlyUsedModels = append([]ModelUsage{newUsage}, s.RecentlyUsedModels...)
|
||||
if len(s.RecentlyUsedModels) > 50 {
|
||||
s.RecentlyUsedModels = s.RecentlyUsedModels[:50]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
|
||||
for i, usage := range s.RecentlyUsedModels {
|
||||
if usage.ProviderID == providerID && usage.ModelID == modelID {
|
||||
s.RecentlyUsedModels = append(s.RecentlyUsedModels[:i], s.RecentlyUsedModels[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) AddPromptToHistory(prompt Prompt) {
|
||||
s.MessageHistory = append([]Prompt{prompt}, s.MessageHistory...)
|
||||
if len(s.MessageHistory) > 50 {
|
||||
s.MessageHistory = s.MessageHistory[:50]
|
||||
}
|
||||
}
|
||||
|
||||
// SaveState writes the provided Config struct to the specified TOML file.
|
||||
// It will create the file if it doesn't exist, or overwrite it if it does.
|
||||
func SaveState(filePath string, state *State) error {
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
encoder := toml.NewEncoder(writer)
|
||||
if err := encoder.Encode(state); err != nil {
|
||||
return fmt.Errorf("failed to encode state to TOML file %s: %w", filePath, err)
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
return fmt.Errorf("failed to flush writer for state file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
slog.Debug("State saved to file", "file", filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadState loads the state from the specified TOML file.
|
||||
// It returns a pointer to the State struct and an error if any issues occur.
|
||||
func LoadState(filePath string) (*State, error) {
|
||||
var state State
|
||||
if _, err := toml.DecodeFile(filePath, &state); err != nil {
|
||||
if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
|
||||
return nil, fmt.Errorf("state file not found at %s: %w", filePath, statErr)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
77
packages/client/tui/internal/attachment/attachment.go
Normal file
77
packages/client/tui/internal/attachment/attachment.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TextSource struct {
|
||||
Value string `toml:"value"`
|
||||
}
|
||||
|
||||
type FileSource struct {
|
||||
Path string `toml:"path"`
|
||||
Mime string `toml:"mime"`
|
||||
Data []byte `toml:"data,omitempty"` // Optional for image data
|
||||
}
|
||||
|
||||
type SymbolSource struct {
|
||||
Path string `toml:"path"`
|
||||
Name string `toml:"name"`
|
||||
Kind int `toml:"kind"`
|
||||
Range SymbolRange `toml:"range"`
|
||||
}
|
||||
|
||||
type SymbolRange struct {
|
||||
Start Position `toml:"start"`
|
||||
End Position `toml:"end"`
|
||||
}
|
||||
|
||||
type Position struct {
|
||||
Line int `toml:"line"`
|
||||
Char int `toml:"char"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
ID string `toml:"id"`
|
||||
Type string `toml:"type"`
|
||||
Display string `toml:"display"`
|
||||
URL string `toml:"url"`
|
||||
Filename string `toml:"filename"`
|
||||
MediaType string `toml:"media_type"`
|
||||
StartIndex int `toml:"start_index"`
|
||||
EndIndex int `toml:"end_index"`
|
||||
Source any `toml:"source,omitempty"`
|
||||
}
|
||||
|
||||
// NewAttachment creates a new attachment with a unique ID
|
||||
func NewAttachment() *Attachment {
|
||||
return &Attachment{
|
||||
ID: uuid.NewString(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Attachment) GetTextSource() (*TextSource, bool) {
|
||||
if a.Type != "text" {
|
||||
return nil, false
|
||||
}
|
||||
ts, ok := a.Source.(*TextSource)
|
||||
return ts, ok
|
||||
}
|
||||
|
||||
// GetFileSource returns the source as FileSource if the attachment is a file type
|
||||
func (a *Attachment) GetFileSource() (*FileSource, bool) {
|
||||
if a.Type != "file" {
|
||||
return nil, false
|
||||
}
|
||||
fs, ok := a.Source.(*FileSource)
|
||||
return fs, ok
|
||||
}
|
||||
|
||||
// GetSymbolSource returns the source as SymbolSource if the attachment is a symbol type
|
||||
func (a *Attachment) GetSymbolSource() (*SymbolSource, bool) {
|
||||
if a.Type != "symbol" {
|
||||
return nil, false
|
||||
}
|
||||
ss, ok := a.Source.(*SymbolSource)
|
||||
return ss, ok
|
||||
}
|
||||
155
packages/client/tui/internal/clipboard/clipboard.go
Normal file
155
packages/client/tui/internal/clipboard/clipboard.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
/*
|
||||
Package clipboard provides cross platform clipboard access and supports
|
||||
macOS/Linux/Windows/Android/iOS platform. Before interacting with the
|
||||
clipboard, one must call Init to assert if it is possible to use this
|
||||
package:
|
||||
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
The most common operations are `Read` and `Write`. To use them:
|
||||
|
||||
// write/read text format data of the clipboard, and
|
||||
// the byte buffer regarding the text are UTF8 encoded.
|
||||
clipboard.Write(clipboard.FmtText, []byte("text data"))
|
||||
clipboard.Read(clipboard.FmtText)
|
||||
|
||||
// write/read image format data of the clipboard, and
|
||||
// the byte buffer regarding the image are PNG encoded.
|
||||
clipboard.Write(clipboard.FmtImage, []byte("image data"))
|
||||
clipboard.Read(clipboard.FmtImage)
|
||||
|
||||
Note that read/write regarding image format assumes that the bytes are
|
||||
PNG encoded since it serves the alpha blending purpose that might be
|
||||
used in other graphical software.
|
||||
|
||||
In addition, `clipboard.Write` returns a channel that can receive an
|
||||
empty struct as a signal, which indicates the corresponding write call
|
||||
to the clipboard is outdated, meaning the clipboard has been overwritten
|
||||
by others and the previously written data is lost. For instance:
|
||||
|
||||
changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
|
||||
|
||||
select {
|
||||
case <-changed:
|
||||
println(`"text data" is no longer available from clipboard.`)
|
||||
}
|
||||
|
||||
You can ignore the returning channel if you don't need this type of
|
||||
notification. Furthermore, when you need more than just knowing whether
|
||||
clipboard data is changed, use the watcher API:
|
||||
|
||||
ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
|
||||
for data := range ch {
|
||||
// print out clipboard data whenever it is changed
|
||||
println(string(data))
|
||||
}
|
||||
*/
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// activate only for running tests.
|
||||
debug = false
|
||||
errUnavailable = errors.New("clipboard unavailable")
|
||||
errUnsupported = errors.New("unsupported format")
|
||||
errNoCgo = errors.New("clipboard: cannot use when CGO_ENABLED=0")
|
||||
)
|
||||
|
||||
// Format represents the format of clipboard data.
|
||||
type Format int
|
||||
|
||||
// All sorts of supported clipboard data
|
||||
const (
|
||||
// FmtText indicates plain text clipboard format
|
||||
FmtText Format = iota
|
||||
// FmtImage indicates image/png clipboard format
|
||||
FmtImage
|
||||
)
|
||||
|
||||
var (
|
||||
// Due to the limitation on operating systems (such as darwin),
|
||||
// concurrent read can even cause panic, use a global lock to
|
||||
// guarantee one read at a time.
|
||||
lock = sync.Mutex{}
|
||||
initOnce sync.Once
|
||||
initError error
|
||||
)
|
||||
|
||||
// Init initializes the clipboard package. It returns an error
|
||||
// if the clipboard is not available to use. This may happen if the
|
||||
// target system lacks required dependency, such as libx11-dev in X11
|
||||
// environment. For example,
|
||||
//
|
||||
// err := clipboard.Init()
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
//
|
||||
// If Init returns an error, any subsequent Read/Write/Watch call
|
||||
// may result in an unrecoverable panic.
|
||||
func Init() error {
|
||||
initOnce.Do(func() {
|
||||
initError = initialize()
|
||||
})
|
||||
return initError
|
||||
}
|
||||
|
||||
// Read returns a chunk of bytes of the clipboard data if it presents
|
||||
// in the desired format t presents. Otherwise, it returns nil.
|
||||
func Read(t Format) []byte {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
buf, err := read(t)
|
||||
if err != nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
// Write writes a given buffer to the clipboard in a specified format.
|
||||
// Write returned a receive-only channel can receive an empty struct
|
||||
// as a signal, which indicates the clipboard has been overwritten from
|
||||
// this write.
|
||||
// If format t indicates an image, then the given buf assumes
|
||||
// the image data is PNG encoded.
|
||||
func Write(t Format, buf []byte) <-chan struct{} {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
changed, err := write(t, buf)
|
||||
if err != nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// Watch returns a receive-only channel that received the clipboard data
|
||||
// whenever any change of clipboard data in the desired format happens.
|
||||
//
|
||||
// The returned channel will be closed if the given context is canceled.
|
||||
func Watch(ctx context.Context, t Format) <-chan []byte {
|
||||
return watch(ctx, t)
|
||||
}
|
||||
266
packages/client/tui/internal/clipboard/clipboard_darwin.go
Normal file
266
packages/client/tui/internal/clipboard/clipboard_darwin.go
Normal file
@@ -0,0 +1,266 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
lastChangeCount int64
|
||||
changeCountMu sync.Mutex
|
||||
)
|
||||
|
||||
func initialize() error { return nil }
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
switch t {
|
||||
case FmtText:
|
||||
return readText()
|
||||
case FmtImage:
|
||||
return readImage()
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func readText() ([]byte, error) {
|
||||
// Check if clipboard contains string data
|
||||
checkScript := `
|
||||
try
|
||||
set clipboardTypes to (clipboard info)
|
||||
repeat with aType in clipboardTypes
|
||||
if (first item of aType) is string then
|
||||
return "hastext"
|
||||
end if
|
||||
end repeat
|
||||
return "notext"
|
||||
on error
|
||||
return "error"
|
||||
end try
|
||||
`
|
||||
|
||||
cmd := exec.Command("osascript", "-e", checkScript)
|
||||
checkOut, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
checkOut = bytes.TrimSpace(checkOut)
|
||||
if !bytes.Equal(checkOut, []byte("hastext")) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Now get the actual text
|
||||
cmd = exec.Command("osascript", "-e", "get the clipboard")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
// Remove trailing newline that osascript adds
|
||||
out = bytes.TrimSuffix(out, []byte("\n"))
|
||||
|
||||
// If clipboard was set to empty string, return nil
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func readImage() ([]byte, error) {
|
||||
// AppleScript to read image data from clipboard as base64
|
||||
script := `
|
||||
try
|
||||
set theData to the clipboard as «class PNGf»
|
||||
return theData
|
||||
on error
|
||||
return ""
|
||||
end try
|
||||
`
|
||||
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Check if we got any data
|
||||
out = bytes.TrimSpace(out)
|
||||
if len(out) == 0 {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// The output is in hex format (e.g., «data PNGf89504E...»)
|
||||
// We need to extract and convert it
|
||||
outStr := string(out)
|
||||
if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Extract hex data
|
||||
hexData := strings.TrimPrefix(outStr, "«data PNGf")
|
||||
hexData = strings.TrimSuffix(hexData, "»")
|
||||
|
||||
// Convert hex to bytes
|
||||
buf := make([]byte, len(hexData)/2)
|
||||
for i := 0; i < len(hexData); i += 2 {
|
||||
b, err := strconv.ParseUint(hexData[i:i+2], 16, 8)
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
buf[i/2] = byte(b)
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// write writes the given data to clipboard and
|
||||
// returns true if success or false if failed.
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
var err error
|
||||
switch t {
|
||||
case FmtText:
|
||||
err = writeText(buf)
|
||||
case FmtImage:
|
||||
err = writeImage(buf)
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update change count
|
||||
changeCountMu.Lock()
|
||||
lastChangeCount++
|
||||
currentCount := lastChangeCount
|
||||
changeCountMu.Unlock()
|
||||
|
||||
// use unbuffered channel to prevent goroutine leak
|
||||
changed := make(chan struct{}, 1)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
changeCountMu.Lock()
|
||||
if lastChangeCount != currentCount {
|
||||
changeCountMu.Unlock()
|
||||
changed <- struct{}{}
|
||||
close(changed)
|
||||
return
|
||||
}
|
||||
changeCountMu.Unlock()
|
||||
}
|
||||
}()
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func writeText(buf []byte) error {
|
||||
if len(buf) == 0 {
|
||||
// Clear clipboard
|
||||
script := `set the clipboard to ""`
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Escape the text for AppleScript
|
||||
text := string(buf)
|
||||
text = strings.ReplaceAll(text, "\\", "\\\\")
|
||||
text = strings.ReplaceAll(text, "\"", "\\\"")
|
||||
|
||||
script := fmt.Sprintf(`set the clipboard to "%s"`, text)
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func writeImage(buf []byte) error {
|
||||
if len(buf) == 0 {
|
||||
// Clear clipboard
|
||||
script := `set the clipboard to ""`
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a temporary file to store the PNG data
|
||||
tmpFile, err := os.CreateTemp("", "clipboard*.png")
|
||||
if err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.Write(buf); err != nil {
|
||||
tmpFile.Close()
|
||||
return errUnavailable
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Use osascript to set clipboard to the image file
|
||||
script := fmt.Sprintf(`
|
||||
set theFile to POSIX file "%s"
|
||||
set theImage to read theFile as «class PNGf»
|
||||
set the clipboard to theImage
|
||||
`, tmpFile.Name())
|
||||
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
ti := time.NewTicker(time.Second)
|
||||
|
||||
// Get initial clipboard content
|
||||
var lastContent []byte
|
||||
if b := Read(t); b != nil {
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(recv)
|
||||
defer ti.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ti.C:
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if content changed
|
||||
if !bytes.Equal(lastContent, b) {
|
||||
recv <- b
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return recv
|
||||
}
|
||||
311
packages/client/tui/internal/clipboard/clipboard_linux.go
Normal file
311
packages/client/tui/internal/clipboard/clipboard_linux.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build linux
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// Clipboard tools in order of preference
|
||||
clipboardTools = []struct {
|
||||
name string
|
||||
readCmd []string
|
||||
writeCmd []string
|
||||
readImg []string
|
||||
writeImg []string
|
||||
available bool
|
||||
}{
|
||||
{
|
||||
name: "xclip",
|
||||
readCmd: []string{"xclip", "-selection", "clipboard", "-o"},
|
||||
writeCmd: []string{"xclip", "-selection", "clipboard"},
|
||||
readImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
|
||||
writeImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png"},
|
||||
},
|
||||
{
|
||||
name: "xsel",
|
||||
readCmd: []string{"xsel", "--clipboard", "--output"},
|
||||
writeCmd: []string{"xsel", "--clipboard", "--input"},
|
||||
readImg: []string{"xsel", "--clipboard", "--output"},
|
||||
writeImg: []string{"xsel", "--clipboard", "--input"},
|
||||
},
|
||||
{
|
||||
name: "wl-copy",
|
||||
readCmd: []string{"wl-paste", "-n"},
|
||||
writeCmd: []string{"wl-copy"},
|
||||
readImg: []string{"wl-paste", "-t", "image/png", "-n"},
|
||||
writeImg: []string{"wl-copy", "-t", "image/png"},
|
||||
},
|
||||
}
|
||||
|
||||
selectedTool int = -1
|
||||
toolMutex sync.Mutex
|
||||
lastChangeTime time.Time
|
||||
changeTimeMu sync.Mutex
|
||||
)
|
||||
|
||||
func initialize() error {
|
||||
toolMutex.Lock()
|
||||
defer toolMutex.Unlock()
|
||||
|
||||
if selectedTool >= 0 {
|
||||
return nil // Already initialized
|
||||
}
|
||||
|
||||
order := []string{"xclip", "xsel", "wl-copy"}
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
order = []string{"wl-copy", "xclip", "xsel"}
|
||||
}
|
||||
|
||||
for _, name := range order {
|
||||
for i, tool := range clipboardTools {
|
||||
if tool.name == name {
|
||||
cmd := exec.Command("which", tool.name)
|
||||
if err := cmd.Run(); err == nil {
|
||||
clipboardTools[i].available = true
|
||||
if selectedTool < 0 {
|
||||
selectedTool = i
|
||||
slog.Debug("Clipboard tool found", "tool", tool.name)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selectedTool < 0 {
|
||||
slog.Warn(
|
||||
"No clipboard utility found on system. Copy/paste functionality will be disabled. See https://opencode.ai/docs/troubleshooting/ for more information.",
|
||||
)
|
||||
return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
|
||||
|
||||
For X11 systems:
|
||||
apt install -y xclip
|
||||
# or
|
||||
apt install -y xsel
|
||||
|
||||
For Wayland systems:
|
||||
apt install -y wl-clipboard
|
||||
|
||||
If running in a headless environment, you may also need:
|
||||
apt install -y xvfb
|
||||
# and run:
|
||||
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
|
||||
export DISPLAY=:99.0`, errUnavailable)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
// Ensure clipboard is initialized before attempting to read
|
||||
if err := initialize(); err != nil {
|
||||
slog.Debug("Clipboard read failed: not initialized", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toolMutex.Lock()
|
||||
tool := clipboardTools[selectedTool]
|
||||
toolMutex.Unlock()
|
||||
|
||||
switch t {
|
||||
case FmtText:
|
||||
return readText(tool)
|
||||
case FmtImage:
|
||||
return readImage(tool)
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func readText(tool struct {
|
||||
name string
|
||||
readCmd []string
|
||||
writeCmd []string
|
||||
readImg []string
|
||||
writeImg []string
|
||||
available bool
|
||||
}) ([]byte, error) {
|
||||
// First check if clipboard contains text
|
||||
cmd := exec.Command(tool.readCmd[0], tool.readCmd[1:]...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Check if it's because clipboard contains non-text data
|
||||
if tool.name == "xclip" {
|
||||
// xclip returns error when clipboard doesn't contain requested type
|
||||
checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
|
||||
targets, _ := checkCmd.Output()
|
||||
if bytes.Contains(targets, []byte("image/png")) &&
|
||||
!bytes.Contains(targets, []byte("UTF8_STRING")) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
}
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readImage(tool struct {
|
||||
name string
|
||||
readCmd []string
|
||||
writeCmd []string
|
||||
readImg []string
|
||||
writeImg []string
|
||||
available bool
|
||||
}) ([]byte, error) {
|
||||
if tool.name == "xsel" {
|
||||
// xsel doesn't support image types well, return error
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
cmd := exec.Command(tool.readImg[0], tool.readImg[1:]...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Verify it's PNG data
|
||||
if len(out) < 8 ||
|
||||
!bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
// Ensure clipboard is initialized before attempting to write
|
||||
if err := initialize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toolMutex.Lock()
|
||||
tool := clipboardTools[selectedTool]
|
||||
toolMutex.Unlock()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch t {
|
||||
case FmtText:
|
||||
if len(buf) == 0 {
|
||||
// Write empty string
|
||||
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
|
||||
cmd.Stdin = bytes.NewReader([]byte{})
|
||||
} else {
|
||||
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
|
||||
cmd.Stdin = bytes.NewReader(buf)
|
||||
}
|
||||
case FmtImage:
|
||||
if tool.name == "xsel" {
|
||||
// xsel doesn't support image types well
|
||||
return nil, errUnavailable
|
||||
}
|
||||
if len(buf) == 0 {
|
||||
// Clear clipboard
|
||||
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
|
||||
cmd.Stdin = bytes.NewReader([]byte{})
|
||||
} else {
|
||||
cmd = exec.Command(tool.writeImg[0], tool.writeImg[1:]...)
|
||||
cmd.Stdin = bytes.NewReader(buf)
|
||||
}
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// Update change time
|
||||
changeTimeMu.Lock()
|
||||
lastChangeTime = time.Now()
|
||||
currentTime := lastChangeTime
|
||||
changeTimeMu.Unlock()
|
||||
|
||||
// Create change notification channel
|
||||
changed := make(chan struct{}, 1)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
changeTimeMu.Lock()
|
||||
if !lastChangeTime.Equal(currentTime) {
|
||||
changeTimeMu.Unlock()
|
||||
changed <- struct{}{}
|
||||
close(changed)
|
||||
return
|
||||
}
|
||||
changeTimeMu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
|
||||
// Ensure clipboard is initialized before starting watch
|
||||
if err := initialize(); err != nil {
|
||||
close(recv)
|
||||
return recv
|
||||
}
|
||||
|
||||
ti := time.NewTicker(time.Second)
|
||||
|
||||
// Get initial clipboard content
|
||||
var lastContent []byte
|
||||
if b := Read(t); b != nil {
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(recv)
|
||||
defer ti.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ti.C:
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if content changed
|
||||
if !bytes.Equal(lastContent, b) {
|
||||
recv <- b
|
||||
lastContent = make([]byte, len(b))
|
||||
copy(lastContent, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return recv
|
||||
}
|
||||
|
||||
// Helper function to check clipboard content type for xclip
|
||||
func getClipboardTargets() []string {
|
||||
cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(string(out), "\n")
|
||||
}
|
||||
25
packages/client/tui/internal/clipboard/clipboard_nocgo.go
Normal file
25
packages/client/tui/internal/clipboard/clipboard_nocgo.go
Normal file
@@ -0,0 +1,25 @@
|
||||
//go:build !windows && !darwin && !linux && !cgo
|
||||
|
||||
package clipboard
|
||||
|
||||
import "context"
|
||||
|
||||
func initialize() error {
|
||||
return errNoCgo
|
||||
}
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
||||
|
||||
func readc(t string) ([]byte, error) {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
||||
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
panic("clipboard: cannot use when CGO_ENABLED=0")
|
||||
}
|
||||
551
packages/client/tui/internal/clipboard/clipboard_windows.go
Normal file
551
packages/client/tui/internal/clipboard/clipboard_windows.go
Normal file
@@ -0,0 +1,551 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build windows
|
||||
|
||||
package clipboard
|
||||
|
||||
// Interacting with Clipboard on Windows:
|
||||
// https://docs.microsoft.com/zh-cn/windows/win32/dataxchg/using-the-clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/image/bmp"
|
||||
)
|
||||
|
||||
func initialize() error { return nil }
|
||||
|
||||
// readText reads the clipboard and returns the text data if presents.
|
||||
// The caller is responsible for opening/closing the clipboard before
|
||||
// calling this function.
|
||||
func readText() (buf []byte, err error) {
|
||||
hMem, _, err := getClipboardData.Call(cFmtUnicodeText)
|
||||
if hMem == 0 {
|
||||
return nil, err
|
||||
}
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return nil, err
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
// Find NUL terminator
|
||||
n := 0
|
||||
for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ {
|
||||
ptr = unsafe.Pointer(uintptr(ptr) +
|
||||
unsafe.Sizeof(*((*uint16)(unsafe.Pointer(p)))))
|
||||
}
|
||||
|
||||
var s []uint16
|
||||
h := (*reflect.SliceHeader)(unsafe.Pointer(&s))
|
||||
h.Data = p
|
||||
h.Len = n
|
||||
h.Cap = n
|
||||
return []byte(string(utf16.Decode(s))), nil
|
||||
}
|
||||
|
||||
// writeText writes given data to the clipboard. It is the caller's
|
||||
// responsibility for opening/closing the clipboard before calling
|
||||
// this function.
|
||||
func writeText(buf []byte) error {
|
||||
r, _, err := emptyClipboard.Call()
|
||||
if r == 0 {
|
||||
return fmt.Errorf("failed to clear clipboard: %w", err)
|
||||
}
|
||||
|
||||
// empty text, we are done here.
|
||||
if len(buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
s, err := syscall.UTF16FromString(string(buf))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert given string: %w", err)
|
||||
}
|
||||
|
||||
hMem, _, err := gAlloc.Call(gmemMoveable, uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
|
||||
if hMem == 0 {
|
||||
return fmt.Errorf("failed to alloc global memory: %w", err)
|
||||
}
|
||||
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return fmt.Errorf("failed to lock global memory: %w", err)
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
// no return value
|
||||
memMove.Call(p, uintptr(unsafe.Pointer(&s[0])),
|
||||
uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
|
||||
|
||||
v, _, err := setClipboardData.Call(cFmtUnicodeText, hMem)
|
||||
if v == 0 {
|
||||
gFree.Call(hMem)
|
||||
return fmt.Errorf("failed to set text to clipboard: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readImage reads the clipboard and returns PNG encoded image data
|
||||
// if presents. The caller is responsible for opening/closing the
|
||||
// clipboard before calling this function.
|
||||
func readImage() ([]byte, error) {
|
||||
hMem, _, err := getClipboardData.Call(cFmtDIBV5)
|
||||
if hMem == 0 {
|
||||
// second chance to try FmtDIB
|
||||
return readImageDib()
|
||||
}
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return nil, err
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
// inspect header information
|
||||
info := (*bitmapV5Header)(unsafe.Pointer(p))
|
||||
|
||||
// maybe deal with other formats?
|
||||
if info.BitCount != 32 {
|
||||
return nil, errUnsupported
|
||||
}
|
||||
|
||||
var data []byte
|
||||
sh := (*reflect.SliceHeader)(unsafe.Pointer(&data))
|
||||
sh.Data = uintptr(p)
|
||||
sh.Cap = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
|
||||
sh.Len = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
|
||||
img := image.NewRGBA(image.Rect(0, 0, int(info.Width), int(info.Height)))
|
||||
offset := int(info.Size)
|
||||
stride := int(info.Width)
|
||||
for y := 0; y < int(info.Height); y++ {
|
||||
for x := 0; x < int(info.Width); x++ {
|
||||
idx := offset + 4*(y*stride+x)
|
||||
xhat := (x + int(info.Width)) % int(info.Width)
|
||||
yhat := int(info.Height) - 1 - y
|
||||
r := data[idx+2]
|
||||
g := data[idx+1]
|
||||
b := data[idx+0]
|
||||
a := data[idx+3]
|
||||
img.SetRGBA(xhat, yhat, color.RGBA{r, g, b, a})
|
||||
}
|
||||
}
|
||||
// always use PNG encoding.
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, img)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func readImageDib() ([]byte, error) {
|
||||
const (
|
||||
fileHeaderLen = 14
|
||||
infoHeaderLen = 40
|
||||
cFmtDIB = 8
|
||||
)
|
||||
|
||||
hClipDat, _, err := getClipboardData.Call(cFmtDIB)
|
||||
if err != nil {
|
||||
return nil, errors.New("not dib format data: " + err.Error())
|
||||
}
|
||||
pMemBlk, _, err := gLock.Call(hClipDat)
|
||||
if pMemBlk == 0 {
|
||||
return nil, errors.New("failed to call global lock: " + err.Error())
|
||||
}
|
||||
defer gUnlock.Call(hClipDat)
|
||||
|
||||
bmpHeader := (*bitmapHeader)(unsafe.Pointer(pMemBlk))
|
||||
dataSize := bmpHeader.SizeImage + fileHeaderLen + infoHeaderLen
|
||||
|
||||
if bmpHeader.SizeImage == 0 && bmpHeader.Compression == 0 {
|
||||
iSizeImage := bmpHeader.Height * ((bmpHeader.Width*uint32(bmpHeader.BitCount)/8 + 3) &^ 3)
|
||||
dataSize += iSizeImage
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
binary.Write(buf, binary.LittleEndian, uint16('B')|(uint16('M')<<8))
|
||||
binary.Write(buf, binary.LittleEndian, uint32(dataSize))
|
||||
binary.Write(buf, binary.LittleEndian, uint32(0))
|
||||
const sizeof_colorbar = 0
|
||||
binary.Write(buf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar))
|
||||
j := 0
|
||||
for i := fileHeaderLen; i < int(dataSize); i++ {
|
||||
binary.Write(buf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j))))
|
||||
j++
|
||||
}
|
||||
return bmpToPng(buf)
|
||||
}
|
||||
|
||||
func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) {
|
||||
var f bytes.Buffer
|
||||
original_image, err := bmp.Decode(bmpBuf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = png.Encode(&f, original_image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeImage(buf []byte) error {
|
||||
r, _, err := emptyClipboard.Call()
|
||||
if r == 0 {
|
||||
return fmt.Errorf("failed to clear clipboard: %w", err)
|
||||
}
|
||||
|
||||
// empty text, we are done here.
|
||||
if len(buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
img, err := png.Decode(bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return fmt.Errorf("input bytes is not PNG encoded: %w", err)
|
||||
}
|
||||
|
||||
offset := unsafe.Sizeof(bitmapV5Header{})
|
||||
width := img.Bounds().Dx()
|
||||
height := img.Bounds().Dy()
|
||||
imageSize := 4 * width * height
|
||||
|
||||
data := make([]byte, int(offset)+imageSize)
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
idx := int(offset) + 4*(y*width+x)
|
||||
r, g, b, a := img.At(x, height-1-y).RGBA()
|
||||
data[idx+2] = uint8(r)
|
||||
data[idx+1] = uint8(g)
|
||||
data[idx+0] = uint8(b)
|
||||
data[idx+3] = uint8(a)
|
||||
}
|
||||
}
|
||||
|
||||
info := bitmapV5Header{}
|
||||
info.Size = uint32(offset)
|
||||
info.Width = int32(width)
|
||||
info.Height = int32(height)
|
||||
info.Planes = 1
|
||||
info.Compression = 0 // BI_RGB
|
||||
info.SizeImage = uint32(4 * info.Width * info.Height)
|
||||
info.RedMask = 0xff0000 // default mask
|
||||
info.GreenMask = 0xff00
|
||||
info.BlueMask = 0xff
|
||||
info.AlphaMask = 0xff000000
|
||||
info.BitCount = 32 // we only deal with 32 bpp at the moment.
|
||||
// Use calibrated RGB values as Go's image/png assumes linear color space.
|
||||
// Other options:
|
||||
// - LCS_CALIBRATED_RGB = 0x00000000
|
||||
// - LCS_sRGB = 0x73524742
|
||||
// - LCS_WINDOWS_COLOR_SPACE = 0x57696E20
|
||||
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/eb4bbd50-b3ce-4917-895c-be31f214797f
|
||||
info.CSType = 0x73524742
|
||||
// Use GL_IMAGES for GamutMappingIntent
|
||||
// Other options:
|
||||
// - LCS_GM_ABS_COLORIMETRIC = 0x00000008
|
||||
// - LCS_GM_BUSINESS = 0x00000001
|
||||
// - LCS_GM_GRAPHICS = 0x00000002
|
||||
// - LCS_GM_IMAGES = 0x00000004
|
||||
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/9fec0834-607d-427d-abd5-ab240fb0db38
|
||||
info.Intent = 4 // LCS_GM_IMAGES
|
||||
|
||||
infob := make([]byte, int(unsafe.Sizeof(info)))
|
||||
for i, v := range *(*[unsafe.Sizeof(info)]byte)(unsafe.Pointer(&info)) {
|
||||
infob[i] = v
|
||||
}
|
||||
copy(data[:], infob[:])
|
||||
|
||||
hMem, _, err := gAlloc.Call(gmemMoveable,
|
||||
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
|
||||
if hMem == 0 {
|
||||
return fmt.Errorf("failed to alloc global memory: %w", err)
|
||||
}
|
||||
|
||||
p, _, err := gLock.Call(hMem)
|
||||
if p == 0 {
|
||||
return fmt.Errorf("failed to lock global memory: %w", err)
|
||||
}
|
||||
defer gUnlock.Call(hMem)
|
||||
|
||||
memMove.Call(p, uintptr(unsafe.Pointer(&data[0])),
|
||||
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
|
||||
|
||||
v, _, err := setClipboardData.Call(cFmtDIBV5, hMem)
|
||||
if v == 0 {
|
||||
gFree.Call(hMem)
|
||||
return fmt.Errorf("failed to set text to clipboard: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
// On Windows, OpenClipboard and CloseClipboard must be executed on
|
||||
// the same thread. Thus, lock the OS thread for further execution.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
var format uintptr
|
||||
switch t {
|
||||
case FmtImage:
|
||||
format = cFmtDIBV5
|
||||
case FmtText:
|
||||
fallthrough
|
||||
default:
|
||||
format = cFmtUnicodeText
|
||||
}
|
||||
|
||||
// check if clipboard is avaliable for the requested format
|
||||
r, _, err := isClipboardFormatAvailable.Call(format)
|
||||
if r == 0 {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
// try again until open clipboard successed
|
||||
for {
|
||||
r, _, _ = openClipboard.Call()
|
||||
if r == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
defer closeClipboard.Call()
|
||||
|
||||
switch format {
|
||||
case cFmtDIBV5:
|
||||
return readImage()
|
||||
case cFmtUnicodeText:
|
||||
fallthrough
|
||||
default:
|
||||
return readText()
|
||||
}
|
||||
}
|
||||
|
||||
// write writes the given data to clipboard and
|
||||
// returns true if success or false if failed.
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
errch := make(chan error)
|
||||
changed := make(chan struct{}, 1)
|
||||
go func() {
|
||||
// make sure GetClipboardSequenceNumber happens with
|
||||
// OpenClipboard on the same thread.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
for {
|
||||
r, _, _ := openClipboard.Call(0)
|
||||
if r == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// var param uintptr
|
||||
switch t {
|
||||
case FmtImage:
|
||||
err := writeImage(buf)
|
||||
if err != nil {
|
||||
errch <- err
|
||||
closeClipboard.Call()
|
||||
return
|
||||
}
|
||||
case FmtText:
|
||||
fallthrough
|
||||
default:
|
||||
// param = cFmtUnicodeText
|
||||
err := writeText(buf)
|
||||
if err != nil {
|
||||
errch <- err
|
||||
closeClipboard.Call()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Close the clipboard otherwise other applications cannot
|
||||
// paste the data.
|
||||
closeClipboard.Call()
|
||||
|
||||
cnt, _, _ := getClipboardSequenceNumber.Call()
|
||||
errch <- nil
|
||||
for {
|
||||
time.Sleep(time.Second)
|
||||
cur, _, _ := getClipboardSequenceNumber.Call()
|
||||
if cur != cnt {
|
||||
changed <- struct{}{}
|
||||
close(changed)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
err := <-errch
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
ready := make(chan struct{})
|
||||
go func() {
|
||||
// not sure if we are too slow or the user too fast :)
|
||||
ti := time.NewTicker(time.Second)
|
||||
cnt, _, _ := getClipboardSequenceNumber.Call()
|
||||
ready <- struct{}{}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(recv)
|
||||
return
|
||||
case <-ti.C:
|
||||
cur, _, _ := getClipboardSequenceNumber.Call()
|
||||
if cnt != cur {
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
recv <- b
|
||||
cnt = cur
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
<-ready
|
||||
return recv
|
||||
}
|
||||
|
||||
const (
|
||||
cFmtBitmap = 2 // Win+PrintScreen
|
||||
cFmtUnicodeText = 13
|
||||
cFmtDIBV5 = 17
|
||||
// Screenshot taken from special shortcut is in different format (why??), see:
|
||||
// https://jpsoft.com/forums/threads/detecting-clipboard-format.5225/
|
||||
cFmtDataObject = 49161 // Shift+Win+s, returned from enumClipboardFormats
|
||||
gmemMoveable = 0x0002
|
||||
)
|
||||
|
||||
// BITMAPV5Header structure, see:
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header
|
||||
type bitmapV5Header struct {
|
||||
Size uint32
|
||||
Width int32
|
||||
Height int32
|
||||
Planes uint16
|
||||
BitCount uint16
|
||||
Compression uint32
|
||||
SizeImage uint32
|
||||
XPelsPerMeter int32
|
||||
YPelsPerMeter int32
|
||||
ClrUsed uint32
|
||||
ClrImportant uint32
|
||||
RedMask uint32
|
||||
GreenMask uint32
|
||||
BlueMask uint32
|
||||
AlphaMask uint32
|
||||
CSType uint32
|
||||
Endpoints struct {
|
||||
CiexyzRed, CiexyzGreen, CiexyzBlue struct {
|
||||
CiexyzX, CiexyzY, CiexyzZ int32 // FXPT2DOT30
|
||||
}
|
||||
}
|
||||
GammaRed uint32
|
||||
GammaGreen uint32
|
||||
GammaBlue uint32
|
||||
Intent uint32
|
||||
ProfileData uint32
|
||||
ProfileSize uint32
|
||||
Reserved uint32
|
||||
}
|
||||
|
||||
type bitmapHeader struct {
|
||||
Size uint32
|
||||
Width uint32
|
||||
Height uint32
|
||||
PLanes uint16
|
||||
BitCount uint16
|
||||
Compression uint32
|
||||
SizeImage uint32
|
||||
XPelsPerMeter uint32
|
||||
YPelsPerMeter uint32
|
||||
ClrUsed uint32
|
||||
ClrImportant uint32
|
||||
}
|
||||
|
||||
// Calling a Windows DLL, see:
|
||||
// https://github.com/golang/go/wiki/WindowsDLLs
|
||||
var (
|
||||
user32 = syscall.MustLoadDLL("user32")
|
||||
// Opens the clipboard for examination and prevents other
|
||||
// applications from modifying the clipboard content.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard
|
||||
openClipboard = user32.MustFindProc("OpenClipboard")
|
||||
// Closes the clipboard.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closeclipboard
|
||||
closeClipboard = user32.MustFindProc("CloseClipboard")
|
||||
// Empties the clipboard and frees handles to data in the clipboard.
|
||||
// The function then assigns ownership of the clipboard to the
|
||||
// window that currently has the clipboard open.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-emptyclipboard
|
||||
emptyClipboard = user32.MustFindProc("EmptyClipboard")
|
||||
// Retrieves data from the clipboard in a specified format.
|
||||
// The clipboard must have been opened previously.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboarddata
|
||||
getClipboardData = user32.MustFindProc("GetClipboardData")
|
||||
// Places data on the clipboard in a specified clipboard format.
|
||||
// The window must be the current clipboard owner, and the
|
||||
// application must have called the OpenClipboard function. (When
|
||||
// responding to the WM_RENDERFORMAT message, the clipboard owner
|
||||
// must not call OpenClipboard before calling SetClipboardData.)
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
|
||||
setClipboardData = user32.MustFindProc("SetClipboardData")
|
||||
// Determines whether the clipboard contains data in the specified format.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
|
||||
isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
|
||||
// Clipboard data formats are stored in an ordered list. To perform
|
||||
// an enumeration of clipboard data formats, you make a series of
|
||||
// calls to the EnumClipboardFormats function. For each call, the
|
||||
// format parameter specifies an available clipboard format, and the
|
||||
// function returns the next available clipboard format.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
|
||||
enumClipboardFormats = user32.MustFindProc("EnumClipboardFormats")
|
||||
// Retrieves the clipboard sequence number for the current window station.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboardsequencenumber
|
||||
getClipboardSequenceNumber = user32.MustFindProc("GetClipboardSequenceNumber")
|
||||
// Registers a new clipboard format. This format can then be used as
|
||||
// a valid clipboard format.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformata
|
||||
registerClipboardFormatA = user32.MustFindProc("RegisterClipboardFormatA")
|
||||
|
||||
kernel32 = syscall.NewLazyDLL("kernel32")
|
||||
|
||||
// Locks a global memory object and returns a pointer to the first
|
||||
// byte of the object's memory block.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock
|
||||
gLock = kernel32.NewProc("GlobalLock")
|
||||
// Decrements the lock count associated with a memory object that was
|
||||
// allocated with GMEM_MOVEABLE. This function has no effect on memory
|
||||
// objects allocated with GMEM_FIXED.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalunlock
|
||||
gUnlock = kernel32.NewProc("GlobalUnlock")
|
||||
// Allocates the specified number of bytes from the heap.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc
|
||||
gAlloc = kernel32.NewProc("GlobalAlloc")
|
||||
// Frees the specified global memory object and invalidates its handle.
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalfree
|
||||
gFree = kernel32.NewProc("GlobalFree")
|
||||
memMove = kernel32.NewProc("RtlMoveMemory")
|
||||
)
|
||||
389
packages/client/tui/internal/commands/command.go
Normal file
389
packages/client/tui/internal/commands/command.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
)
|
||||
|
||||
type ExecuteCommandMsg Command
|
||||
type ExecuteCommandsMsg []Command
|
||||
type CommandExecutedMsg Command
|
||||
|
||||
type Keybinding struct {
|
||||
RequiresLeader bool
|
||||
Key string
|
||||
}
|
||||
|
||||
func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
|
||||
key := k.Key
|
||||
key = strings.TrimSpace(key)
|
||||
return key == msg.String() && (k.RequiresLeader == leader)
|
||||
}
|
||||
|
||||
type CommandName string
|
||||
type Command struct {
|
||||
Name CommandName
|
||||
Description string
|
||||
Keybindings []Keybinding
|
||||
Trigger []string
|
||||
}
|
||||
|
||||
func (c Command) Keys() []string {
|
||||
var keys []string
|
||||
for _, k := range c.Keybindings {
|
||||
keys = append(keys, k.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (c Command) HasTrigger() bool {
|
||||
return len(c.Trigger) > 0
|
||||
}
|
||||
|
||||
func (c Command) PrimaryTrigger() string {
|
||||
if len(c.Trigger) > 0 {
|
||||
return c.Trigger[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c Command) MatchesTrigger(trigger string) bool {
|
||||
return slices.Contains(c.Trigger, trigger)
|
||||
}
|
||||
|
||||
type CommandRegistry map[CommandName]Command
|
||||
|
||||
func (r CommandRegistry) Sorted() []Command {
|
||||
var commands []Command
|
||||
for _, command := range r {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
slices.SortFunc(commands, func(a, b Command) int {
|
||||
// Priority order: session_new, session_share, model_list, app_help first, app_exit last
|
||||
priorityOrder := map[CommandName]int{
|
||||
SessionNewCommand: 0,
|
||||
AppHelpCommand: 1,
|
||||
SessionShareCommand: 2,
|
||||
ModelListCommand: 3,
|
||||
}
|
||||
|
||||
aPriority, aHasPriority := priorityOrder[a.Name]
|
||||
bPriority, bHasPriority := priorityOrder[b.Name]
|
||||
|
||||
if aHasPriority && bHasPriority {
|
||||
return aPriority - bPriority
|
||||
}
|
||||
if aHasPriority {
|
||||
return -1
|
||||
}
|
||||
if bHasPriority {
|
||||
return 1
|
||||
}
|
||||
if a.Name == AppExitCommand {
|
||||
return 1
|
||||
}
|
||||
if b.Name == AppExitCommand {
|
||||
return -1
|
||||
}
|
||||
|
||||
return strings.Compare(string(a.Name), string(b.Name))
|
||||
})
|
||||
return commands
|
||||
}
|
||||
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
|
||||
var matched []Command
|
||||
for _, command := range r.Sorted() {
|
||||
if command.Matches(msg, leader) {
|
||||
matched = append(matched, command)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
const (
|
||||
AppHelpCommand CommandName = "app_help"
|
||||
SwitchModeCommand CommandName = "switch_mode"
|
||||
SwitchModeReverseCommand CommandName = "switch_mode_reverse"
|
||||
EditorOpenCommand CommandName = "editor_open"
|
||||
SessionNewCommand CommandName = "session_new"
|
||||
SessionListCommand CommandName = "session_list"
|
||||
SessionShareCommand CommandName = "session_share"
|
||||
SessionUnshareCommand CommandName = "session_unshare"
|
||||
SessionInterruptCommand CommandName = "session_interrupt"
|
||||
SessionCompactCommand CommandName = "session_compact"
|
||||
SessionExportCommand CommandName = "session_export"
|
||||
ToolDetailsCommand CommandName = "tool_details"
|
||||
ModelListCommand CommandName = "model_list"
|
||||
ThemeListCommand CommandName = "theme_list"
|
||||
FileListCommand CommandName = "file_list"
|
||||
FileCloseCommand CommandName = "file_close"
|
||||
FileSearchCommand CommandName = "file_search"
|
||||
FileDiffToggleCommand CommandName = "file_diff_toggle"
|
||||
ProjectInitCommand CommandName = "project_init"
|
||||
InputClearCommand CommandName = "input_clear"
|
||||
InputPasteCommand CommandName = "input_paste"
|
||||
InputSubmitCommand CommandName = "input_submit"
|
||||
InputNewlineCommand CommandName = "input_newline"
|
||||
MessagesPageUpCommand CommandName = "messages_page_up"
|
||||
MessagesPageDownCommand CommandName = "messages_page_down"
|
||||
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
|
||||
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
|
||||
MessagesPreviousCommand CommandName = "messages_previous"
|
||||
MessagesNextCommand CommandName = "messages_next"
|
||||
MessagesFirstCommand CommandName = "messages_first"
|
||||
MessagesLastCommand CommandName = "messages_last"
|
||||
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
|
||||
MessagesCopyCommand CommandName = "messages_copy"
|
||||
MessagesUndoCommand CommandName = "messages_undo"
|
||||
MessagesRedoCommand CommandName = "messages_redo"
|
||||
AppExitCommand CommandName = "app_exit"
|
||||
)
|
||||
|
||||
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
|
||||
for _, binding := range k.Keybindings {
|
||||
if binding.Matches(msg, leader) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseBindings(bindings ...string) []Keybinding {
|
||||
var parsedBindings []Keybinding
|
||||
for _, binding := range bindings {
|
||||
for p := range strings.SplitSeq(binding, ",") {
|
||||
requireLeader := strings.HasPrefix(p, "<leader>")
|
||||
keybinding := strings.ReplaceAll(p, "<leader>", "")
|
||||
keybinding = strings.TrimSpace(keybinding)
|
||||
parsedBindings = append(parsedBindings, Keybinding{
|
||||
RequiresLeader: requireLeader,
|
||||
Key: keybinding,
|
||||
})
|
||||
}
|
||||
}
|
||||
return parsedBindings
|
||||
}
|
||||
|
||||
func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
defaults := []Command{
|
||||
{
|
||||
Name: AppHelpCommand,
|
||||
Description: "show help",
|
||||
Keybindings: parseBindings("<leader>h"),
|
||||
Trigger: []string{"help"},
|
||||
},
|
||||
{
|
||||
Name: SwitchModeCommand,
|
||||
Description: "next mode",
|
||||
Keybindings: parseBindings("tab"),
|
||||
},
|
||||
{
|
||||
Name: SwitchModeReverseCommand,
|
||||
Description: "previous mode",
|
||||
Keybindings: parseBindings("shift+tab"),
|
||||
},
|
||||
{
|
||||
Name: EditorOpenCommand,
|
||||
Description: "open editor",
|
||||
Keybindings: parseBindings("<leader>e"),
|
||||
Trigger: []string{"editor"},
|
||||
},
|
||||
{
|
||||
Name: SessionExportCommand,
|
||||
Description: "export conversation",
|
||||
Keybindings: parseBindings("<leader>x"),
|
||||
Trigger: []string{"export"},
|
||||
},
|
||||
{
|
||||
Name: SessionNewCommand,
|
||||
Description: "new session",
|
||||
Keybindings: parseBindings("<leader>n"),
|
||||
Trigger: []string{"new", "clear"},
|
||||
},
|
||||
{
|
||||
Name: SessionListCommand,
|
||||
Description: "list sessions",
|
||||
Keybindings: parseBindings("<leader>l"),
|
||||
Trigger: []string{"sessions", "resume", "continue"},
|
||||
},
|
||||
{
|
||||
Name: SessionShareCommand,
|
||||
Description: "share session",
|
||||
Keybindings: parseBindings("<leader>s"),
|
||||
Trigger: []string{"share"},
|
||||
},
|
||||
{
|
||||
Name: SessionUnshareCommand,
|
||||
Description: "unshare session",
|
||||
Keybindings: parseBindings("<leader>u"),
|
||||
Trigger: []string{"unshare"},
|
||||
},
|
||||
{
|
||||
Name: SessionInterruptCommand,
|
||||
Description: "interrupt session",
|
||||
Keybindings: parseBindings("esc"),
|
||||
},
|
||||
{
|
||||
Name: SessionCompactCommand,
|
||||
Description: "compact the session",
|
||||
Keybindings: parseBindings("<leader>c"),
|
||||
Trigger: []string{"compact", "summarize"},
|
||||
},
|
||||
{
|
||||
Name: ToolDetailsCommand,
|
||||
Description: "toggle tool details",
|
||||
Keybindings: parseBindings("<leader>d"),
|
||||
Trigger: []string{"details"},
|
||||
},
|
||||
{
|
||||
Name: ModelListCommand,
|
||||
Description: "list models",
|
||||
Keybindings: parseBindings("<leader>m"),
|
||||
Trigger: []string{"models"},
|
||||
},
|
||||
{
|
||||
Name: ThemeListCommand,
|
||||
Description: "list themes",
|
||||
Keybindings: parseBindings("<leader>t"),
|
||||
Trigger: []string{"themes"},
|
||||
},
|
||||
// {
|
||||
// Name: FileListCommand,
|
||||
// Description: "list files",
|
||||
// Keybindings: parseBindings("<leader>f"),
|
||||
// Trigger: []string{"files"},
|
||||
// },
|
||||
{
|
||||
Name: FileCloseCommand,
|
||||
Description: "close file",
|
||||
Keybindings: parseBindings("esc"),
|
||||
},
|
||||
{
|
||||
Name: FileSearchCommand,
|
||||
Description: "search file",
|
||||
Keybindings: parseBindings("<leader>/"),
|
||||
},
|
||||
{
|
||||
Name: FileDiffToggleCommand,
|
||||
Description: "split/unified diff",
|
||||
Keybindings: parseBindings("<leader>v"),
|
||||
},
|
||||
{
|
||||
Name: ProjectInitCommand,
|
||||
Description: "create/update AGENTS.md",
|
||||
Keybindings: parseBindings("<leader>i"),
|
||||
Trigger: []string{"init"},
|
||||
},
|
||||
{
|
||||
Name: InputClearCommand,
|
||||
Description: "clear input",
|
||||
Keybindings: parseBindings("ctrl+c"),
|
||||
},
|
||||
{
|
||||
Name: InputPasteCommand,
|
||||
Description: "paste content",
|
||||
Keybindings: parseBindings("ctrl+v", "super+v"),
|
||||
},
|
||||
{
|
||||
Name: InputSubmitCommand,
|
||||
Description: "submit message",
|
||||
Keybindings: parseBindings("enter"),
|
||||
},
|
||||
{
|
||||
Name: InputNewlineCommand,
|
||||
Description: "insert newline",
|
||||
Keybindings: parseBindings("shift+enter", "ctrl+j"),
|
||||
},
|
||||
{
|
||||
Name: MessagesPageUpCommand,
|
||||
Description: "page up",
|
||||
Keybindings: parseBindings("pgup"),
|
||||
},
|
||||
{
|
||||
Name: MessagesPageDownCommand,
|
||||
Description: "page down",
|
||||
Keybindings: parseBindings("pgdown"),
|
||||
},
|
||||
{
|
||||
Name: MessagesHalfPageUpCommand,
|
||||
Description: "half page up",
|
||||
Keybindings: parseBindings("ctrl+alt+u"),
|
||||
},
|
||||
{
|
||||
Name: MessagesHalfPageDownCommand,
|
||||
Description: "half page down",
|
||||
Keybindings: parseBindings("ctrl+alt+d"),
|
||||
},
|
||||
{
|
||||
Name: MessagesPreviousCommand,
|
||||
Description: "previous message",
|
||||
Keybindings: parseBindings("ctrl+up"),
|
||||
},
|
||||
{
|
||||
Name: MessagesNextCommand,
|
||||
Description: "next message",
|
||||
Keybindings: parseBindings("ctrl+down"),
|
||||
},
|
||||
{
|
||||
Name: MessagesFirstCommand,
|
||||
Description: "first message",
|
||||
Keybindings: parseBindings("ctrl+g"),
|
||||
},
|
||||
{
|
||||
Name: MessagesLastCommand,
|
||||
Description: "last message",
|
||||
Keybindings: parseBindings("ctrl+alt+g"),
|
||||
},
|
||||
{
|
||||
Name: MessagesLayoutToggleCommand,
|
||||
Description: "toggle layout",
|
||||
Keybindings: parseBindings("<leader>p"),
|
||||
},
|
||||
{
|
||||
Name: MessagesCopyCommand,
|
||||
Description: "copy message",
|
||||
Keybindings: parseBindings("<leader>y"),
|
||||
},
|
||||
{
|
||||
Name: MessagesUndoCommand,
|
||||
Description: "undo last message",
|
||||
Keybindings: parseBindings("<leader>u"),
|
||||
Trigger: []string{"undo"},
|
||||
},
|
||||
{
|
||||
Name: MessagesRedoCommand,
|
||||
Description: "redo message",
|
||||
Keybindings: parseBindings("<leader>r"),
|
||||
Trigger: []string{"redo"},
|
||||
},
|
||||
{
|
||||
Name: AppExitCommand,
|
||||
Description: "exit the app",
|
||||
Keybindings: parseBindings("ctrl+c", "<leader>q"),
|
||||
Trigger: []string{"exit", "quit", "q"},
|
||||
},
|
||||
}
|
||||
registry := make(CommandRegistry)
|
||||
keybinds := map[string]string{}
|
||||
marshalled, _ := json.Marshal(config.Keybinds)
|
||||
json.Unmarshal(marshalled, &keybinds)
|
||||
for _, command := range defaults {
|
||||
// Remove share/unshare commands if sharing is disabled
|
||||
if config.Share == opencode.ConfigShareDisabled &&
|
||||
(command.Name == SessionShareCommand || command.Name == SessionUnshareCommand) {
|
||||
continue
|
||||
}
|
||||
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
|
||||
if keybind == "none" {
|
||||
continue
|
||||
}
|
||||
command.Keybindings = parseBindings(keybind)
|
||||
}
|
||||
registry[command.Name] = command
|
||||
}
|
||||
return registry
|
||||
}
|
||||
110
packages/client/tui/internal/completions/commands.go
Normal file
110
packages/client/tui/internal/completions/commands.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package completions
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type CommandCompletionProvider struct {
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func NewCommandCompletionProvider(app *app.App) CompletionProvider {
|
||||
return &CommandCompletionProvider{app: app}
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetId() string {
|
||||
return "commands"
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
||||
return "no matching commands"
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) getCommandCompletionItem(
|
||||
cmd commands.Command,
|
||||
space int,
|
||||
) CompletionSuggestion {
|
||||
displayFunc := func(s styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
spacer := strings.Repeat(" ", space)
|
||||
display := " /" + cmd.PrimaryTrigger() + s.
|
||||
Foreground(t.TextMuted()).
|
||||
Render(spacer+cmd.Description)
|
||||
return display
|
||||
}
|
||||
|
||||
value := string(cmd.Name)
|
||||
return CompletionSuggestion{
|
||||
Display: displayFunc,
|
||||
Value: value,
|
||||
ProviderID: c.GetId(),
|
||||
RawData: cmd,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetChildEntries(
|
||||
query string,
|
||||
) ([]CompletionSuggestion, error) {
|
||||
commands := c.app.Commands
|
||||
|
||||
space := 1
|
||||
for _, cmd := range c.app.Commands {
|
||||
if cmd.HasTrigger() && lipgloss.Width(cmd.PrimaryTrigger()) > space {
|
||||
space = lipgloss.Width(cmd.PrimaryTrigger())
|
||||
}
|
||||
}
|
||||
space += 2
|
||||
|
||||
sorted := commands.Sorted()
|
||||
if query == "" {
|
||||
// If no query, return all commands
|
||||
items := []CompletionSuggestion{}
|
||||
for _, cmd := range sorted {
|
||||
if !cmd.HasTrigger() {
|
||||
continue
|
||||
}
|
||||
space := space - lipgloss.Width(cmd.PrimaryTrigger())
|
||||
items = append(items, c.getCommandCompletionItem(cmd, space))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
var commandNames []string
|
||||
commandMap := make(map[string]CompletionSuggestion)
|
||||
|
||||
for _, cmd := range sorted {
|
||||
if !cmd.HasTrigger() {
|
||||
continue
|
||||
}
|
||||
space := space - lipgloss.Width(cmd.PrimaryTrigger())
|
||||
for _, trigger := range cmd.Trigger {
|
||||
commandNames = append(commandNames, trigger)
|
||||
commandMap[trigger] = c.getCommandCompletionItem(cmd, space)
|
||||
}
|
||||
}
|
||||
|
||||
matches := fuzzy.RankFindFold(query, commandNames)
|
||||
sort.Sort(matches)
|
||||
|
||||
// Convert matches to completion items, deduplicating by command name
|
||||
items := []CompletionSuggestion{}
|
||||
seen := make(map[string]bool)
|
||||
for _, match := range matches {
|
||||
if item, ok := commandMap[match.Target]; ok {
|
||||
// Use the command's value (name) as the deduplication key
|
||||
if !seen[item.Value] {
|
||||
seen[item.Value] = true
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
126
packages/client/tui/internal/completions/files.go
Normal file
126
packages/client/tui/internal/completions/files.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type filesContextGroup struct {
|
||||
app *app.App
|
||||
gitFiles []CompletionSuggestion
|
||||
}
|
||||
|
||||
func (cg *filesContextGroup) GetId() string {
|
||||
return "files"
|
||||
}
|
||||
|
||||
func (cg *filesContextGroup) GetEmptyMessage() string {
|
||||
return "no matching files"
|
||||
}
|
||||
|
||||
func (cg *filesContextGroup) getGitFiles() []CompletionSuggestion {
|
||||
items := make([]CompletionSuggestion, 0)
|
||||
|
||||
status, _ := cg.app.Client.File.Status(context.Background())
|
||||
if status != nil {
|
||||
files := *status
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].Added+files[i].Removed > files[j].Added+files[j].Removed
|
||||
})
|
||||
|
||||
for _, file := range files {
|
||||
displayFunc := func(s styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
green := s.Foreground(t.Success()).Render
|
||||
red := s.Foreground(t.Error()).Render
|
||||
display := file.Path
|
||||
if file.Added > 0 {
|
||||
display += green(" +" + strconv.Itoa(int(file.Added)))
|
||||
}
|
||||
if file.Removed > 0 {
|
||||
display += red(" -" + strconv.Itoa(int(file.Removed)))
|
||||
}
|
||||
return display
|
||||
}
|
||||
item := CompletionSuggestion{
|
||||
Display: displayFunc,
|
||||
Value: file.Path,
|
||||
ProviderID: cg.GetId(),
|
||||
RawData: file,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func (cg *filesContextGroup) GetChildEntries(
|
||||
query string,
|
||||
) ([]CompletionSuggestion, error) {
|
||||
items := make([]CompletionSuggestion, 0)
|
||||
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
items = append(items, cg.gitFiles...)
|
||||
}
|
||||
|
||||
files, err := cg.app.Client.Find.Files(
|
||||
context.Background(),
|
||||
opencode.FindFilesParams{Query: opencode.F(query)},
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
return items, err
|
||||
}
|
||||
if files == nil {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
for _, file := range *files {
|
||||
exists := false
|
||||
for _, existing := range cg.gitFiles {
|
||||
if existing.Value == file {
|
||||
if query != "" {
|
||||
items = append(items, existing)
|
||||
}
|
||||
exists = true
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
displayFunc := func(s styles.Style) string {
|
||||
// t := theme.CurrentTheme()
|
||||
// return s.Foreground(t.Text()).Render(file)
|
||||
return s.Render(file)
|
||||
}
|
||||
|
||||
item := CompletionSuggestion{
|
||||
Display: displayFunc,
|
||||
Value: file,
|
||||
ProviderID: cg.GetId(),
|
||||
RawData: file,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func NewFileContextGroup(app *app.App) CompletionProvider {
|
||||
cg := &filesContextGroup{
|
||||
app: app,
|
||||
}
|
||||
go func() {
|
||||
cg.gitFiles = cg.getGitFiles()
|
||||
}()
|
||||
return cg
|
||||
}
|
||||
8
packages/client/tui/internal/completions/provider.go
Normal file
8
packages/client/tui/internal/completions/provider.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package completions
|
||||
|
||||
// CompletionProvider defines the interface for completion data providers
|
||||
type CompletionProvider interface {
|
||||
GetId() string
|
||||
GetChildEntries(query string) ([]CompletionSuggestion, error)
|
||||
GetEmptyMessage() string
|
||||
}
|
||||
24
packages/client/tui/internal/completions/suggestion.go
Normal file
24
packages/client/tui/internal/completions/suggestion.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package completions
|
||||
|
||||
import "github.com/sst/opencode/internal/styles"
|
||||
|
||||
// CompletionSuggestion represents a data-only completion suggestion
|
||||
// with no styling or rendering logic
|
||||
type CompletionSuggestion struct {
|
||||
// The text to be displayed in the list. May contain minimal inline
|
||||
// ANSI styling if intrinsic to the data (e.g., git diff colors).
|
||||
Display func(styles.Style) string
|
||||
|
||||
// The value to be used when the item is selected (e.g., inserted into the editor).
|
||||
Value string
|
||||
|
||||
// An optional, longer description to be displayed.
|
||||
Description string
|
||||
|
||||
// The ID of the provider that generated this suggestion.
|
||||
ProviderID string
|
||||
|
||||
// The raw, underlying data object (e.g., opencode.Symbol, commands.Command).
|
||||
// This allows the selection handler to perform rich actions.
|
||||
RawData any
|
||||
}
|
||||
119
packages/client/tui/internal/completions/symbols.go
Normal file
119
packages/client/tui/internal/completions/symbols.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type symbolsContextGroup struct {
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func (cg *symbolsContextGroup) GetId() string {
|
||||
return "symbols"
|
||||
}
|
||||
|
||||
func (cg *symbolsContextGroup) GetEmptyMessage() string {
|
||||
return "no matching symbols"
|
||||
}
|
||||
|
||||
type SymbolKind int
|
||||
|
||||
const (
|
||||
SymbolKindFile SymbolKind = 1
|
||||
SymbolKindModule SymbolKind = 2
|
||||
SymbolKindNamespace SymbolKind = 3
|
||||
SymbolKindPackage SymbolKind = 4
|
||||
SymbolKindClass SymbolKind = 5
|
||||
SymbolKindMethod SymbolKind = 6
|
||||
SymbolKindProperty SymbolKind = 7
|
||||
SymbolKindField SymbolKind = 8
|
||||
SymbolKindConstructor SymbolKind = 9
|
||||
SymbolKindEnum SymbolKind = 10
|
||||
SymbolKindInterface SymbolKind = 11
|
||||
SymbolKindFunction SymbolKind = 12
|
||||
SymbolKindVariable SymbolKind = 13
|
||||
SymbolKindConstant SymbolKind = 14
|
||||
SymbolKindString SymbolKind = 15
|
||||
SymbolKindNumber SymbolKind = 16
|
||||
SymbolKindBoolean SymbolKind = 17
|
||||
SymbolKindArray SymbolKind = 18
|
||||
SymbolKindObject SymbolKind = 19
|
||||
SymbolKindKey SymbolKind = 20
|
||||
SymbolKindNull SymbolKind = 21
|
||||
SymbolKindEnumMember SymbolKind = 22
|
||||
SymbolKindStruct SymbolKind = 23
|
||||
SymbolKindEvent SymbolKind = 24
|
||||
SymbolKindOperator SymbolKind = 25
|
||||
SymbolKindTypeParameter SymbolKind = 26
|
||||
)
|
||||
|
||||
func (cg *symbolsContextGroup) GetChildEntries(
|
||||
query string,
|
||||
) ([]CompletionSuggestion, error) {
|
||||
items := make([]CompletionSuggestion, 0)
|
||||
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
symbols, err := cg.app.Client.Find.Symbols(
|
||||
context.Background(),
|
||||
opencode.FindSymbolsParams{Query: opencode.F(query)},
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get symbol completion items", "error", err)
|
||||
return items, err
|
||||
}
|
||||
if symbols == nil {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
for _, sym := range *symbols {
|
||||
parts := strings.Split(sym.Name, ".")
|
||||
lastPart := parts[len(parts)-1]
|
||||
start := int(sym.Location.Range.Start.Line)
|
||||
end := int(sym.Location.Range.End.Line)
|
||||
|
||||
displayFunc := func(s styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
base := s.Foreground(t.Text()).Render
|
||||
muted := s.Foreground(t.TextMuted()).Render
|
||||
display := base(lastPart)
|
||||
|
||||
uriParts := strings.Split(sym.Location.Uri, "/")
|
||||
lastTwoParts := uriParts[len(uriParts)-2:]
|
||||
joined := strings.Join(lastTwoParts, "/")
|
||||
display += muted(fmt.Sprintf(" %s", joined))
|
||||
|
||||
display += muted(fmt.Sprintf(":L%d-%d", start, end))
|
||||
return display
|
||||
}
|
||||
|
||||
value := fmt.Sprintf("%s?start=%d&end=%d", sym.Location.Uri, start, end)
|
||||
|
||||
item := CompletionSuggestion{
|
||||
Display: displayFunc,
|
||||
Value: value,
|
||||
ProviderID: cg.GetId(),
|
||||
RawData: sym,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func NewSymbolsContextGroup(app *app.App) CompletionProvider {
|
||||
return &symbolsContextGroup{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MessageCache caches rendered messages to avoid re-rendering
|
||||
type MessageCache struct {
|
||||
// PartCache caches rendered messages to avoid re-rendering
|
||||
type PartCache struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]string
|
||||
}
|
||||
|
||||
// NewMessageCache creates a new message cache
|
||||
func NewMessageCache() *MessageCache {
|
||||
return &MessageCache{
|
||||
// NewPartCache creates a new message cache
|
||||
func NewPartCache() *PartCache {
|
||||
return &PartCache{
|
||||
cache: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// generateKey creates a unique key for a message based on its content and rendering parameters
|
||||
func (c *MessageCache) GenerateKey(params ...any) string {
|
||||
h := sha256.New()
|
||||
func (c *PartCache) GenerateKey(params ...any) string {
|
||||
h := fnv.New64a()
|
||||
for _, param := range params {
|
||||
h.Write(fmt.Appendf(nil, ":%v", param))
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func (c *MessageCache) GenerateKey(params ...any) string {
|
||||
}
|
||||
|
||||
// Get retrieves a cached rendered message
|
||||
func (c *MessageCache) Get(key string) (string, bool) {
|
||||
func (c *PartCache) Get(key string) (string, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
@@ -39,14 +39,14 @@ func (c *MessageCache) Get(key string) (string, bool) {
|
||||
}
|
||||
|
||||
// Set stores a rendered message in the cache
|
||||
func (c *MessageCache) Set(key string, content string) {
|
||||
func (c *PartCache) Set(key string, content string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cache[key] = content
|
||||
}
|
||||
|
||||
// Clear removes all entries from the cache
|
||||
func (c *MessageCache) Clear() {
|
||||
func (c *PartCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -54,7 +54,7 @@ func (c *MessageCache) Clear() {
|
||||
}
|
||||
|
||||
// Size returns the number of cached entries
|
||||
func (c *MessageCache) Size() int {
|
||||
func (c *PartCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
794
packages/client/tui/internal/components/chat/editor.go
Normal file
794
packages/client/tui/internal/components/chat/editor.go
Normal file
@@ -0,0 +1,794 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/attachment"
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/textarea"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type EditorComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
Content() string
|
||||
Lines() int
|
||||
Value() string
|
||||
Length() int
|
||||
Focused() bool
|
||||
Focus() (tea.Model, tea.Cmd)
|
||||
Blur()
|
||||
Submit() (tea.Model, tea.Cmd)
|
||||
Clear() (tea.Model, tea.Cmd)
|
||||
Paste() (tea.Model, tea.Cmd)
|
||||
Newline() (tea.Model, tea.Cmd)
|
||||
SetValue(value string)
|
||||
SetValueWithAttachments(value string)
|
||||
SetInterruptKeyInDebounce(inDebounce bool)
|
||||
SetExitKeyInDebounce(inDebounce bool)
|
||||
RestoreFromHistory(index int)
|
||||
}
|
||||
|
||||
type editorComponent struct {
|
||||
app *app.App
|
||||
width int
|
||||
textarea textarea.Model
|
||||
spinner spinner.Model
|
||||
interruptKeyInDebounce bool
|
||||
exitKeyInDebounce bool
|
||||
historyIndex int // -1 means current (not in history)
|
||||
currentText string // Store current text when navigating history
|
||||
pasteCounter int
|
||||
reverted bool
|
||||
}
|
||||
|
||||
func (m *editorComponent) Init() tea.Cmd {
|
||||
return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width - 4
|
||||
return m, nil
|
||||
case spinner.TickMsg:
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
case tea.KeyPressMsg:
|
||||
// Handle up/down arrows and ctrl+p/ctrl+n for history navigation
|
||||
switch msg.String() {
|
||||
case "up", "ctrl+p":
|
||||
// Only navigate history if cursor is at the first line and column (for arrow keys)
|
||||
// or allow ctrl+p from anywhere
|
||||
if (msg.String() == "ctrl+p" || (m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0)) && len(m.app.State.MessageHistory) > 0 {
|
||||
if m.historyIndex == -1 {
|
||||
// Save current text before entering history
|
||||
m.currentText = m.textarea.Value()
|
||||
m.textarea.MoveToBegin()
|
||||
}
|
||||
// Move up in history (older messages)
|
||||
if m.historyIndex < len(m.app.State.MessageHistory)-1 {
|
||||
m.historyIndex++
|
||||
m.RestoreFromHistory(m.historyIndex)
|
||||
m.textarea.MoveToBegin()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case "down", "ctrl+n":
|
||||
// Only navigate history if cursor is at the last line and we're in history navigation (for arrow keys)
|
||||
// or allow ctrl+n from anywhere if we're in history navigation
|
||||
if (msg.String() == "ctrl+n" || m.textarea.IsCursorAtEnd()) && m.historyIndex > -1 {
|
||||
// Move down in history (newer messages)
|
||||
m.historyIndex--
|
||||
if m.historyIndex == -1 {
|
||||
// Restore current text
|
||||
m.textarea.Reset()
|
||||
m.textarea.SetValue(m.currentText)
|
||||
m.currentText = ""
|
||||
} else {
|
||||
m.RestoreFromHistory(m.historyIndex)
|
||||
m.textarea.MoveToEnd()
|
||||
}
|
||||
return m, nil
|
||||
} else if m.historyIndex > -1 && msg.String() == "down" {
|
||||
m.textarea.MoveToEnd()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
// Reset history navigation on any other input
|
||||
if m.historyIndex != -1 {
|
||||
m.historyIndex = -1
|
||||
m.currentText = ""
|
||||
}
|
||||
// Maximize editor responsiveness for printable characters
|
||||
if msg.Text != "" {
|
||||
m.reverted = false
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
case app.MessageRevertedMsg:
|
||||
if msg.Session.ID == m.app.Session.ID {
|
||||
switch msg.Message.Info.(type) {
|
||||
case opencode.UserMessage:
|
||||
prompt, err := msg.Message.ToPrompt()
|
||||
if err != nil {
|
||||
return m, toast.NewErrorToast("Failed to revert message")
|
||||
}
|
||||
m.RestoreFromPrompt(*prompt)
|
||||
m.textarea.MoveToEnd()
|
||||
m.reverted = true
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
case app.SessionUnrevertedMsg:
|
||||
if msg.Session.ID == m.app.Session.ID {
|
||||
if m.reverted {
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case tea.PasteMsg:
|
||||
text := string(msg)
|
||||
|
||||
if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" {
|
||||
statPath := filePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
statPath = filepath.Join(m.app.Info.Path.Cwd, filePath)
|
||||
}
|
||||
if _, err := os.Stat(statPath); err == nil {
|
||||
attachment := m.createAttachmentFromPath(filePath)
|
||||
if attachment != nil {
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text = strings.ReplaceAll(text, "\\", "")
|
||||
text, err := strconv.Unquote(`"` + text + `"`)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unquote text", "error", err)
|
||||
text := string(msg)
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if _, err := os.Stat(text); err != nil {
|
||||
slog.Error("Failed to paste file", "error", err)
|
||||
text := string(msg)
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
filePath := text
|
||||
|
||||
attachment := m.createAttachmentFromFile(filePath)
|
||||
if attachment == nil {
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
case tea.ClipboardMsg:
|
||||
text := string(msg)
|
||||
// Check if the pasted text is long and should be summarized
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(text))
|
||||
}
|
||||
case dialog.ThemeSelectedMsg:
|
||||
m.textarea = updateTextareaStyles(m.textarea)
|
||||
m.spinner = createSpinner()
|
||||
return m, tea.Batch(m.textarea.Focus(), m.spinner.Tick)
|
||||
case dialog.CompletionSelectedMsg:
|
||||
switch msg.Item.ProviderID {
|
||||
case "commands":
|
||||
commandName := strings.TrimPrefix(msg.Item.Value, "/")
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
|
||||
return m, tea.Batch(cmds...)
|
||||
case "files":
|
||||
atIndex := m.textarea.LastRuneIndex('@')
|
||||
if atIndex == -1 {
|
||||
// Should not happen, but as a fallback, just insert.
|
||||
m.textarea.InsertString(msg.Item.Value + " ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// The range to replace is from the '@' up to the current cursor position.
|
||||
// Replace the search term (e.g., "@search") with an empty string first.
|
||||
cursorCol := m.textarea.CursorColumn()
|
||||
m.textarea.ReplaceRange(atIndex, cursorCol, "")
|
||||
|
||||
// Now, insert the attachment at the position where the '@' was.
|
||||
// The cursor is now at `atIndex` after the replacement.
|
||||
filePath := msg.Item.Value
|
||||
attachment := m.createAttachmentFromPath(filePath)
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
case "symbols":
|
||||
atIndex := m.textarea.LastRuneIndex('@')
|
||||
if atIndex == -1 {
|
||||
// Should not happen, but as a fallback, just insert.
|
||||
m.textarea.InsertString(msg.Item.Value + " ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
cursorCol := m.textarea.CursorColumn()
|
||||
m.textarea.ReplaceRange(atIndex, cursorCol, "")
|
||||
|
||||
symbol := msg.Item.RawData.(opencode.Symbol)
|
||||
parts := strings.Split(symbol.Name, ".")
|
||||
lastPart := parts[len(parts)-1]
|
||||
attachment := &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "symbol",
|
||||
Display: "@" + lastPart,
|
||||
URL: msg.Item.Value,
|
||||
Filename: lastPart,
|
||||
MediaType: "text/plain",
|
||||
Source: &attachment.SymbolSource{
|
||||
Path: symbol.Location.Uri,
|
||||
Name: symbol.Name,
|
||||
Kind: int(symbol.Kind),
|
||||
Range: attachment.SymbolRange{
|
||||
Start: attachment.Position{
|
||||
Line: int(symbol.Location.Range.Start.Line),
|
||||
Char: int(symbol.Location.Range.Start.Character),
|
||||
},
|
||||
End: attachment.Position{
|
||||
Line: int(symbol.Location.Range.End.Line),
|
||||
Char: int(symbol.Location.Range.End.Character),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
default:
|
||||
slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Content() string {
|
||||
width := m.width
|
||||
if m.app.Session.ID == "" {
|
||||
width = min(width, 80)
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||
promptStyle := styles.NewStyle().Foreground(t.Primary()).
|
||||
Padding(0, 0, 0, 1).
|
||||
Bold(true)
|
||||
prompt := promptStyle.Render(">")
|
||||
|
||||
m.textarea.SetWidth(width - 6)
|
||||
textarea := lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
prompt,
|
||||
m.textarea.View(),
|
||||
)
|
||||
borderForeground := t.Border()
|
||||
if m.app.IsLeaderSequence {
|
||||
borderForeground = t.Accent()
|
||||
}
|
||||
textarea = styles.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderForeground(borderForeground).
|
||||
BorderBackground(t.Background()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
Render(textarea)
|
||||
|
||||
hint := base(m.getSubmitKeyText()) + muted(" send ")
|
||||
if m.exitKeyInDebounce {
|
||||
keyText := m.getExitKeyText()
|
||||
hint = base(keyText+" again") + muted(" to exit")
|
||||
} else if m.app.IsBusy() {
|
||||
keyText := m.getInterruptKeyText()
|
||||
if m.interruptKeyInDebounce {
|
||||
hint = muted(
|
||||
"working",
|
||||
) + m.spinner.View() + muted(
|
||||
" ",
|
||||
) + base(
|
||||
keyText+" again",
|
||||
) + muted(
|
||||
" interrupt",
|
||||
)
|
||||
} else {
|
||||
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
|
||||
}
|
||||
}
|
||||
|
||||
model := ""
|
||||
if m.app.Model != nil {
|
||||
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
|
||||
}
|
||||
|
||||
space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
|
||||
spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
|
||||
|
||||
info := hint + spacer + model
|
||||
info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
|
||||
|
||||
content := strings.Join([]string{"", textarea, info}, "\n")
|
||||
return content
|
||||
}
|
||||
|
||||
func (m *editorComponent) View() string {
|
||||
width := m.width
|
||||
if m.app.Session.ID == "" {
|
||||
width = min(width, 80)
|
||||
}
|
||||
|
||||
if m.Lines() > 1 {
|
||||
return lipgloss.Place(
|
||||
width,
|
||||
5,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
"",
|
||||
styles.WhitespaceStyle(theme.CurrentTheme().Background()),
|
||||
)
|
||||
}
|
||||
return m.Content()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Focused() bool {
|
||||
return m.textarea.Focused()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
|
||||
return m, m.textarea.Focus()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Blur() {
|
||||
m.textarea.Blur()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Lines() int {
|
||||
return m.textarea.LineCount()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Value() string {
|
||||
return m.textarea.Value()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Length() int {
|
||||
return m.textarea.Length()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
||||
value := strings.TrimSpace(m.Value())
|
||||
if value == "" {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch value {
|
||||
case "exit", "quit", "q", ":q":
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
if len(value) > 0 && value[len(value)-1] == '\\' {
|
||||
// If the last character is a backslash, remove it and add a newline
|
||||
backslashCol := m.textarea.CurrentRowLength() - 1
|
||||
m.textarea.ReplaceRange(backslashCol, backslashCol+1, "")
|
||||
m.textarea.InsertString("\n")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
attachments := m.textarea.GetAttachments()
|
||||
|
||||
prompt := app.Prompt{Text: value, Attachments: attachments}
|
||||
m.app.State.AddPromptToHistory(prompt)
|
||||
cmds = append(cmds, m.app.SaveState())
|
||||
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt)))
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
|
||||
m.textarea.Reset()
|
||||
m.historyIndex = -1
|
||||
m.currentText = ""
|
||||
m.pasteCounter = 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
|
||||
imageBytes := clipboard.Read(clipboard.FmtImage)
|
||||
if imageBytes != nil {
|
||||
attachmentCount := len(m.textarea.GetAttachments())
|
||||
attachmentIndex := attachmentCount + 1
|
||||
base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
attachment := &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
MediaType: "image/png",
|
||||
Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
|
||||
Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
|
||||
URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
|
||||
Source: &attachment.FileSource{
|
||||
Path: fmt.Sprintf("image-%d.png", attachmentIndex),
|
||||
Mime: "image/png",
|
||||
Data: imageBytes,
|
||||
},
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
textBytes := clipboard.Read(clipboard.FmtText)
|
||||
if textBytes != nil {
|
||||
text := string(textBytes)
|
||||
// Check if the pasted text is long and should be summarized
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(text))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// fallback to reading the clipboard using OSC52
|
||||
return m, tea.ReadClipboard
|
||||
}
|
||||
|
||||
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
|
||||
m.textarea.Newline()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
|
||||
m.interruptKeyInDebounce = inDebounce
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetValue(value string) {
|
||||
m.textarea.SetValue(value)
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetValueWithAttachments(value string) {
|
||||
m.textarea.Reset()
|
||||
|
||||
i := 0
|
||||
for i < len(value) {
|
||||
// Check if filepath and add attachment
|
||||
if value[i] == '@' {
|
||||
start := i + 1
|
||||
end := start
|
||||
for end < len(value) && value[end] != ' ' && value[end] != '\t' && value[end] != '\n' && value[end] != '\r' {
|
||||
end++
|
||||
}
|
||||
|
||||
if end > start {
|
||||
filePath := value[start:end]
|
||||
slog.Debug("test", "filePath", filePath)
|
||||
if _, err := os.Stat(filepath.Join(m.app.Info.Path.Cwd, filePath)); err == nil {
|
||||
slog.Debug("test", "found", true)
|
||||
attachment := m.createAttachmentFromFile(filePath)
|
||||
if attachment != nil {
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a valid file path, insert the character normally
|
||||
m.textarea.InsertRune(rune(value[i]))
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
|
||||
m.exitKeyInDebounce = inDebounce
|
||||
}
|
||||
|
||||
func (m *editorComponent) getInterruptKeyText() string {
|
||||
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
|
||||
}
|
||||
|
||||
func (m *editorComponent) getSubmitKeyText() string {
|
||||
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
|
||||
}
|
||||
|
||||
func (m *editorComponent) getExitKeyText() string {
|
||||
return m.app.Commands[commands.AppExitCommand].Keys()[0]
|
||||
}
|
||||
|
||||
// shouldSummarizePastedText determines if pasted text should be summarized
|
||||
func (m *editorComponent) shouldSummarizePastedText(text string) bool {
|
||||
lines := strings.Split(text, "\n")
|
||||
lineCount := len(lines)
|
||||
charCount := len(text)
|
||||
|
||||
// Consider text long if it has more than 3 lines or more than 150 characters
|
||||
return lineCount > 3 || charCount > 150
|
||||
}
|
||||
|
||||
// handleLongPaste handles long pasted text by creating a summary attachment
|
||||
func (m *editorComponent) handleLongPaste(text string) {
|
||||
lines := strings.Split(text, "\n")
|
||||
lineCount := len(lines)
|
||||
|
||||
// Increment paste counter
|
||||
m.pasteCounter++
|
||||
|
||||
// Create attachment with full text as base64 encoded data
|
||||
fileBytes := []byte(text)
|
||||
base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes)
|
||||
url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText)
|
||||
|
||||
fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter)
|
||||
displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount)
|
||||
|
||||
attachment := &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "text",
|
||||
MediaType: "text/plain",
|
||||
Display: displayText,
|
||||
URL: url,
|
||||
Filename: fileName,
|
||||
Source: &attachment.TextSource{
|
||||
Value: text,
|
||||
},
|
||||
}
|
||||
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
}
|
||||
|
||||
func updateTextareaStyles(ta textarea.Model) textarea.Model {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Attachment = styles.NewStyle().
|
||||
Foreground(t.Secondary()).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.SelectedAttachment = styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.Secondary()).
|
||||
Lipgloss()
|
||||
ta.Styles.Cursor.Color = t.Primary()
|
||||
return ta
|
||||
}
|
||||
|
||||
func createSpinner() spinner.Model {
|
||||
t := theme.CurrentTheme()
|
||||
return spinner.New(
|
||||
spinner.WithSpinner(spinner.Ellipsis),
|
||||
spinner.WithStyle(
|
||||
styles.NewStyle().
|
||||
Background(t.Background()).
|
||||
Foreground(t.TextMuted()).
|
||||
Width(3).
|
||||
Lipgloss(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func NewEditorComponent(app *app.App) EditorComponent {
|
||||
s := createSpinner()
|
||||
|
||||
ta := textarea.New()
|
||||
ta.Prompt = " "
|
||||
ta.ShowLineNumbers = false
|
||||
ta.CharLimit = -1
|
||||
ta = updateTextareaStyles(ta)
|
||||
|
||||
m := &editorComponent{
|
||||
app: app,
|
||||
textarea: ta,
|
||||
spinner: s,
|
||||
interruptKeyInDebounce: false,
|
||||
historyIndex: -1,
|
||||
pasteCounter: 0,
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *editorComponent) RestoreFromPrompt(prompt app.Prompt) {
|
||||
m.textarea.Reset()
|
||||
m.textarea.SetValue(prompt.Text)
|
||||
|
||||
// Sort attachments by start index in reverse order (process from end to beginning)
|
||||
// This prevents index shifting issues
|
||||
attachmentsCopy := make([]*attachment.Attachment, len(prompt.Attachments))
|
||||
copy(attachmentsCopy, prompt.Attachments)
|
||||
|
||||
for i := 0; i < len(attachmentsCopy)-1; i++ {
|
||||
for j := i + 1; j < len(attachmentsCopy); j++ {
|
||||
if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex {
|
||||
attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, att := range attachmentsCopy {
|
||||
m.textarea.SetCursorColumn(att.StartIndex)
|
||||
m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "")
|
||||
m.textarea.InsertAttachment(att)
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreFromHistory restores a message from history at the given index
|
||||
func (m *editorComponent) RestoreFromHistory(index int) {
|
||||
if index < 0 || index >= len(m.app.State.MessageHistory) {
|
||||
return
|
||||
}
|
||||
entry := m.app.State.MessageHistory[index]
|
||||
m.RestoreFromPrompt(entry)
|
||||
}
|
||||
|
||||
func getMediaTypeFromExtension(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".jpg":
|
||||
return "image/jpeg"
|
||||
case ".png", ".jpeg", ".gif", ".webp":
|
||||
return "image/" + ext[1:]
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
default:
|
||||
return "text/plain"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
mediaType := getMediaTypeFromExtension(ext)
|
||||
absolutePath := filePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
|
||||
}
|
||||
|
||||
// For text files, create a simple file reference
|
||||
if mediaType == "text/plain" {
|
||||
return &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
Display: "@" + filePath,
|
||||
URL: fmt.Sprintf("file://./%s", filePath),
|
||||
Filename: filePath,
|
||||
MediaType: mediaType,
|
||||
Source: &attachment.FileSource{
|
||||
Path: absolutePath,
|
||||
Mime: mediaType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// For binary files (images, PDFs), read and encode
|
||||
fileBytes, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read file", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
|
||||
url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
|
||||
attachmentCount := len(m.textarea.GetAttachments())
|
||||
attachmentIndex := attachmentCount + 1
|
||||
label := "File"
|
||||
if strings.HasPrefix(mediaType, "image/") {
|
||||
label = "Image"
|
||||
}
|
||||
return &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
MediaType: mediaType,
|
||||
Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
|
||||
URL: url,
|
||||
Filename: filePath,
|
||||
Source: &attachment.FileSource{
|
||||
Path: absolutePath,
|
||||
Mime: mediaType,
|
||||
Data: fileBytes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment {
|
||||
extension := filepath.Ext(filePath)
|
||||
mediaType := getMediaTypeFromExtension(extension)
|
||||
absolutePath := filePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
|
||||
}
|
||||
return &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
Display: "@" + filePath,
|
||||
URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
|
||||
Filename: filePath,
|
||||
MediaType: mediaType,
|
||||
Source: &attachment.FileSource{
|
||||
Path: absolutePath,
|
||||
Mime: mediaType,
|
||||
},
|
||||
}
|
||||
}
|
||||
719
packages/client/tui/internal/components/chat/message.go
Normal file
719
packages/client/tui/internal/components/chat/message.go
Normal file
@@ -0,0 +1,719 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/diff"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type blockRenderer struct {
|
||||
textColor compat.AdaptiveColor
|
||||
border bool
|
||||
borderColor *compat.AdaptiveColor
|
||||
borderColorRight bool
|
||||
paddingTop int
|
||||
paddingBottom int
|
||||
paddingLeft int
|
||||
paddingRight int
|
||||
marginTop int
|
||||
marginBottom int
|
||||
}
|
||||
|
||||
type renderingOption func(*blockRenderer)
|
||||
|
||||
func WithTextColor(color compat.AdaptiveColor) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.textColor = color
|
||||
}
|
||||
}
|
||||
|
||||
func WithNoBorder() renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.border = false
|
||||
}
|
||||
}
|
||||
|
||||
func WithBorderColor(color compat.AdaptiveColor) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.borderColor = &color
|
||||
}
|
||||
}
|
||||
|
||||
func WithBorderColorRight(color compat.AdaptiveColor) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.borderColorRight = true
|
||||
c.borderColor = &color
|
||||
}
|
||||
}
|
||||
|
||||
func WithMarginTop(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.marginTop = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithMarginBottom(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.marginBottom = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithPadding(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.paddingTop = padding
|
||||
c.paddingBottom = padding
|
||||
c.paddingLeft = padding
|
||||
c.paddingRight = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaddingLeft(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.paddingLeft = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaddingRight(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.paddingRight = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaddingTop(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.paddingTop = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaddingBottom(padding int) renderingOption {
|
||||
return func(c *blockRenderer) {
|
||||
c.paddingBottom = padding
|
||||
}
|
||||
}
|
||||
|
||||
func renderContentBlock(
|
||||
app *app.App,
|
||||
content string,
|
||||
width int,
|
||||
options ...renderingOption,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
renderer := &blockRenderer{
|
||||
textColor: t.TextMuted(),
|
||||
border: true,
|
||||
paddingTop: 1,
|
||||
paddingBottom: 1,
|
||||
paddingLeft: 2,
|
||||
paddingRight: 2,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(renderer)
|
||||
}
|
||||
|
||||
borderColor := t.BackgroundPanel()
|
||||
if renderer.borderColor != nil {
|
||||
borderColor = *renderer.borderColor
|
||||
}
|
||||
|
||||
style := styles.NewStyle().
|
||||
Foreground(renderer.textColor).
|
||||
Background(t.BackgroundPanel()).
|
||||
PaddingTop(renderer.paddingTop).
|
||||
PaddingBottom(renderer.paddingBottom).
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
PaddingRight(renderer.paddingRight).
|
||||
AlignHorizontal(lipgloss.Left)
|
||||
|
||||
if renderer.border {
|
||||
style = style.
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
BorderLeftForeground(borderColor).
|
||||
BorderLeftBackground(t.Background()).
|
||||
BorderRightForeground(t.BackgroundPanel()).
|
||||
BorderRightBackground(t.Background())
|
||||
|
||||
if renderer.borderColorRight {
|
||||
style = style.
|
||||
BorderLeftBackground(t.Background()).
|
||||
BorderLeftForeground(t.BackgroundPanel()).
|
||||
BorderRightForeground(borderColor).
|
||||
BorderRightBackground(t.Background())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
content = style.Render(content)
|
||||
if renderer.marginTop > 0 {
|
||||
for range renderer.marginTop {
|
||||
content = "\n" + content
|
||||
}
|
||||
}
|
||||
if renderer.marginBottom > 0 {
|
||||
for range renderer.marginBottom {
|
||||
content = content + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
func renderText(
|
||||
app *app.App,
|
||||
message opencode.MessageUnion,
|
||||
text string,
|
||||
author string,
|
||||
showToolDetails bool,
|
||||
width int,
|
||||
extra string,
|
||||
toolCalls ...opencode.ToolPart,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
var ts time.Time
|
||||
backgroundColor := t.BackgroundPanel()
|
||||
var content string
|
||||
switch casted := message.(type) {
|
||||
case opencode.AssistantMessage:
|
||||
ts = time.UnixMilli(int64(casted.Time.Created))
|
||||
content = util.ToMarkdown(text, width, backgroundColor)
|
||||
case opencode.UserMessage:
|
||||
ts = time.UnixMilli(int64(casted.Time.Created))
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
|
||||
text = ansi.WordwrapWc(text, width-6, " -")
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
words := strings.Fields(line)
|
||||
for i, word := range words {
|
||||
if strings.HasPrefix(word, "@") {
|
||||
words[i] = base.Foreground(t.Secondary()).Render(word + " ")
|
||||
} else {
|
||||
words[i] = base.Render(word + " ")
|
||||
}
|
||||
}
|
||||
lines[i] = strings.Join(words, "")
|
||||
}
|
||||
text = strings.Join(lines, "\n")
|
||||
content = base.Width(width - 6).Render(text)
|
||||
}
|
||||
|
||||
timestamp := ts.
|
||||
Local().
|
||||
Format("02 Jan 2006 03:04 PM")
|
||||
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
|
||||
// don't show the date if it's today
|
||||
timestamp = timestamp[12:]
|
||||
}
|
||||
info := fmt.Sprintf("%s (%s)", author, timestamp)
|
||||
info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
|
||||
|
||||
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
|
||||
content = content + "\n\n"
|
||||
for _, toolCall := range toolCalls {
|
||||
title := renderToolTitle(toolCall, width)
|
||||
style := styles.NewStyle()
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusError {
|
||||
style = style.Foreground(t.Error())
|
||||
}
|
||||
title = style.Render(title)
|
||||
title = "∟ " + title + "\n"
|
||||
content = content + title
|
||||
}
|
||||
}
|
||||
|
||||
sections := []string{content, info}
|
||||
if extra != "" {
|
||||
sections = append(sections, "\n"+extra)
|
||||
}
|
||||
content = strings.Join(sections, "\n")
|
||||
|
||||
switch message.(type) {
|
||||
case opencode.UserMessage:
|
||||
return renderContentBlock(
|
||||
app,
|
||||
content,
|
||||
width,
|
||||
WithTextColor(t.Text()),
|
||||
WithBorderColorRight(t.Secondary()),
|
||||
)
|
||||
case opencode.AssistantMessage:
|
||||
return renderContentBlock(
|
||||
app,
|
||||
content,
|
||||
width,
|
||||
WithBorderColor(t.Accent()),
|
||||
)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func renderToolDetails(
|
||||
app *app.App,
|
||||
toolCall opencode.ToolPart,
|
||||
width int,
|
||||
) string {
|
||||
measure := util.Measure("chat.renderToolDetails")
|
||||
defer measure("tool", toolCall.Tool)
|
||||
ignoredTools := []string{"todoread"}
|
||||
if slices.Contains(ignoredTools, toolCall.Tool) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
|
||||
title := renderToolTitle(toolCall, width)
|
||||
return renderContentBlock(app, title, width)
|
||||
}
|
||||
|
||||
var result *string
|
||||
if toolCall.State.Output != "" {
|
||||
result = &toolCall.State.Output
|
||||
}
|
||||
|
||||
toolInputMap := make(map[string]any)
|
||||
if toolCall.State.Input != nil {
|
||||
value := toolCall.State.Input
|
||||
if m, ok := value.(map[string]any); ok {
|
||||
toolInputMap = m
|
||||
keys := make([]string, 0, len(toolInputMap))
|
||||
for key := range toolInputMap {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
}
|
||||
}
|
||||
|
||||
body := ""
|
||||
t := theme.CurrentTheme()
|
||||
backgroundColor := t.BackgroundPanel()
|
||||
borderColor := t.BackgroundPanel()
|
||||
defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
|
||||
|
||||
if toolCall.State.Metadata != nil {
|
||||
metadata := toolCall.State.Metadata.(map[string]any)
|
||||
switch toolCall.Tool {
|
||||
case "read":
|
||||
var preview any
|
||||
if metadata != nil {
|
||||
preview = metadata["preview"]
|
||||
}
|
||||
if preview != nil && toolInputMap["filePath"] != nil {
|
||||
filename := toolInputMap["filePath"].(string)
|
||||
body = preview.(string)
|
||||
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
|
||||
}
|
||||
case "edit":
|
||||
if filename, ok := toolInputMap["filePath"].(string); ok {
|
||||
var diffField any
|
||||
if metadata != nil {
|
||||
diffField = metadata["diff"]
|
||||
}
|
||||
if diffField != nil {
|
||||
patch := diffField.(string)
|
||||
var formattedDiff string
|
||||
if width < 120 {
|
||||
formattedDiff, _ = diff.FormatUnifiedDiff(
|
||||
filename,
|
||||
patch,
|
||||
diff.WithWidth(width-2),
|
||||
)
|
||||
} else {
|
||||
formattedDiff, _ = diff.FormatDiff(
|
||||
filename,
|
||||
patch,
|
||||
diff.WithWidth(width-2),
|
||||
)
|
||||
}
|
||||
body = strings.TrimSpace(formattedDiff)
|
||||
style := styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
Foreground(t.TextMuted()).
|
||||
Padding(1, 2).
|
||||
Width(width - 4)
|
||||
|
||||
if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" {
|
||||
diagnostics = style.Render(diagnostics)
|
||||
body += "\n" + diagnostics
|
||||
}
|
||||
|
||||
title := renderToolTitle(toolCall, width)
|
||||
title = style.Render(title)
|
||||
content := title + "\n" + body
|
||||
content = renderContentBlock(
|
||||
app,
|
||||
content,
|
||||
width,
|
||||
WithPadding(0),
|
||||
WithBorderColor(borderColor),
|
||||
)
|
||||
return content
|
||||
}
|
||||
}
|
||||
case "write":
|
||||
if filename, ok := toolInputMap["filePath"].(string); ok {
|
||||
if content, ok := toolInputMap["content"].(string); ok {
|
||||
body = util.RenderFile(filename, content, width)
|
||||
if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-4); diagnostics != "" {
|
||||
body += "\n\n" + diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
case "bash":
|
||||
command := toolInputMap["command"].(string)
|
||||
body = fmt.Sprintf("```console\n$ %s\n", command)
|
||||
stdout := metadata["stdout"]
|
||||
if stdout != nil {
|
||||
body += ansi.Strip(fmt.Sprintf("%s", stdout))
|
||||
}
|
||||
body += "```"
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
case "webfetch":
|
||||
if format, ok := toolInputMap["format"].(string); ok && result != nil {
|
||||
body = *result
|
||||
body = util.TruncateHeight(body, 10)
|
||||
if format == "html" || format == "markdown" {
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
}
|
||||
}
|
||||
case "todowrite":
|
||||
todos := metadata["todos"]
|
||||
if todos != nil {
|
||||
for _, item := range todos.([]any) {
|
||||
todo := item.(map[string]any)
|
||||
content := todo["content"].(string)
|
||||
switch todo["status"] {
|
||||
case "completed":
|
||||
body += fmt.Sprintf("- [x] %s\n", content)
|
||||
case "cancelled":
|
||||
// strike through cancelled todo
|
||||
body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
|
||||
case "in_progress":
|
||||
// highlight in progress todo
|
||||
body += fmt.Sprintf("- [ ] `%s`\n", content)
|
||||
default:
|
||||
body += fmt.Sprintf("- [ ] %s\n", content)
|
||||
}
|
||||
}
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
}
|
||||
case "task":
|
||||
summary := metadata["summary"]
|
||||
if summary != nil {
|
||||
toolcalls := summary.([]any)
|
||||
steps := []string{}
|
||||
for _, item := range toolcalls {
|
||||
data, _ := json.Marshal(item)
|
||||
var toolCall opencode.ToolPart
|
||||
_ = json.Unmarshal(data, &toolCall)
|
||||
step := renderToolTitle(toolCall, width)
|
||||
step = "∟ " + step
|
||||
steps = append(steps, step)
|
||||
}
|
||||
body = strings.Join(steps, "\n")
|
||||
}
|
||||
body = defaultStyle(body)
|
||||
default:
|
||||
if result == nil {
|
||||
empty := ""
|
||||
result = &empty
|
||||
}
|
||||
body = *result
|
||||
body = util.TruncateHeight(body, 10)
|
||||
body = defaultStyle(body)
|
||||
}
|
||||
}
|
||||
|
||||
error := ""
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusError {
|
||||
error = toolCall.State.Error
|
||||
}
|
||||
|
||||
if error != "" {
|
||||
body = styles.NewStyle().
|
||||
Width(width - 6).
|
||||
Foreground(t.Error()).
|
||||
Background(backgroundColor).
|
||||
Render(error)
|
||||
}
|
||||
|
||||
if body == "" && error == "" && result != nil {
|
||||
body = *result
|
||||
body = util.TruncateHeight(body, 10)
|
||||
body = defaultStyle(body)
|
||||
}
|
||||
|
||||
if body == "" {
|
||||
body = defaultStyle("")
|
||||
}
|
||||
|
||||
title := renderToolTitle(toolCall, width)
|
||||
content := title + "\n\n" + body
|
||||
return renderContentBlock(app, content, width, WithBorderColor(borderColor))
|
||||
}
|
||||
|
||||
func renderToolName(name string) string {
|
||||
switch name {
|
||||
case "webfetch":
|
||||
return "Fetch"
|
||||
default:
|
||||
normalizedName := name
|
||||
if after, ok := strings.CutPrefix(name, "opencode_"); ok {
|
||||
normalizedName = after
|
||||
}
|
||||
return cases.Title(language.Und).String(normalizedName)
|
||||
}
|
||||
}
|
||||
|
||||
func getTodoPhase(metadata map[string]any) string {
|
||||
todos, ok := metadata["todos"].([]any)
|
||||
if !ok || len(todos) == 0 {
|
||||
return "Plan"
|
||||
}
|
||||
|
||||
counts := map[string]int{"pending": 0, "completed": 0}
|
||||
for _, item := range todos {
|
||||
if todo, ok := item.(map[string]any); ok {
|
||||
if status, ok := todo["status"].(string); ok {
|
||||
counts[status]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total := len(todos)
|
||||
switch {
|
||||
case counts["pending"] == total:
|
||||
return "Creating plan"
|
||||
case counts["completed"] == total:
|
||||
return "Completing plan"
|
||||
default:
|
||||
return "Updating plan"
|
||||
}
|
||||
}
|
||||
|
||||
func getTodoTitle(toolCall opencode.ToolPart) string {
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
|
||||
if metadata, ok := toolCall.State.Metadata.(map[string]any); ok {
|
||||
return getTodoPhase(metadata)
|
||||
}
|
||||
}
|
||||
return "Plan"
|
||||
}
|
||||
|
||||
func renderToolTitle(
|
||||
toolCall opencode.ToolPart,
|
||||
width int,
|
||||
) string {
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
|
||||
title := renderToolAction(toolCall.Tool)
|
||||
return styles.NewStyle().Width(width - 6).Render(title)
|
||||
}
|
||||
|
||||
toolArgs := ""
|
||||
toolArgsMap := make(map[string]any)
|
||||
if toolCall.State.Input != nil {
|
||||
value := toolCall.State.Input
|
||||
if m, ok := value.(map[string]any); ok {
|
||||
toolArgsMap = m
|
||||
|
||||
keys := make([]string, 0, len(toolArgsMap))
|
||||
for key := range toolArgsMap {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
firstKey := ""
|
||||
if len(keys) > 0 {
|
||||
firstKey = keys[0]
|
||||
}
|
||||
|
||||
toolArgs = renderArgs(&toolArgsMap, firstKey)
|
||||
}
|
||||
}
|
||||
|
||||
title := renderToolName(toolCall.Tool)
|
||||
switch toolCall.Tool {
|
||||
case "read":
|
||||
toolArgs = renderArgs(&toolArgsMap, "filePath")
|
||||
title = fmt.Sprintf("%s %s", title, toolArgs)
|
||||
case "edit", "write":
|
||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||
title = fmt.Sprintf("%s %s", title, util.Relative(filename))
|
||||
}
|
||||
case "bash", "task":
|
||||
if description, ok := toolArgsMap["description"].(string); ok {
|
||||
title = fmt.Sprintf("%s %s", title, description)
|
||||
}
|
||||
case "webfetch":
|
||||
toolArgs = renderArgs(&toolArgsMap, "url")
|
||||
title = fmt.Sprintf("%s %s", title, toolArgs)
|
||||
case "todowrite":
|
||||
title = getTodoTitle(toolCall)
|
||||
case "todoread":
|
||||
return "Plan"
|
||||
default:
|
||||
toolName := renderToolName(toolCall.Tool)
|
||||
title = fmt.Sprintf("%s %s", toolName, toolArgs)
|
||||
}
|
||||
|
||||
title = truncate.StringWithTail(title, uint(width-6), "...")
|
||||
return title
|
||||
}
|
||||
|
||||
func renderToolAction(name string) string {
|
||||
switch name {
|
||||
case "task":
|
||||
return "Planning..."
|
||||
case "bash":
|
||||
return "Writing command..."
|
||||
case "edit":
|
||||
return "Preparing edit..."
|
||||
case "webfetch":
|
||||
return "Fetching from the web..."
|
||||
case "glob":
|
||||
return "Finding files..."
|
||||
case "grep":
|
||||
return "Searching content..."
|
||||
case "list":
|
||||
return "Listing directory..."
|
||||
case "read":
|
||||
return "Reading file..."
|
||||
case "write":
|
||||
return "Preparing write..."
|
||||
case "todowrite", "todoread":
|
||||
return "Planning..."
|
||||
case "patch":
|
||||
return "Preparing patch..."
|
||||
}
|
||||
return "Working..."
|
||||
}
|
||||
|
||||
func renderArgs(args *map[string]any, titleKey string) string {
|
||||
if args == nil || len(*args) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(*args))
|
||||
for key := range *args {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
|
||||
title := ""
|
||||
parts := []string{}
|
||||
for _, key := range keys {
|
||||
value := (*args)[key]
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if key == "filePath" || key == "path" {
|
||||
value = util.Relative(value.(string))
|
||||
}
|
||||
if key == titleKey {
|
||||
title = fmt.Sprintf("%s", value)
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", key, value))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return title
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// Diagnostic represents an LSP diagnostic
|
||||
type Diagnostic struct {
|
||||
Range struct {
|
||||
Start struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
} `json:"start"`
|
||||
} `json:"range"`
|
||||
Severity int `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// renderDiagnostics formats LSP diagnostics for display in the TUI
|
||||
func renderDiagnostics(
|
||||
metadata map[string]any,
|
||||
filePath string,
|
||||
backgroundColor compat.AdaptiveColor,
|
||||
width int,
|
||||
) string {
|
||||
if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
|
||||
if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
|
||||
var errorDiagnostics []string
|
||||
for _, diagInterface := range fileDiagnostics {
|
||||
diagMap, ok := diagInterface.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Parse the diagnostic
|
||||
var diag Diagnostic
|
||||
diagBytes, err := json.Marshal(diagMap)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(diagBytes, &diag); err != nil {
|
||||
continue
|
||||
}
|
||||
// Only show error diagnostics (severity === 1)
|
||||
if diag.Severity != 1 {
|
||||
continue
|
||||
}
|
||||
line := diag.Range.Start.Line + 1 // 1-based
|
||||
column := diag.Range.Start.Character + 1 // 1-based
|
||||
errorDiagnostics = append(
|
||||
errorDiagnostics,
|
||||
fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
|
||||
)
|
||||
}
|
||||
if len(errorDiagnostics) == 0 {
|
||||
return ""
|
||||
}
|
||||
t := theme.CurrentTheme()
|
||||
var result strings.Builder
|
||||
for _, diagnostic := range errorDiagnostics {
|
||||
if result.Len() > 0 {
|
||||
result.WriteString("\n\n")
|
||||
}
|
||||
diagnostic = ansi.WordwrapWc(diagnostic, width, " -")
|
||||
result.WriteString(
|
||||
styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
Foreground(t.Error()).
|
||||
Render(diagnostic),
|
||||
)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
// diagnosticsData should be a map[string][]Diagnostic
|
||||
// strDiagnosticsData := diagnosticsData.Raw()
|
||||
// diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
|
||||
// fileDiagnostics, ok := diagnosticsMap[filePath]
|
||||
// if !ok {
|
||||
// return ""
|
||||
// }
|
||||
|
||||
// diagnosticsList, ok := fileDiagnostics.([]any)
|
||||
// if !ok {
|
||||
// return ""
|
||||
// }
|
||||
|
||||
}
|
||||
1027
packages/client/tui/internal/components/chat/messages.go
Normal file
1027
packages/client/tui/internal/components/chat/messages.go
Normal file
File diff suppressed because it is too large
Load Diff
247
packages/client/tui/internal/components/commands/commands.go
Normal file
247
packages/client/tui/internal/components/commands/commands.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type CommandsComponent interface {
|
||||
tea.ViewModel
|
||||
SetSize(width, height int) tea.Cmd
|
||||
SetBackgroundColor(color compat.AdaptiveColor)
|
||||
}
|
||||
|
||||
type commandsComponent struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
showKeybinds bool
|
||||
showAll bool
|
||||
showVscode bool
|
||||
background *compat.AdaptiveColor
|
||||
limit *int
|
||||
}
|
||||
|
||||
func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
|
||||
c.width = width
|
||||
c.height = height
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
|
||||
c.background = &color
|
||||
}
|
||||
|
||||
func (c *commandsComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
triggerStyle := styles.NewStyle().Foreground(t.Primary()).Bold(true)
|
||||
descriptionStyle := styles.NewStyle().Foreground(t.Text())
|
||||
keybindStyle := styles.NewStyle().Foreground(t.TextMuted())
|
||||
|
||||
if c.background != nil {
|
||||
triggerStyle = triggerStyle.Background(*c.background)
|
||||
descriptionStyle = descriptionStyle.Background(*c.background)
|
||||
keybindStyle = keybindStyle.Background(*c.background)
|
||||
}
|
||||
|
||||
var commandsToShow []commands.Command
|
||||
var triggeredCommands []commands.Command
|
||||
var untriggeredCommands []commands.Command
|
||||
|
||||
for _, cmd := range c.app.Commands.Sorted() {
|
||||
if c.showAll || cmd.HasTrigger() {
|
||||
if cmd.HasTrigger() {
|
||||
triggeredCommands = append(triggeredCommands, cmd)
|
||||
} else if c.showAll {
|
||||
untriggeredCommands = append(untriggeredCommands, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine triggered commands first, then untriggered
|
||||
commandsToShow = append(commandsToShow, triggeredCommands...)
|
||||
commandsToShow = append(commandsToShow, untriggeredCommands...)
|
||||
|
||||
if c.limit != nil && len(commandsToShow) > *c.limit {
|
||||
commandsToShow = commandsToShow[:*c.limit]
|
||||
}
|
||||
|
||||
if c.showVscode {
|
||||
ctrlKey := "ctrl"
|
||||
if runtime.GOOS == "darwin" {
|
||||
ctrlKey = "cmd"
|
||||
}
|
||||
commandsToShow = append(commandsToShow,
|
||||
// empty line
|
||||
commands.Command{
|
||||
Name: "",
|
||||
Description: "",
|
||||
},
|
||||
commands.Command{
|
||||
Name: commands.CommandName(util.Ide()),
|
||||
Description: "open opencode",
|
||||
Keybindings: []commands.Keybinding{
|
||||
{Key: ctrlKey + "+esc", RequiresLeader: false},
|
||||
},
|
||||
},
|
||||
commands.Command{
|
||||
Name: commands.CommandName(util.Ide()),
|
||||
Description: "reference file",
|
||||
Keybindings: []commands.Keybinding{
|
||||
{Key: ctrlKey + "+opt+k", RequiresLeader: false},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if len(commandsToShow) == 0 {
|
||||
muted := styles.NewStyle().Foreground(theme.CurrentTheme().TextMuted())
|
||||
if c.showAll {
|
||||
return muted.Render("No commands available")
|
||||
}
|
||||
return muted.Render("No commands with triggers available")
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
maxTriggerWidth := 0
|
||||
maxDescriptionWidth := 0
|
||||
maxKeybindWidth := 0
|
||||
|
||||
// Prepare command data
|
||||
type commandRow struct {
|
||||
trigger string
|
||||
description string
|
||||
keybinds string
|
||||
}
|
||||
|
||||
rows := make([]commandRow, 0, len(commandsToShow))
|
||||
|
||||
for _, cmd := range commandsToShow {
|
||||
trigger := ""
|
||||
if cmd.HasTrigger() {
|
||||
trigger = "/" + cmd.PrimaryTrigger()
|
||||
} else {
|
||||
trigger = string(cmd.Name)
|
||||
}
|
||||
description := cmd.Description
|
||||
|
||||
// Format keybindings
|
||||
var keybindStrs []string
|
||||
if c.showKeybinds {
|
||||
for _, kb := range cmd.Keybindings {
|
||||
if kb.RequiresLeader {
|
||||
keybindStrs = append(keybindStrs, c.app.Config.Keybinds.Leader+" "+kb.Key)
|
||||
} else {
|
||||
keybindStrs = append(keybindStrs, kb.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
keybinds := strings.Join(keybindStrs, ", ")
|
||||
|
||||
rows = append(rows, commandRow{
|
||||
trigger: trigger,
|
||||
description: description,
|
||||
keybinds: keybinds,
|
||||
})
|
||||
|
||||
// Update max widths
|
||||
if len(trigger) > maxTriggerWidth {
|
||||
maxTriggerWidth = len(trigger)
|
||||
}
|
||||
if len(description) > maxDescriptionWidth {
|
||||
maxDescriptionWidth = len(description)
|
||||
}
|
||||
if len(keybinds) > maxKeybindWidth {
|
||||
maxKeybindWidth = len(keybinds)
|
||||
}
|
||||
}
|
||||
|
||||
// Add padding between columns
|
||||
columnPadding := 3
|
||||
|
||||
// Build the output
|
||||
var output strings.Builder
|
||||
|
||||
maxWidth := 0
|
||||
for _, row := range rows {
|
||||
// Pad each column to align properly
|
||||
trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
|
||||
description := fmt.Sprintf("%-*s", maxDescriptionWidth, row.description)
|
||||
|
||||
// Apply styles and combine
|
||||
line := triggerStyle.Render(trigger) +
|
||||
triggerStyle.Render(strings.Repeat(" ", columnPadding)) +
|
||||
descriptionStyle.Render(description)
|
||||
|
||||
if c.showKeybinds && row.keybinds != "" {
|
||||
line += keybindStyle.Render(strings.Repeat(" ", columnPadding)) +
|
||||
keybindStyle.Render(row.keybinds)
|
||||
}
|
||||
|
||||
output.WriteString(line + "\n")
|
||||
maxWidth = max(maxWidth, lipgloss.Width(line))
|
||||
}
|
||||
|
||||
// Remove trailing newline
|
||||
result := strings.TrimSuffix(output.String(), "\n")
|
||||
if c.background != nil {
|
||||
result = styles.NewStyle().Background(*c.background).Width(maxWidth).Render(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type Option func(*commandsComponent)
|
||||
|
||||
func WithKeybinds(show bool) Option {
|
||||
return func(c *commandsComponent) {
|
||||
c.showKeybinds = show
|
||||
}
|
||||
}
|
||||
|
||||
func WithBackground(background compat.AdaptiveColor) Option {
|
||||
return func(c *commandsComponent) {
|
||||
c.background = &background
|
||||
}
|
||||
}
|
||||
|
||||
func WithLimit(limit int) Option {
|
||||
return func(c *commandsComponent) {
|
||||
c.limit = &limit
|
||||
}
|
||||
}
|
||||
|
||||
func WithShowAll(showAll bool) Option {
|
||||
return func(c *commandsComponent) {
|
||||
c.showAll = showAll
|
||||
}
|
||||
}
|
||||
|
||||
func WithVscode(showVscode bool) Option {
|
||||
return func(c *commandsComponent) {
|
||||
c.showVscode = showVscode
|
||||
}
|
||||
}
|
||||
|
||||
func New(app *app.App, opts ...Option) CommandsComponent {
|
||||
c := &commandsComponent{
|
||||
app: app,
|
||||
background: nil,
|
||||
showKeybinds: true,
|
||||
showAll: false,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
283
packages/client/tui/internal/components/dialog/complete.go
Normal file
283
packages/client/tui/internal/components/dialog/complete.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type CompletionSelectedMsg struct {
|
||||
Item completions.CompletionSuggestion
|
||||
SearchString string
|
||||
}
|
||||
|
||||
type CompletionDialogCompleteItemMsg struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
type CompletionDialogCloseMsg struct{}
|
||||
|
||||
type CompletionDialog interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
SetWidth(width int)
|
||||
IsEmpty() bool
|
||||
}
|
||||
|
||||
type completionDialogComponent struct {
|
||||
query string
|
||||
providers []completions.CompletionProvider
|
||||
width int
|
||||
height int
|
||||
pseudoSearchTextArea textarea.Model
|
||||
list list.List[completions.CompletionSuggestion]
|
||||
trigger string
|
||||
}
|
||||
|
||||
type completionDialogKeyMap struct {
|
||||
Complete key.Binding
|
||||
Cancel key.Binding
|
||||
}
|
||||
|
||||
var completionDialogKeys = completionDialogKeyMap{
|
||||
Complete: key.NewBinding(
|
||||
key.WithKeys("tab", "enter", "right"),
|
||||
),
|
||||
Cancel: key.NewBinding(
|
||||
key.WithKeys("space", " ", "esc", "backspace", "ctrl+h", "ctrl+c"),
|
||||
),
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
allItems := make([]completions.CompletionSuggestion, 0)
|
||||
providersWithResults := 0
|
||||
|
||||
// Collect results from all providers
|
||||
for _, provider := range c.providers {
|
||||
items, err := provider.GetChildEntries(query)
|
||||
if err != nil {
|
||||
slog.Error(
|
||||
"Failed to get completion items",
|
||||
"provider",
|
||||
provider.GetId(),
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if len(items) > 0 {
|
||||
providersWithResults++
|
||||
allItems = append(allItems, items...)
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a query, use fuzzy ranking to sort results
|
||||
if query != "" && providersWithResults > 1 {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Background(t.BackgroundElement())
|
||||
// Create a slice of display values for fuzzy matching
|
||||
displayValues := make([]string, len(allItems))
|
||||
for i, item := range allItems {
|
||||
displayValues[i] = item.Display(baseStyle)
|
||||
}
|
||||
|
||||
matches := fuzzy.RankFindFold(query, displayValues)
|
||||
sort.Sort(matches)
|
||||
|
||||
// Reorder items based on fuzzy ranking
|
||||
rankedItems := make([]completions.CompletionSuggestion, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
rankedItems = append(rankedItems, allItems[match.OriginalIndex])
|
||||
}
|
||||
|
||||
return rankedItems
|
||||
}
|
||||
|
||||
return allItems
|
||||
}
|
||||
}
|
||||
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case []completions.CompletionSuggestion:
|
||||
c.list.SetItems(msg)
|
||||
case tea.KeyMsg:
|
||||
if c.pseudoSearchTextArea.Focused() {
|
||||
if !key.Matches(msg, completionDialogKeys.Complete) {
|
||||
var cmd tea.Cmd
|
||||
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
fullValue := c.pseudoSearchTextArea.Value()
|
||||
query := strings.TrimPrefix(fullValue, c.trigger)
|
||||
|
||||
if query != c.query {
|
||||
c.query = query
|
||||
cmds = append(cmds, c.getAllCompletions(query))
|
||||
}
|
||||
|
||||
u, cmd := c.list.Update(msg)
|
||||
c.list = u.(list.List[completions.CompletionSuggestion])
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, completionDialogKeys.Complete):
|
||||
item, i := c.list.GetSelectedItem()
|
||||
if i == -1 {
|
||||
return c, nil
|
||||
}
|
||||
return c, c.complete(item)
|
||||
case key.Matches(msg, completionDialogKeys.Cancel):
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
width := lipgloss.Width(value)
|
||||
triggerWidth := lipgloss.Width(c.trigger)
|
||||
// Only close on backspace when there are no characters left, unless we're back to just the trigger
|
||||
if (msg.String() != "backspace" && msg.String() != "ctrl+h") || (width <= triggerWidth && value != c.trigger) {
|
||||
return c, c.close()
|
||||
}
|
||||
}
|
||||
|
||||
return c, tea.Batch(cmds...)
|
||||
} else {
|
||||
cmds = append(cmds, c.getAllCompletions(""))
|
||||
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
|
||||
return c, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
|
||||
return c, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
c.list.SetMaxWidth(c.width)
|
||||
|
||||
return styles.NewStyle().
|
||||
Padding(0, 1).
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundElement()).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
BorderForeground(t.Border()).
|
||||
BorderBackground(t.Background()).
|
||||
Width(c.width).
|
||||
Render(c.list.View())
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) SetWidth(width int) {
|
||||
c.width = width
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) IsEmpty() bool {
|
||||
return c.list.IsEmpty()
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) complete(item completions.CompletionSuggestion) tea.Cmd {
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
return tea.Batch(
|
||||
util.CmdHandler(CompletionSelectedMsg{
|
||||
SearchString: value,
|
||||
Item: item,
|
||||
}),
|
||||
c.close(),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) close() tea.Cmd {
|
||||
c.pseudoSearchTextArea.Reset()
|
||||
c.pseudoSearchTextArea.Blur()
|
||||
return util.CmdHandler(CompletionDialogCloseMsg{})
|
||||
}
|
||||
|
||||
func NewCompletionDialogComponent(
|
||||
trigger string,
|
||||
providers ...completions.CompletionProvider,
|
||||
) CompletionDialog {
|
||||
ti := textarea.New()
|
||||
ti.SetValue(trigger)
|
||||
|
||||
// Use a generic empty message if we have multiple providers
|
||||
emptyMessage := "no matching items"
|
||||
if len(providers) == 1 {
|
||||
emptyMessage = providers[0].GetEmptyMessage()
|
||||
}
|
||||
|
||||
// Define render function for completion suggestions
|
||||
renderFunc := func(item completions.CompletionSuggestion, selected bool, width int, baseStyle styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
style := baseStyle
|
||||
|
||||
if selected {
|
||||
style = style.Background(t.BackgroundElement()).Foreground(t.Primary())
|
||||
} else {
|
||||
style = style.Background(t.BackgroundElement()).Foreground(t.Text())
|
||||
}
|
||||
|
||||
// The item.Display string already has any inline colors from the provider
|
||||
truncatedStr := truncate.String(item.Display(style), uint(width-4))
|
||||
return style.Width(width - 4).Render(truncatedStr)
|
||||
}
|
||||
|
||||
// Define selectable function - all completion suggestions are selectable
|
||||
selectableFunc := func(item completions.CompletionSuggestion) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
li := list.NewListComponent(
|
||||
list.WithItems([]completions.CompletionSuggestion{}),
|
||||
list.WithMaxVisibleHeight[completions.CompletionSuggestion](7),
|
||||
list.WithFallbackMessage[completions.CompletionSuggestion](emptyMessage),
|
||||
list.WithAlphaNumericKeys[completions.CompletionSuggestion](false),
|
||||
list.WithRenderFunc(renderFunc),
|
||||
list.WithSelectableFunc(selectableFunc),
|
||||
)
|
||||
|
||||
c := &completionDialogComponent{
|
||||
query: "",
|
||||
providers: providers,
|
||||
pseudoSearchTextArea: ti,
|
||||
list: li,
|
||||
trigger: trigger,
|
||||
}
|
||||
|
||||
// Load initial items from all providers
|
||||
go func() {
|
||||
allItems := make([]completions.CompletionSuggestion, 0)
|
||||
for _, provider := range providers {
|
||||
items, err := provider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error(
|
||||
"Failed to get completion items",
|
||||
"provider",
|
||||
provider.GetId(),
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
allItems = append(allItems, items...)
|
||||
}
|
||||
li.SetItems(allItems)
|
||||
}()
|
||||
|
||||
return c
|
||||
}
|
||||
236
packages/client/tui/internal/components/dialog/find.go
Normal file
236
packages/client/tui/internal/components/dialog/find.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
findDialogWidth = 76
|
||||
)
|
||||
|
||||
type FindSelectedMsg struct {
|
||||
FilePath string
|
||||
}
|
||||
|
||||
type FindDialogCloseMsg struct{}
|
||||
|
||||
type findInitialSuggestionsMsg struct {
|
||||
suggestions []completions.CompletionSuggestion
|
||||
}
|
||||
|
||||
type FindDialog interface {
|
||||
layout.Modal
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
SetWidth(width int)
|
||||
SetHeight(height int)
|
||||
IsEmpty() bool
|
||||
}
|
||||
|
||||
// findItem is a custom list item for file suggestions
|
||||
type findItem struct {
|
||||
suggestion completions.CompletionSuggestion
|
||||
}
|
||||
|
||||
func (f findItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundPanel()).
|
||||
Foreground(t.TextMuted())
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.Foreground(t.Primary())
|
||||
}
|
||||
|
||||
return itemStyle.PaddingLeft(1).Render(f.suggestion.Display(itemStyle))
|
||||
}
|
||||
|
||||
func (f findItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type findDialogComponent struct {
|
||||
completionProvider completions.CompletionProvider
|
||||
allSuggestions []completions.CompletionSuggestion
|
||||
width, height int
|
||||
modal *modal.Modal
|
||||
searchDialog *SearchDialog
|
||||
dialogWidth int
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
f.loadInitialSuggestions(),
|
||||
f.searchDialog.Init(),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) loadInitialSuggestions() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := f.completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error("Failed to get initial completion items", "error", err)
|
||||
return findInitialSuggestionsMsg{suggestions: []completions.CompletionSuggestion{}}
|
||||
}
|
||||
return findInitialSuggestionsMsg{suggestions: items}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case findInitialSuggestionsMsg:
|
||||
// Handle initial suggestions setup
|
||||
f.allSuggestions = msg.suggestions
|
||||
|
||||
// Calculate dialog width
|
||||
f.dialogWidth = f.calculateDialogWidth()
|
||||
|
||||
// Initialize search dialog with calculated width
|
||||
f.searchDialog = NewSearchDialog("Search files...", 10)
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
|
||||
// Convert to list items
|
||||
items := make([]list.Item, len(f.allSuggestions))
|
||||
for i, suggestion := range f.allSuggestions {
|
||||
items[i] = findItem{suggestion: suggestion}
|
||||
}
|
||||
f.searchDialog.SetItems(items)
|
||||
|
||||
// Update modal with calculated width
|
||||
f.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(f.dialogWidth+4),
|
||||
)
|
||||
|
||||
return f, f.searchDialog.Init()
|
||||
|
||||
case []completions.CompletionSuggestion:
|
||||
// Store suggestions and convert to findItem for the search dialog
|
||||
f.allSuggestions = msg
|
||||
items := make([]list.Item, len(msg))
|
||||
for i, suggestion := range msg {
|
||||
items[i] = findItem{suggestion: suggestion}
|
||||
}
|
||||
f.searchDialog.SetItems(items)
|
||||
return f, nil
|
||||
|
||||
case SearchSelectionMsg:
|
||||
// Handle selection from search dialog - now we can directly access the suggestion
|
||||
if item, ok := msg.Item.(findItem); ok {
|
||||
return f, f.selectFile(item.suggestion)
|
||||
}
|
||||
return f, nil
|
||||
|
||||
case SearchCancelledMsg:
|
||||
return f, f.Close()
|
||||
|
||||
case SearchQueryChangedMsg:
|
||||
// Update completion items based on search query
|
||||
return f, func() tea.Msg {
|
||||
items, err := f.completionProvider.GetChildEntries(msg.Query)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
return []completions.CompletionSuggestion{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
f.width = msg.Width
|
||||
f.height = msg.Height
|
||||
// Recalculate width based on new viewport size
|
||||
oldWidth := f.dialogWidth
|
||||
f.dialogWidth = f.calculateDialogWidth()
|
||||
if oldWidth != f.dialogWidth {
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
// Update modal max width too
|
||||
f.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(f.dialogWidth+4),
|
||||
)
|
||||
}
|
||||
f.searchDialog.SetHeight(msg.Height)
|
||||
}
|
||||
|
||||
// Forward all other messages to the search dialog
|
||||
updatedDialog, cmd := f.searchDialog.Update(msg)
|
||||
f.searchDialog = updatedDialog.(*SearchDialog)
|
||||
return f, cmd
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) View() string {
|
||||
return f.searchDialog.View()
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) calculateDialogWidth() int {
|
||||
// Use fixed width unless viewport is smaller
|
||||
if f.width > 0 && f.width < findDialogWidth+10 {
|
||||
return f.width - 10
|
||||
}
|
||||
return findDialogWidth
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) SetWidth(width int) {
|
||||
f.width = width
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) SetHeight(height int) {
|
||||
f.height = height
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) IsEmpty() bool {
|
||||
return f.searchDialog.GetQuery() == ""
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) selectFile(item completions.CompletionSuggestion) tea.Cmd {
|
||||
return tea.Sequence(
|
||||
f.Close(),
|
||||
util.CmdHandler(FindSelectedMsg{
|
||||
FilePath: item.Value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Render(background string) string {
|
||||
return f.modal.Render(f.View(), background)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Close() tea.Cmd {
|
||||
f.searchDialog.SetQuery("")
|
||||
f.searchDialog.Blur()
|
||||
return util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
|
||||
func NewFindDialog(completionProvider completions.CompletionProvider) FindDialog {
|
||||
component := &findDialogComponent{
|
||||
completionProvider: completionProvider,
|
||||
dialogWidth: findDialogWidth,
|
||||
allSuggestions: []completions.CompletionSuggestion{},
|
||||
}
|
||||
|
||||
// Create search dialog and modal with fixed width
|
||||
component.searchDialog = NewSearchDialog("Search files...", 10)
|
||||
component.searchDialog.SetWidth(findDialogWidth)
|
||||
|
||||
component.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(findDialogWidth+4),
|
||||
)
|
||||
|
||||
return component
|
||||
}
|
||||
80
packages/client/tui/internal/components/dialog/help.go
Normal file
80
packages/client/tui/internal/components/dialog/help.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
commandsComponent "github.com/sst/opencode/internal/components/commands"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/viewport"
|
||||
)
|
||||
|
||||
type helpDialog struct {
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
app *app.App
|
||||
commandsComponent commandsComponent.CommandsComponent
|
||||
viewport viewport.Model
|
||||
}
|
||||
|
||||
func (h *helpDialog) Init() tea.Cmd {
|
||||
return h.viewport.Init()
|
||||
}
|
||||
|
||||
func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
h.width = msg.Width
|
||||
h.height = msg.Height
|
||||
// Set viewport size with some padding for the modal, but cap at reasonable width
|
||||
maxWidth := min(80, msg.Width-8)
|
||||
h.viewport = viewport.New(viewport.WithWidth(maxWidth-4), viewport.WithHeight(msg.Height-6))
|
||||
h.commandsComponent.SetSize(maxWidth-4, msg.Height-6)
|
||||
}
|
||||
|
||||
// Update viewport content
|
||||
h.viewport.SetContent(h.commandsComponent.View())
|
||||
|
||||
// Update viewport
|
||||
var vpCmd tea.Cmd
|
||||
h.viewport, vpCmd = h.viewport.Update(msg)
|
||||
cmds = append(cmds, vpCmd)
|
||||
|
||||
return h, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (h *helpDialog) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
h.commandsComponent.SetBackgroundColor(t.BackgroundPanel())
|
||||
return h.viewport.View()
|
||||
}
|
||||
|
||||
func (h *helpDialog) Render(background string) string {
|
||||
return h.modal.Render(h.View(), background)
|
||||
}
|
||||
|
||||
func (h *helpDialog) Close() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
type HelpDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
func NewHelpDialog(app *app.App) HelpDialog {
|
||||
vp := viewport.New(viewport.WithHeight(12))
|
||||
return &helpDialog{
|
||||
app: app,
|
||||
commandsComponent: commandsComponent.New(app,
|
||||
commandsComponent.WithBackground(theme.CurrentTheme().BackgroundPanel()),
|
||||
commandsComponent.WithShowAll(true),
|
||||
commandsComponent.WithKeybinds(true),
|
||||
),
|
||||
modal: modal.New(modal.WithTitle("Help"), modal.WithMaxWidth(80)),
|
||||
viewport: vp,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -94,7 +94,7 @@ func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// View implements tea.Model.
|
||||
func (m InitDialogCmp) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||
|
||||
// Calculate width needed for content
|
||||
maxWidth := 60 // Width for explanation text
|
||||
@@ -173,11 +173,6 @@ func (m *InitDialogCmp) SetSize(width, height int) {
|
||||
m.height = height
|
||||
}
|
||||
|
||||
// Bindings implements layout.Bindings.
|
||||
func (m InitDialogCmp) Bindings() []key.Binding {
|
||||
return m.keys.ShortHelp()
|
||||
}
|
||||
|
||||
// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
|
||||
type CloseInitDialogMsg struct {
|
||||
Initialize bool
|
||||
457
packages/client/tui/internal/components/dialog/models.go
Normal file
457
packages/client/tui/internal/components/dialog/models.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
numVisibleModels = 10
|
||||
minDialogWidth = 40
|
||||
maxDialogWidth = 80
|
||||
maxRecentModels = 5
|
||||
)
|
||||
|
||||
// ModelDialog interface for the model selection dialog
|
||||
type ModelDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
type modelDialog struct {
|
||||
app *app.App
|
||||
allModels []ModelWithProvider
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
searchDialog *SearchDialog
|
||||
dialogWidth int
|
||||
}
|
||||
|
||||
type ModelWithProvider struct {
|
||||
Model opencode.Model
|
||||
Provider opencode.Provider
|
||||
}
|
||||
|
||||
// modelItem is a custom list item for model selections
|
||||
type modelItem struct {
|
||||
model ModelWithProvider
|
||||
}
|
||||
|
||||
func (m modelItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundPanel()).
|
||||
Foreground(t.Text())
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.Foreground(t.Primary())
|
||||
}
|
||||
|
||||
providerStyle := baseStyle.
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundPanel())
|
||||
|
||||
modelPart := itemStyle.Render(m.model.Model.Name)
|
||||
providerPart := providerStyle.Render(fmt.Sprintf(" %s", m.model.Provider.Name))
|
||||
|
||||
combinedText := modelPart + providerPart
|
||||
return baseStyle.
|
||||
Background(t.BackgroundPanel()).
|
||||
PaddingLeft(1).
|
||||
Render(combinedText)
|
||||
}
|
||||
|
||||
func (m modelItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type modelKeyMap struct {
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
}
|
||||
|
||||
var modelKeys = modelKeyMap{
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select model"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "close"),
|
||||
),
|
||||
}
|
||||
|
||||
func (m *modelDialog) Init() tea.Cmd {
|
||||
m.setupAllModels()
|
||||
return m.searchDialog.Init()
|
||||
}
|
||||
|
||||
func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case SearchSelectionMsg:
|
||||
// Handle selection from search dialog
|
||||
if item, ok := msg.Item.(modelItem); ok {
|
||||
return m, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(
|
||||
app.ModelSelectedMsg{
|
||||
Provider: item.model.Provider,
|
||||
Model: item.model.Model,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
case SearchCancelledMsg:
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
|
||||
case SearchRemoveItemMsg:
|
||||
if item, ok := msg.Item.(modelItem); ok {
|
||||
if m.isModelInRecentSection(item.model, msg.Index) {
|
||||
m.app.State.RemoveModelFromRecentlyUsed(item.model.Provider.ID, item.model.Model.ID)
|
||||
items := m.buildDisplayList(m.searchDialog.GetQuery())
|
||||
m.searchDialog.SetItems(items)
|
||||
return m, m.app.SaveState()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case SearchQueryChangedMsg:
|
||||
// Update the list based on search query
|
||||
items := m.buildDisplayList(msg.Query)
|
||||
m.searchDialog.SetItems(items)
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.searchDialog.SetWidth(m.dialogWidth)
|
||||
m.searchDialog.SetHeight(msg.Height)
|
||||
}
|
||||
|
||||
updatedDialog, cmd := m.searchDialog.Update(msg)
|
||||
m.searchDialog = updatedDialog.(*SearchDialog)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *modelDialog) View() string {
|
||||
return m.searchDialog.View()
|
||||
}
|
||||
|
||||
func (m *modelDialog) calculateOptimalWidth(models []ModelWithProvider) int {
|
||||
maxWidth := minDialogWidth
|
||||
|
||||
for _, model := range models {
|
||||
// Calculate the width needed for this item: "ModelName (ProviderName)"
|
||||
// Add 4 for the parentheses, space, and some padding
|
||||
itemWidth := len(model.Model.Name) + len(model.Provider.Name) + 4
|
||||
if itemWidth > maxWidth {
|
||||
maxWidth = itemWidth
|
||||
}
|
||||
}
|
||||
|
||||
if maxWidth > maxDialogWidth {
|
||||
maxWidth = maxDialogWidth
|
||||
}
|
||||
|
||||
return maxWidth
|
||||
}
|
||||
|
||||
func (m *modelDialog) setupAllModels() {
|
||||
providers, _ := m.app.ListProviders(context.Background())
|
||||
|
||||
m.allModels = make([]ModelWithProvider, 0)
|
||||
for _, provider := range providers {
|
||||
for _, model := range provider.Models {
|
||||
m.allModels = append(m.allModels, ModelWithProvider{
|
||||
Model: model,
|
||||
Provider: provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
m.sortModels()
|
||||
|
||||
// Calculate optimal width based on all models
|
||||
m.dialogWidth = m.calculateOptimalWidth(m.allModels)
|
||||
|
||||
// Initialize search dialog
|
||||
m.searchDialog = NewSearchDialog("Search models...", numVisibleModels)
|
||||
m.searchDialog.SetWidth(m.dialogWidth)
|
||||
|
||||
// Build initial display list (empty query shows grouped view)
|
||||
items := m.buildDisplayList("")
|
||||
m.searchDialog.SetItems(items)
|
||||
}
|
||||
|
||||
func (m *modelDialog) sortModels() {
|
||||
sort.Slice(m.allModels, func(i, j int) bool {
|
||||
modelA := m.allModels[i]
|
||||
modelB := m.allModels[j]
|
||||
|
||||
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
|
||||
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
|
||||
|
||||
// If both have usage times, sort by most recent first
|
||||
if !usageA.IsZero() && !usageB.IsZero() {
|
||||
return usageA.After(usageB)
|
||||
}
|
||||
|
||||
// If only one has usage time, it goes first
|
||||
if !usageA.IsZero() && usageB.IsZero() {
|
||||
return true
|
||||
}
|
||||
if usageA.IsZero() && !usageB.IsZero() {
|
||||
return false
|
||||
}
|
||||
|
||||
// If neither has usage time, sort by release date desc if available
|
||||
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
|
||||
dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
|
||||
dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
|
||||
if !dateA.IsZero() && !dateB.IsZero() {
|
||||
return dateA.After(dateB)
|
||||
}
|
||||
}
|
||||
|
||||
// If only one has release date, it goes first
|
||||
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
|
||||
return true
|
||||
}
|
||||
if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// If neither has usage time nor release date, fall back to alphabetical sorting
|
||||
return modelA.Model.Name < modelB.Model.Name
|
||||
})
|
||||
}
|
||||
|
||||
func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
|
||||
if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
|
||||
return parsed
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
|
||||
for _, usage := range m.app.State.RecentlyUsedModels {
|
||||
if usage.ProviderID == providerID && usage.ModelID == modelID {
|
||||
return usage.LastUsed
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// buildDisplayList creates the list items based on search query
|
||||
func (m *modelDialog) buildDisplayList(query string) []list.Item {
|
||||
if query != "" {
|
||||
// Search mode: use fuzzy matching
|
||||
return m.buildSearchResults(query)
|
||||
} else {
|
||||
// Grouped mode: show Recent section and provider groups
|
||||
return m.buildGroupedResults()
|
||||
}
|
||||
}
|
||||
|
||||
// buildSearchResults creates a flat list of search results using fuzzy matching
|
||||
func (m *modelDialog) buildSearchResults(query string) []list.Item {
|
||||
type modelMatch struct {
|
||||
model ModelWithProvider
|
||||
score int
|
||||
}
|
||||
|
||||
modelNames := []string{}
|
||||
modelMap := make(map[string]ModelWithProvider)
|
||||
|
||||
// Create search strings and perform fuzzy matching
|
||||
for _, model := range m.allModels {
|
||||
searchStr := fmt.Sprintf("%s %s", model.Model.Name, model.Provider.Name)
|
||||
modelNames = append(modelNames, searchStr)
|
||||
modelMap[searchStr] = model
|
||||
|
||||
searchStr = fmt.Sprintf("%s %s", model.Provider.Name, model.Model.Name)
|
||||
modelNames = append(modelNames, searchStr)
|
||||
modelMap[searchStr] = model
|
||||
}
|
||||
|
||||
matches := fuzzy.RankFindFold(query, modelNames)
|
||||
sort.Sort(matches)
|
||||
|
||||
items := []list.Item{}
|
||||
seenModels := make(map[string]bool)
|
||||
|
||||
for _, match := range matches {
|
||||
model := modelMap[match.Target]
|
||||
// Create a unique key to avoid duplicates
|
||||
key := fmt.Sprintf("%s:%s", model.Provider.ID, model.Model.ID)
|
||||
if seenModels[key] {
|
||||
continue
|
||||
}
|
||||
seenModels[key] = true
|
||||
items = append(items, modelItem{model: model})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// buildGroupedResults creates a grouped list with Recent section and provider groups
|
||||
func (m *modelDialog) buildGroupedResults() []list.Item {
|
||||
var items []list.Item
|
||||
|
||||
// Add Recent section
|
||||
recentModels := m.getRecentModels(maxRecentModels)
|
||||
if len(recentModels) > 0 {
|
||||
items = append(items, list.HeaderItem("Recent"))
|
||||
for _, model := range recentModels {
|
||||
items = append(items, modelItem{model: model})
|
||||
}
|
||||
}
|
||||
|
||||
// Group models by provider
|
||||
providerGroups := make(map[string][]ModelWithProvider)
|
||||
for _, model := range m.allModels {
|
||||
providerName := model.Provider.Name
|
||||
providerGroups[providerName] = append(providerGroups[providerName], model)
|
||||
}
|
||||
|
||||
// Get sorted provider names for consistent order
|
||||
var providerNames []string
|
||||
for name := range providerGroups {
|
||||
providerNames = append(providerNames, name)
|
||||
}
|
||||
sort.Strings(providerNames)
|
||||
|
||||
// Add provider groups
|
||||
for _, providerName := range providerNames {
|
||||
models := providerGroups[providerName]
|
||||
|
||||
// Sort models within provider group
|
||||
sort.Slice(models, func(i, j int) bool {
|
||||
modelA := models[i]
|
||||
modelB := models[j]
|
||||
|
||||
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
|
||||
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
|
||||
|
||||
// Sort by usage time first, then by release date, then alphabetically
|
||||
if !usageA.IsZero() && !usageB.IsZero() {
|
||||
return usageA.After(usageB)
|
||||
}
|
||||
if !usageA.IsZero() && usageB.IsZero() {
|
||||
return true
|
||||
}
|
||||
if usageA.IsZero() && !usageB.IsZero() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sort by release date if available
|
||||
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
|
||||
dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
|
||||
dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
|
||||
if !dateA.IsZero() && !dateB.IsZero() {
|
||||
return dateA.After(dateB)
|
||||
}
|
||||
}
|
||||
|
||||
return modelA.Model.Name < modelB.Model.Name
|
||||
})
|
||||
|
||||
// Add provider header
|
||||
items = append(items, list.HeaderItem(providerName))
|
||||
|
||||
// Add models in this provider group
|
||||
for _, model := range models {
|
||||
items = append(items, modelItem{model: model})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// getRecentModels returns the most recently used models
|
||||
func (m *modelDialog) getRecentModels(limit int) []ModelWithProvider {
|
||||
var recentModels []ModelWithProvider
|
||||
|
||||
// Get recent models from app state
|
||||
for _, usage := range m.app.State.RecentlyUsedModels {
|
||||
if len(recentModels) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
// Find the corresponding model
|
||||
for _, model := range m.allModels {
|
||||
if model.Provider.ID == usage.ProviderID && model.Model.ID == usage.ModelID {
|
||||
recentModels = append(recentModels, model)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return recentModels
|
||||
}
|
||||
|
||||
func (m *modelDialog) isModelInRecentSection(model ModelWithProvider, index int) bool {
|
||||
// Only check if we're in grouped mode (no search query)
|
||||
if m.searchDialog.GetQuery() != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
recentModels := m.getRecentModels(maxRecentModels)
|
||||
if len(recentModels) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Index 0 is the "Recent" header, so recent models are at indices 1 to len(recentModels)
|
||||
if index >= 1 && index <= len(recentModels) {
|
||||
if index-1 < len(recentModels) {
|
||||
recentModel := recentModels[index-1]
|
||||
return recentModel.Provider.ID == model.Provider.ID &&
|
||||
recentModel.Model.ID == model.Model.ID
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *modelDialog) Render(background string) string {
|
||||
return m.modal.Render(m.View(), background)
|
||||
}
|
||||
|
||||
func (s *modelDialog) Close() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewModelDialog(app *app.App) ModelDialog {
|
||||
dialog := &modelDialog{
|
||||
app: app,
|
||||
}
|
||||
|
||||
dialog.setupAllModels()
|
||||
|
||||
dialog.modal = modal.New(
|
||||
modal.WithTitle("Select Model"),
|
||||
modal.WithMaxWidth(dialog.dialogWidth+4),
|
||||
)
|
||||
|
||||
return dialog
|
||||
}
|
||||
247
packages/client/tui/internal/components/dialog/search.go
Normal file
247
packages/client/tui/internal/components/dialog/search.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
// SearchQueryChangedMsg is emitted when the search query changes
|
||||
type SearchQueryChangedMsg struct {
|
||||
Query string
|
||||
}
|
||||
|
||||
// SearchSelectionMsg is emitted when an item is selected
|
||||
type SearchSelectionMsg struct {
|
||||
Item any
|
||||
Index int
|
||||
}
|
||||
|
||||
// SearchCancelledMsg is emitted when the search is cancelled
|
||||
type SearchCancelledMsg struct{}
|
||||
|
||||
// SearchRemoveItemMsg is emitted when Ctrl+X is pressed to remove an item
|
||||
type SearchRemoveItemMsg struct {
|
||||
Item any
|
||||
Index int
|
||||
}
|
||||
|
||||
// SearchDialog is a reusable component that combines a text input with a list
|
||||
type SearchDialog struct {
|
||||
textInput textinput.Model
|
||||
list list.List[list.Item]
|
||||
width int
|
||||
height int
|
||||
focused bool
|
||||
}
|
||||
|
||||
type searchKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
Remove key.Binding
|
||||
}
|
||||
|
||||
var searchKeys = searchKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "ctrl+p"),
|
||||
key.WithHelp("↑", "previous item"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "ctrl+n"),
|
||||
key.WithHelp("↓", "next item"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "cancel"),
|
||||
),
|
||||
Remove: key.NewBinding(
|
||||
key.WithKeys("ctrl+x"),
|
||||
key.WithHelp("ctrl+x", "remove from recent"),
|
||||
),
|
||||
}
|
||||
|
||||
// NewSearchDialog creates a new SearchDialog
|
||||
func NewSearchDialog(placeholder string, maxVisibleHeight int) *SearchDialog {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = placeholder
|
||||
ti.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Blurred.Text = styles.NewStyle().
|
||||
Foreground(textColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Text = styles.NewStyle().
|
||||
Foreground(textColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Prompt = styles.NewStyle().
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Cursor.Color = t.Primary()
|
||||
ti.VirtualCursor = true
|
||||
|
||||
ti.Prompt = " "
|
||||
ti.CharLimit = -1
|
||||
ti.Focus()
|
||||
|
||||
emptyList := list.NewListComponent(
|
||||
list.WithItems([]list.Item{}),
|
||||
list.WithMaxVisibleHeight[list.Item](maxVisibleHeight),
|
||||
list.WithFallbackMessage[list.Item](" No items"),
|
||||
list.WithAlphaNumericKeys[list.Item](false),
|
||||
list.WithRenderFunc(
|
||||
func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, baseStyle)
|
||||
},
|
||||
),
|
||||
list.WithSelectableFunc(func(item list.Item) bool {
|
||||
return item.Selectable()
|
||||
}),
|
||||
)
|
||||
|
||||
return &SearchDialog{
|
||||
textInput: ti,
|
||||
list: emptyList,
|
||||
focused: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchDialog) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
value := s.textInput.Value()
|
||||
if value == "" {
|
||||
return s, nil
|
||||
}
|
||||
s.textInput.Reset()
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
return SearchQueryChangedMsg{Query: ""}
|
||||
})
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, searchKeys.Escape):
|
||||
return s, func() tea.Msg { return SearchCancelledMsg{} }
|
||||
|
||||
case key.Matches(msg, searchKeys.Enter):
|
||||
if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
|
||||
return s, func() tea.Msg {
|
||||
return SearchSelectionMsg{Item: selectedItem, Index: idx}
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, searchKeys.Remove):
|
||||
if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
|
||||
return s, func() tea.Msg {
|
||||
return SearchRemoveItemMsg{Item: selectedItem, Index: idx}
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, searchKeys.Up):
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[list.Item])
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case key.Matches(msg, searchKeys.Down):
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[list.Item])
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
default:
|
||||
oldValue := s.textInput.Value()
|
||||
var cmd tea.Cmd
|
||||
s.textInput, cmd = s.textInput.Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
if newValue := s.textInput.Value(); newValue != oldValue {
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
return SearchQueryChangedMsg{Query: newValue}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (s *SearchDialog) View() string {
|
||||
s.list.SetMaxWidth(s.width)
|
||||
listView := s.list.View()
|
||||
listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleHeight(), lipgloss.Top, listView)
|
||||
textinput := s.textInput.View()
|
||||
return textinput + "\n\n" + listView
|
||||
}
|
||||
|
||||
// SetWidth sets the width of the search dialog
|
||||
func (s *SearchDialog) SetWidth(width int) {
|
||||
s.width = width
|
||||
s.textInput.SetWidth(width - 2) // Account for padding and borders
|
||||
}
|
||||
|
||||
// SetHeight sets the height of the search dialog
|
||||
func (s *SearchDialog) SetHeight(height int) {
|
||||
s.height = height
|
||||
}
|
||||
|
||||
// SetItems updates the list items
|
||||
func (s *SearchDialog) SetItems(items []list.Item) {
|
||||
s.list.SetItems(items)
|
||||
}
|
||||
|
||||
// GetQuery returns the current search query
|
||||
func (s *SearchDialog) GetQuery() string {
|
||||
return s.textInput.Value()
|
||||
}
|
||||
|
||||
// SetQuery sets the search query
|
||||
func (s *SearchDialog) SetQuery(query string) {
|
||||
s.textInput.SetValue(query)
|
||||
}
|
||||
|
||||
// Focus focuses the search dialog
|
||||
func (s *SearchDialog) Focus() {
|
||||
s.focused = true
|
||||
s.textInput.Focus()
|
||||
}
|
||||
|
||||
// Blur removes focus from the search dialog
|
||||
func (s *SearchDialog) Blur() {
|
||||
s.focused = false
|
||||
s.textInput.Blur()
|
||||
}
|
||||
282
packages/client/tui/internal/components/dialog/session.go
Normal file
282
packages/client/tui/internal/components/dialog/session.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"slices"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
// SessionDialog interface for the session switching dialog
|
||||
type SessionDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
// sessionItem is a custom list item for sessions that can show delete confirmation
|
||||
type sessionItem struct {
|
||||
title string
|
||||
isDeleteConfirming bool
|
||||
isCurrentSession bool
|
||||
}
|
||||
|
||||
func (s sessionItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
isFirstInViewport bool,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
var text string
|
||||
if s.isDeleteConfirming {
|
||||
text = "Press again to confirm delete"
|
||||
} else {
|
||||
if s.isCurrentSession {
|
||||
text = "● " + s.title
|
||||
} else {
|
||||
text = s.title
|
||||
}
|
||||
}
|
||||
|
||||
truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
|
||||
|
||||
var itemStyle styles.Style
|
||||
if selected {
|
||||
if s.isDeleteConfirming {
|
||||
// Red background for delete confirmation
|
||||
itemStyle = baseStyle.
|
||||
Background(t.Error()).
|
||||
Foreground(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingLeft(1)
|
||||
} else if s.isCurrentSession {
|
||||
// Different style for current session when selected
|
||||
itemStyle = baseStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingLeft(1).
|
||||
Bold(true)
|
||||
} else {
|
||||
// Normal selection
|
||||
itemStyle = baseStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingLeft(1)
|
||||
}
|
||||
} else {
|
||||
if s.isDeleteConfirming {
|
||||
// Red text for delete confirmation when not selected
|
||||
itemStyle = baseStyle.
|
||||
Foreground(t.Error()).
|
||||
PaddingLeft(1)
|
||||
} else if s.isCurrentSession {
|
||||
// Highlight current session when not selected
|
||||
itemStyle = baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
PaddingLeft(1).
|
||||
Bold(true)
|
||||
} else {
|
||||
itemStyle = baseStyle.
|
||||
PaddingLeft(1)
|
||||
}
|
||||
}
|
||||
|
||||
return itemStyle.Render(truncatedStr)
|
||||
}
|
||||
|
||||
func (s sessionItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type sessionDialog struct {
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
sessions []opencode.Session
|
||||
list list.List[sessionItem]
|
||||
app *app.App
|
||||
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
s.width = msg.Width
|
||||
s.height = msg.Height
|
||||
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if s.deleteConfirmation >= 0 {
|
||||
s.deleteConfirmation = -1
|
||||
s.updateListItems()
|
||||
return s, nil
|
||||
}
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||
selectedSession := s.sessions[idx]
|
||||
return s, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
|
||||
)
|
||||
}
|
||||
case "n":
|
||||
s.app.Session = &opencode.Session{}
|
||||
s.app.Messages = []app.Message{}
|
||||
return s, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(app.SessionClearedMsg{}),
|
||||
)
|
||||
case "x", "delete", "backspace":
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||
if s.deleteConfirmation == idx {
|
||||
// Second press - actually delete the session
|
||||
sessionToDelete := s.sessions[idx]
|
||||
return s, tea.Sequence(
|
||||
func() tea.Msg {
|
||||
s.sessions = slices.Delete(s.sessions, idx, idx+1)
|
||||
s.deleteConfirmation = -1
|
||||
s.updateListItems()
|
||||
return nil
|
||||
},
|
||||
s.deleteSession(sessionToDelete.ID),
|
||||
)
|
||||
} else {
|
||||
// First press - enter delete confirmation mode
|
||||
s.deleteConfirmation = idx
|
||||
s.updateListItems()
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
case "esc":
|
||||
if s.deleteConfirmation >= 0 {
|
||||
s.deleteConfirmation = -1
|
||||
s.updateListItems()
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[sessionItem])
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Render(background string) string {
|
||||
listView := s.list.View()
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
|
||||
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
|
||||
|
||||
leftHelp := keyStyle("n") + mutedStyle(" new session")
|
||||
rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
|
||||
|
||||
bgColor := t.BackgroundPanel()
|
||||
helpText := layout.Render(layout.FlexOptions{
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Width: layout.Current.Container.Width - 14,
|
||||
Background: &bgColor,
|
||||
}, layout.FlexItem{View: leftHelp}, layout.FlexItem{View: rightHelp})
|
||||
|
||||
helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
|
||||
|
||||
content := strings.Join([]string{listView, helpText}, "\n")
|
||||
|
||||
return s.modal.Render(content, background)
|
||||
}
|
||||
|
||||
func (s *sessionDialog) updateListItems() {
|
||||
_, currentIdx := s.list.GetSelectedItem()
|
||||
|
||||
var items []sessionItem
|
||||
for i, sess := range s.sessions {
|
||||
item := sessionItem{
|
||||
title: sess.Title,
|
||||
isDeleteConfirming: s.deleteConfirmation == i,
|
||||
isCurrentSession: s.app.Session != nil && s.app.Session.ID == sess.ID,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
s.list.SetItems(items)
|
||||
s.list.SetSelectedIndex(currentIdx)
|
||||
}
|
||||
|
||||
func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
if err := s.app.DeleteSession(ctx, sessionID); err != nil {
|
||||
return toast.NewErrorToast("Failed to delete session: " + err.Error())()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionDialog) Close() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSessionDialog creates a new session switching dialog
|
||||
func NewSessionDialog(app *app.App) SessionDialog {
|
||||
sessions, _ := app.ListSessions(context.Background())
|
||||
|
||||
var filteredSessions []opencode.Session
|
||||
var items []sessionItem
|
||||
for _, sess := range sessions {
|
||||
if sess.ParentID != "" {
|
||||
continue
|
||||
}
|
||||
filteredSessions = append(filteredSessions, sess)
|
||||
items = append(items, sessionItem{
|
||||
title: sess.Title,
|
||||
isDeleteConfirming: false,
|
||||
isCurrentSession: app.Session != nil && app.Session.ID == sess.ID,
|
||||
})
|
||||
}
|
||||
|
||||
listComponent := list.NewListComponent(
|
||||
list.WithItems(items),
|
||||
list.WithMaxVisibleHeight[sessionItem](10),
|
||||
list.WithFallbackMessage[sessionItem]("No sessions available"),
|
||||
list.WithAlphaNumericKeys[sessionItem](true),
|
||||
list.WithRenderFunc(
|
||||
func(item sessionItem, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, false, baseStyle)
|
||||
},
|
||||
),
|
||||
list.WithSelectableFunc(func(item sessionItem) bool {
|
||||
return true
|
||||
}),
|
||||
)
|
||||
listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
|
||||
return &sessionDialog{
|
||||
sessions: filteredSessions,
|
||||
list: listComponent,
|
||||
app: app,
|
||||
deleteConfirmation: -1,
|
||||
modal: modal.New(
|
||||
modal.WithTitle("Switch Session"),
|
||||
modal.WithMaxWidth(layout.Current.Container.Width-8),
|
||||
),
|
||||
}
|
||||
}
|
||||
132
packages/client/tui/internal/components/dialog/theme.go
Normal file
132
packages/client/tui/internal/components/dialog/theme.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
list "github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
// ThemeSelectedMsg is sent when the theme is changed
|
||||
type ThemeSelectedMsg struct {
|
||||
ThemeName string
|
||||
}
|
||||
|
||||
// ThemeDialog interface for the theme switching dialog
|
||||
type ThemeDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
type themeDialog struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
modal *modal.Modal
|
||||
list list.List[list.Item]
|
||||
originalTheme string
|
||||
themeApplied bool
|
||||
}
|
||||
|
||||
func (t *themeDialog) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.width = msg.Width
|
||||
t.height = msg.Height
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if item, idx := t.list.GetSelectedItem(); idx >= 0 {
|
||||
if stringItem, ok := item.(list.StringItem); ok {
|
||||
selectedTheme := string(stringItem)
|
||||
if err := theme.SetTheme(selectedTheme); err != nil {
|
||||
// status.Error(err.Error())
|
||||
return t, nil
|
||||
}
|
||||
t.themeApplied = true
|
||||
return t, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
_, prevIdx := t.list.GetSelectedItem()
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := t.list.Update(msg)
|
||||
t.list = listModel.(list.List[list.Item])
|
||||
|
||||
if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx {
|
||||
if stringItem, ok := item.(list.StringItem); ok {
|
||||
theme.SetTheme(string(stringItem))
|
||||
return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(stringItem)})
|
||||
}
|
||||
}
|
||||
return t, cmd
|
||||
}
|
||||
|
||||
func (t *themeDialog) Render(background string) string {
|
||||
return t.modal.Render(t.list.View(), background)
|
||||
}
|
||||
|
||||
func (t *themeDialog) Close() tea.Cmd {
|
||||
if !t.themeApplied {
|
||||
theme.SetTheme(t.originalTheme)
|
||||
return util.CmdHandler(ThemeSelectedMsg{ThemeName: t.originalTheme})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewThemeDialog creates a new theme switching dialog
|
||||
func NewThemeDialog() ThemeDialog {
|
||||
themes := theme.AvailableThemes()
|
||||
currentTheme := theme.CurrentThemeName()
|
||||
|
||||
var selectedIdx int
|
||||
for i, name := range themes {
|
||||
if name == currentTheme {
|
||||
selectedIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
// Convert themes to list items
|
||||
items := make([]list.Item, len(themes))
|
||||
for i, theme := range themes {
|
||||
items[i] = list.StringItem(theme)
|
||||
}
|
||||
|
||||
listComponent := list.NewListComponent(
|
||||
list.WithItems(items),
|
||||
list.WithMaxVisibleHeight[list.Item](10),
|
||||
list.WithFallbackMessage[list.Item]("No themes available"),
|
||||
list.WithAlphaNumericKeys[list.Item](true),
|
||||
list.WithRenderFunc(func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, baseStyle)
|
||||
}),
|
||||
list.WithSelectableFunc(func(item list.Item) bool {
|
||||
return item.Selectable()
|
||||
}),
|
||||
)
|
||||
|
||||
// Set the initial selection to the current theme
|
||||
listComponent.SetSelectedIndex(selectedIdx)
|
||||
|
||||
// Set the max width for the list to match the modal width
|
||||
listComponent.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
|
||||
return &themeDialog{
|
||||
list: listComponent,
|
||||
modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
|
||||
originalTheme: currentTheme,
|
||||
themeApplied: false,
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/formatters"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
stylesi "github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -31,6 +38,10 @@ const (
|
||||
LineRemoved // Line removed from the old file
|
||||
)
|
||||
|
||||
var (
|
||||
ansiRegex = regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
|
||||
)
|
||||
|
||||
// Segment represents a portion of a line for intra-line highlighting
|
||||
type Segment struct {
|
||||
Start int
|
||||
@@ -67,36 +78,41 @@ type linePair struct {
|
||||
right *DiffLine
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Side-by-Side Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// SideBySideConfig configures the rendering of side-by-side diffs
|
||||
type SideBySideConfig struct {
|
||||
TotalWidth int
|
||||
// UnifiedConfig configures the rendering of unified diffs
|
||||
type UnifiedConfig struct {
|
||||
Width int
|
||||
}
|
||||
|
||||
// SideBySideOption modifies a SideBySideConfig
|
||||
type SideBySideOption func(*SideBySideConfig)
|
||||
// UnifiedOption modifies a UnifiedConfig
|
||||
type UnifiedOption func(*UnifiedConfig)
|
||||
|
||||
// NewSideBySideConfig creates a SideBySideConfig with default values
|
||||
func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
|
||||
config := SideBySideConfig{
|
||||
TotalWidth: 160, // Default width for side-by-side view
|
||||
// NewUnifiedConfig creates a UnifiedConfig with default values
|
||||
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
|
||||
config := UnifiedConfig{
|
||||
Width: 80,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(&config)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// WithTotalWidth sets the total width for side-by-side view
|
||||
func WithTotalWidth(width int) SideBySideOption {
|
||||
return func(s *SideBySideConfig) {
|
||||
// NewSideBySideConfig creates a SideBySideConfig with default values
|
||||
func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig {
|
||||
config := UnifiedConfig{
|
||||
Width: 160,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&config)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// WithWidth sets the width for unified view
|
||||
func WithWidth(width int) UnifiedOption {
|
||||
return func(u *UnifiedConfig) {
|
||||
if width > 0 {
|
||||
s.TotalWidth = width
|
||||
u.Width = width
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,101 +125,87 @@ func WithTotalWidth(width int) SideBySideOption {
|
||||
func ParseUnifiedDiff(diff string) (DiffResult, error) {
|
||||
var result DiffResult
|
||||
var currentHunk *Hunk
|
||||
result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
|
||||
|
||||
hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
|
||||
lines := strings.Split(diff, "\n")
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(diff))
|
||||
var oldLine, newLine int
|
||||
inFileHeader := true
|
||||
|
||||
for _, line := range lines {
|
||||
// Parse file headers
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if inFileHeader {
|
||||
if strings.HasPrefix(line, "--- a/") {
|
||||
result.OldFile = strings.TrimPrefix(line, "--- a/")
|
||||
result.OldFile = line[6:]
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "+++ b/") {
|
||||
result.NewFile = strings.TrimPrefix(line, "+++ b/")
|
||||
result.NewFile = line[6:]
|
||||
inFileHeader = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Parse hunk headers
|
||||
if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
if currentHunk != nil {
|
||||
result.Hunks = append(result.Hunks, *currentHunk)
|
||||
}
|
||||
currentHunk = &Hunk{
|
||||
Header: line,
|
||||
Lines: []DiffLine{},
|
||||
Lines: make([]DiffLine, 0, 10), // Pre-allocate
|
||||
}
|
||||
|
||||
oldStart, _ := strconv.Atoi(matches[1])
|
||||
newStart, _ := strconv.Atoi(matches[3])
|
||||
oldLine = oldStart
|
||||
newLine = newStart
|
||||
// Manual parsing of hunk header is faster than regex
|
||||
parts := strings.Split(line, " ")
|
||||
if len(parts) > 2 {
|
||||
oldRange := strings.Split(parts[1][1:], ",")
|
||||
newRange := strings.Split(parts[2][1:], ",")
|
||||
oldLine, _ = strconv.Atoi(oldRange[0])
|
||||
newLine, _ = strconv.Atoi(newRange[0])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore "No newline at end of file" markers
|
||||
if strings.HasPrefix(line, "\\ No newline at end of file") {
|
||||
if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentHunk == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process the line based on its prefix
|
||||
var dl DiffLine
|
||||
dl.Content = line
|
||||
if len(line) > 0 {
|
||||
switch line[0] {
|
||||
case '+':
|
||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
||||
OldLineNo: 0,
|
||||
NewLineNo: newLine,
|
||||
Kind: LineAdded,
|
||||
Content: line[1:],
|
||||
})
|
||||
dl.Kind = LineAdded
|
||||
dl.NewLineNo = newLine
|
||||
dl.Content = line[1:]
|
||||
newLine++
|
||||
case '-':
|
||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
||||
OldLineNo: oldLine,
|
||||
NewLineNo: 0,
|
||||
Kind: LineRemoved,
|
||||
Content: line[1:],
|
||||
})
|
||||
dl.Kind = LineRemoved
|
||||
dl.OldLineNo = oldLine
|
||||
dl.Content = line[1:]
|
||||
oldLine++
|
||||
default:
|
||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
||||
OldLineNo: oldLine,
|
||||
NewLineNo: newLine,
|
||||
Kind: LineContext,
|
||||
Content: line,
|
||||
})
|
||||
default: // context line
|
||||
dl.Kind = LineContext
|
||||
dl.OldLineNo = oldLine
|
||||
dl.NewLineNo = newLine
|
||||
oldLine++
|
||||
newLine++
|
||||
}
|
||||
} else {
|
||||
// Handle empty lines
|
||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
||||
OldLineNo: oldLine,
|
||||
NewLineNo: newLine,
|
||||
Kind: LineContext,
|
||||
Content: "",
|
||||
})
|
||||
} else { // empty context line
|
||||
dl.Kind = LineContext
|
||||
dl.OldLineNo = oldLine
|
||||
dl.NewLineNo = newLine
|
||||
oldLine++
|
||||
newLine++
|
||||
}
|
||||
currentHunk.Lines = append(currentHunk.Lines, dl)
|
||||
}
|
||||
|
||||
// Add the last hunk if there is one
|
||||
if currentHunk != nil {
|
||||
result.Hunks = append(result.Hunks, *currentHunk)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return result, scanner.Err()
|
||||
}
|
||||
|
||||
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
|
||||
@@ -300,7 +302,7 @@ func pairLines(lines []DiffLine) []linePair {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// SyntaxHighlight applies syntax highlighting to text based on file extension
|
||||
func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
|
||||
func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.Color) error {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
// Determine the language lexer to use
|
||||
@@ -404,84 +406,84 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
|
||||
<entry type="TextWhitespace" style="%s"/>
|
||||
</style>
|
||||
`,
|
||||
getColor(t.BackgroundSubtle()), // Background
|
||||
getColor(t.Text()), // Text
|
||||
getColor(t.Text()), // Other
|
||||
getColor(t.Error()), // Error
|
||||
getChromaColor(t.BackgroundPanel()), // Background
|
||||
getChromaColor(t.Text()), // Text
|
||||
getChromaColor(t.Text()), // Other
|
||||
getChromaColor(t.Error()), // Error
|
||||
|
||||
getColor(t.SyntaxKeyword()), // Keyword
|
||||
getColor(t.SyntaxKeyword()), // KeywordConstant
|
||||
getColor(t.SyntaxKeyword()), // KeywordDeclaration
|
||||
getColor(t.SyntaxKeyword()), // KeywordNamespace
|
||||
getColor(t.SyntaxKeyword()), // KeywordPseudo
|
||||
getColor(t.SyntaxKeyword()), // KeywordReserved
|
||||
getColor(t.SyntaxType()), // KeywordType
|
||||
getChromaColor(t.SyntaxKeyword()), // Keyword
|
||||
getChromaColor(t.SyntaxKeyword()), // KeywordConstant
|
||||
getChromaColor(t.SyntaxKeyword()), // KeywordDeclaration
|
||||
getChromaColor(t.SyntaxKeyword()), // KeywordNamespace
|
||||
getChromaColor(t.SyntaxKeyword()), // KeywordPseudo
|
||||
getChromaColor(t.SyntaxKeyword()), // KeywordReserved
|
||||
getChromaColor(t.SyntaxType()), // KeywordType
|
||||
|
||||
getColor(t.Text()), // Name
|
||||
getColor(t.SyntaxVariable()), // NameAttribute
|
||||
getColor(t.SyntaxType()), // NameBuiltin
|
||||
getColor(t.SyntaxVariable()), // NameBuiltinPseudo
|
||||
getColor(t.SyntaxType()), // NameClass
|
||||
getColor(t.SyntaxVariable()), // NameConstant
|
||||
getColor(t.SyntaxFunction()), // NameDecorator
|
||||
getColor(t.SyntaxVariable()), // NameEntity
|
||||
getColor(t.SyntaxType()), // NameException
|
||||
getColor(t.SyntaxFunction()), // NameFunction
|
||||
getColor(t.Text()), // NameLabel
|
||||
getColor(t.SyntaxType()), // NameNamespace
|
||||
getColor(t.SyntaxVariable()), // NameOther
|
||||
getColor(t.SyntaxKeyword()), // NameTag
|
||||
getColor(t.SyntaxVariable()), // NameVariable
|
||||
getColor(t.SyntaxVariable()), // NameVariableClass
|
||||
getColor(t.SyntaxVariable()), // NameVariableGlobal
|
||||
getColor(t.SyntaxVariable()), // NameVariableInstance
|
||||
getChromaColor(t.Text()), // Name
|
||||
getChromaColor(t.SyntaxVariable()), // NameAttribute
|
||||
getChromaColor(t.SyntaxType()), // NameBuiltin
|
||||
getChromaColor(t.SyntaxVariable()), // NameBuiltinPseudo
|
||||
getChromaColor(t.SyntaxType()), // NameClass
|
||||
getChromaColor(t.SyntaxVariable()), // NameConstant
|
||||
getChromaColor(t.SyntaxFunction()), // NameDecorator
|
||||
getChromaColor(t.SyntaxVariable()), // NameEntity
|
||||
getChromaColor(t.SyntaxType()), // NameException
|
||||
getChromaColor(t.SyntaxFunction()), // NameFunction
|
||||
getChromaColor(t.Text()), // NameLabel
|
||||
getChromaColor(t.SyntaxType()), // NameNamespace
|
||||
getChromaColor(t.SyntaxVariable()), // NameOther
|
||||
getChromaColor(t.SyntaxKeyword()), // NameTag
|
||||
getChromaColor(t.SyntaxVariable()), // NameVariable
|
||||
getChromaColor(t.SyntaxVariable()), // NameVariableClass
|
||||
getChromaColor(t.SyntaxVariable()), // NameVariableGlobal
|
||||
getChromaColor(t.SyntaxVariable()), // NameVariableInstance
|
||||
|
||||
getColor(t.SyntaxString()), // Literal
|
||||
getColor(t.SyntaxString()), // LiteralDate
|
||||
getColor(t.SyntaxString()), // LiteralString
|
||||
getColor(t.SyntaxString()), // LiteralStringBacktick
|
||||
getColor(t.SyntaxString()), // LiteralStringChar
|
||||
getColor(t.SyntaxString()), // LiteralStringDoc
|
||||
getColor(t.SyntaxString()), // LiteralStringDouble
|
||||
getColor(t.SyntaxString()), // LiteralStringEscape
|
||||
getColor(t.SyntaxString()), // LiteralStringHeredoc
|
||||
getColor(t.SyntaxString()), // LiteralStringInterpol
|
||||
getColor(t.SyntaxString()), // LiteralStringOther
|
||||
getColor(t.SyntaxString()), // LiteralStringRegex
|
||||
getColor(t.SyntaxString()), // LiteralStringSingle
|
||||
getColor(t.SyntaxString()), // LiteralStringSymbol
|
||||
getChromaColor(t.SyntaxString()), // Literal
|
||||
getChromaColor(t.SyntaxString()), // LiteralDate
|
||||
getChromaColor(t.SyntaxString()), // LiteralString
|
||||
getChromaColor(t.SyntaxString()), // LiteralStringBacktick
|
||||
getChromaColor(t.SyntaxString()), // LiteralStringChar
|
||||
getChromaColor(t.SyntaxString()), // LiteralStringDoc
|
||||
getChromaColor(t.SyntaxString()), // LiteralStringDouble
|
||||
getChromaColor(t.SyntaxString()), // LiteralStringEscape
|
||||
getChromaColor(t.SyntaxString()), // LiteralStringHeredoc
|
||||
getChromaColor(t.SyntaxString()), // LiteralStringInterpol
|
||||
getChromaColor(t.SyntaxString()), // LiteralStringOther
|
||||
getChromaColor(t.SyntaxString()), // LiteralStringRegex
|
||||
getChromaColor(t.SyntaxString()), // LiteralStringSingle
|
||||
getChromaColor(t.SyntaxString()), // LiteralStringSymbol
|
||||
|
||||
getColor(t.SyntaxNumber()), // LiteralNumber
|
||||
getColor(t.SyntaxNumber()), // LiteralNumberBin
|
||||
getColor(t.SyntaxNumber()), // LiteralNumberFloat
|
||||
getColor(t.SyntaxNumber()), // LiteralNumberHex
|
||||
getColor(t.SyntaxNumber()), // LiteralNumberInteger
|
||||
getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
|
||||
getColor(t.SyntaxNumber()), // LiteralNumberOct
|
||||
getChromaColor(t.SyntaxNumber()), // LiteralNumber
|
||||
getChromaColor(t.SyntaxNumber()), // LiteralNumberBin
|
||||
getChromaColor(t.SyntaxNumber()), // LiteralNumberFloat
|
||||
getChromaColor(t.SyntaxNumber()), // LiteralNumberHex
|
||||
getChromaColor(t.SyntaxNumber()), // LiteralNumberInteger
|
||||
getChromaColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
|
||||
getChromaColor(t.SyntaxNumber()), // LiteralNumberOct
|
||||
|
||||
getColor(t.SyntaxOperator()), // Operator
|
||||
getColor(t.SyntaxKeyword()), // OperatorWord
|
||||
getColor(t.SyntaxPunctuation()), // Punctuation
|
||||
getChromaColor(t.SyntaxOperator()), // Operator
|
||||
getChromaColor(t.SyntaxKeyword()), // OperatorWord
|
||||
getChromaColor(t.SyntaxPunctuation()), // Punctuation
|
||||
|
||||
getColor(t.SyntaxComment()), // Comment
|
||||
getColor(t.SyntaxComment()), // CommentHashbang
|
||||
getColor(t.SyntaxComment()), // CommentMultiline
|
||||
getColor(t.SyntaxComment()), // CommentSingle
|
||||
getColor(t.SyntaxComment()), // CommentSpecial
|
||||
getColor(t.SyntaxKeyword()), // CommentPreproc
|
||||
getChromaColor(t.SyntaxComment()), // Comment
|
||||
getChromaColor(t.SyntaxComment()), // CommentHashbang
|
||||
getChromaColor(t.SyntaxComment()), // CommentMultiline
|
||||
getChromaColor(t.SyntaxComment()), // CommentSingle
|
||||
getChromaColor(t.SyntaxComment()), // CommentSpecial
|
||||
getChromaColor(t.SyntaxKeyword()), // CommentPreproc
|
||||
|
||||
getColor(t.Text()), // Generic
|
||||
getColor(t.Error()), // GenericDeleted
|
||||
getColor(t.Text()), // GenericEmph
|
||||
getColor(t.Error()), // GenericError
|
||||
getColor(t.Text()), // GenericHeading
|
||||
getColor(t.Success()), // GenericInserted
|
||||
getColor(t.TextMuted()), // GenericOutput
|
||||
getColor(t.Text()), // GenericPrompt
|
||||
getColor(t.Text()), // GenericStrong
|
||||
getColor(t.Text()), // GenericSubheading
|
||||
getColor(t.Error()), // GenericTraceback
|
||||
getColor(t.Text()), // TextWhitespace
|
||||
getChromaColor(t.Text()), // Generic
|
||||
getChromaColor(t.Error()), // GenericDeleted
|
||||
getChromaColor(t.Text()), // GenericEmph
|
||||
getChromaColor(t.Error()), // GenericError
|
||||
getChromaColor(t.Text()), // GenericHeading
|
||||
getChromaColor(t.Success()), // GenericInserted
|
||||
getChromaColor(t.TextMuted()), // GenericOutput
|
||||
getChromaColor(t.Text()), // GenericPrompt
|
||||
getChromaColor(t.Text()), // GenericStrong
|
||||
getChromaColor(t.Text()), // GenericSubheading
|
||||
getChromaColor(t.Error()), // GenericTraceback
|
||||
getChromaColor(t.Text()), // TextWhitespace
|
||||
)
|
||||
|
||||
r := strings.NewReader(syntaxThemeXml)
|
||||
@@ -490,6 +492,9 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
|
||||
// Modify the style to use the provided background
|
||||
s, err := style.Builder().Transform(
|
||||
func(t chroma.StyleEntry) chroma.StyleEntry {
|
||||
if _, ok := bg.(lipgloss.NoColor); ok {
|
||||
return t
|
||||
}
|
||||
r, g, b, _ := bg.RGBA()
|
||||
t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
|
||||
return t
|
||||
@@ -509,15 +514,20 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
|
||||
}
|
||||
|
||||
// getColor returns the appropriate hex color string based on terminal background
|
||||
func getColor(adaptiveColor lipgloss.AdaptiveColor) string {
|
||||
if lipgloss.HasDarkBackground() {
|
||||
return adaptiveColor.Dark
|
||||
func getColor(adaptiveColor compat.AdaptiveColor) *string {
|
||||
return stylesi.AdaptiveColorToString(adaptiveColor)
|
||||
}
|
||||
|
||||
func getChromaColor(adaptiveColor compat.AdaptiveColor) string {
|
||||
color := stylesi.AdaptiveColorToString(adaptiveColor)
|
||||
if color == nil {
|
||||
return ""
|
||||
}
|
||||
return adaptiveColor.Light
|
||||
return *color
|
||||
}
|
||||
|
||||
// highlightLine applies syntax highlighting to a single line
|
||||
func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
|
||||
func highlightLine(fileName string, line string, bg color.Color) string {
|
||||
var buf bytes.Buffer
|
||||
err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
|
||||
if err != nil {
|
||||
@@ -527,11 +537,11 @@ func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) stri
|
||||
}
|
||||
|
||||
// createStyles generates the lipgloss styles needed for rendering diffs
|
||||
func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
|
||||
removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
|
||||
addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
|
||||
contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
|
||||
lineNumberStyle = lipgloss.NewStyle().Background(t.DiffLineNumber()).Foreground(t.TextMuted())
|
||||
func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle stylesi.Style) {
|
||||
removedLineStyle = stylesi.NewStyle().Background(t.DiffRemovedBg())
|
||||
addedLineStyle = stylesi.NewStyle().Background(t.DiffAddedBg())
|
||||
contextLineStyle = stylesi.NewStyle().Background(t.DiffContextBg())
|
||||
lineNumberStyle = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffLineNumber())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -540,9 +550,8 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// applyHighlighting applies intra-line highlighting to a piece of text
|
||||
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.AdaptiveColor) string {
|
||||
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg compat.AdaptiveColor) string {
|
||||
// Find all ANSI sequences in the content
|
||||
ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
|
||||
ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
|
||||
|
||||
// Build a mapping of visible character positions to their actual indices
|
||||
@@ -570,7 +579,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
|
||||
ansiSequences[visibleIdx] = lastAnsiSeq
|
||||
}
|
||||
visibleIdx++
|
||||
i++
|
||||
|
||||
// Properly advance by UTF-8 rune, not byte
|
||||
_, size := utf8.DecodeRuneInString(content[i:])
|
||||
i += size
|
||||
}
|
||||
|
||||
// Apply highlighting
|
||||
@@ -579,9 +591,17 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
|
||||
currentPos := 0
|
||||
|
||||
// Get the appropriate color based on terminal background
|
||||
bgColor := lipgloss.Color(getColor(highlightBg))
|
||||
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundSubtle()))
|
||||
bg := getColor(highlightBg)
|
||||
fg := getColor(theme.CurrentTheme().BackgroundPanel())
|
||||
var bgColor color.Color
|
||||
var fgColor color.Color
|
||||
|
||||
if bg != nil {
|
||||
bgColor = lipgloss.Color(*bg)
|
||||
}
|
||||
if fg != nil {
|
||||
fgColor = lipgloss.Color(*fg)
|
||||
}
|
||||
for i := 0; i < len(content); {
|
||||
// Check if we're at an ANSI sequence
|
||||
isAnsi := false
|
||||
@@ -609,20 +629,29 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
|
||||
}
|
||||
}
|
||||
|
||||
// Get current character
|
||||
char := string(content[i])
|
||||
// Get current character (properly handle UTF-8)
|
||||
r, size := utf8.DecodeRuneInString(content[i:])
|
||||
char := string(r)
|
||||
|
||||
if inSelection {
|
||||
// Get the current styling
|
||||
currentStyle := ansiSequences[currentPos]
|
||||
|
||||
// Apply foreground and background highlight
|
||||
sb.WriteString("\x1b[38;2;")
|
||||
r, g, b, _ := fgColor.RGBA()
|
||||
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
|
||||
sb.WriteString("\x1b[48;2;")
|
||||
r, g, b, _ = bgColor.RGBA()
|
||||
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
|
||||
if fgColor != nil {
|
||||
sb.WriteString("\x1b[38;2;")
|
||||
r, g, b, _ := fgColor.RGBA()
|
||||
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
|
||||
} else {
|
||||
sb.WriteString("\x1b[49m")
|
||||
}
|
||||
if bgColor != nil {
|
||||
sb.WriteString("\x1b[48;2;")
|
||||
r, g, b, _ := bgColor.RGBA()
|
||||
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
|
||||
} else {
|
||||
sb.WriteString("\x1b[39m")
|
||||
}
|
||||
sb.WriteString(char)
|
||||
|
||||
// Full reset of all attributes to ensure clean state
|
||||
@@ -636,12 +665,107 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
|
||||
}
|
||||
|
||||
currentPos++
|
||||
i++
|
||||
i += size
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderLinePrefix renders the line number and marker prefix for a diff line
|
||||
func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle stylesi.Style, t theme.Theme) string {
|
||||
// Style the marker based on line type
|
||||
var styledMarker string
|
||||
switch dl.Kind {
|
||||
case LineRemoved:
|
||||
styledMarker = stylesi.NewStyle().Foreground(t.DiffRemoved()).Background(t.DiffRemovedBg()).Render(marker)
|
||||
case LineAdded:
|
||||
styledMarker = stylesi.NewStyle().Foreground(t.DiffAdded()).Background(t.DiffAddedBg()).Render(marker)
|
||||
case LineContext:
|
||||
styledMarker = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffContextBg()).Render(marker)
|
||||
default:
|
||||
styledMarker = marker
|
||||
}
|
||||
|
||||
return lineNumberStyle.Render(lineNum + " " + styledMarker)
|
||||
}
|
||||
|
||||
// renderLineContent renders the content of a diff line with syntax and intra-line highlighting
|
||||
func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, highlightColor compat.AdaptiveColor, width int) string {
|
||||
// Apply syntax highlighting
|
||||
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
|
||||
|
||||
// Apply intra-line highlighting if needed
|
||||
if len(dl.Segments) > 0 && (dl.Kind == LineRemoved || dl.Kind == LineAdded) {
|
||||
content = applyHighlighting(content, dl.Segments, dl.Kind, highlightColor)
|
||||
}
|
||||
|
||||
// Add a padding space for added/removed lines
|
||||
if dl.Kind == LineRemoved || dl.Kind == LineAdded {
|
||||
content = bgStyle.Render(" ") + content
|
||||
}
|
||||
|
||||
// Create the final line and truncate if needed
|
||||
return bgStyle.MaxHeight(1).Width(width).Render(
|
||||
ansi.Truncate(
|
||||
content,
|
||||
width,
|
||||
"...",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// renderUnifiedLine renders a single line in unified diff format
|
||||
func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) string {
|
||||
removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
|
||||
|
||||
// Determine line style and marker based on line type
|
||||
var marker string
|
||||
var bgStyle stylesi.Style
|
||||
var lineNum string
|
||||
var highlightColor compat.AdaptiveColor
|
||||
|
||||
switch dl.Kind {
|
||||
case LineRemoved:
|
||||
marker = "-"
|
||||
bgStyle = removedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
|
||||
highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
|
||||
if dl.OldLineNo > 0 {
|
||||
lineNum = fmt.Sprintf("%6d ", dl.OldLineNo)
|
||||
} else {
|
||||
lineNum = " "
|
||||
}
|
||||
case LineAdded:
|
||||
marker = "+"
|
||||
bgStyle = addedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
|
||||
highlightColor = t.DiffHighlightAdded() // TODO: handle "none"
|
||||
if dl.NewLineNo > 0 {
|
||||
lineNum = fmt.Sprintf(" %7d", dl.NewLineNo)
|
||||
} else {
|
||||
lineNum = " "
|
||||
}
|
||||
case LineContext:
|
||||
marker = " "
|
||||
bgStyle = contextLineStyle
|
||||
if dl.OldLineNo > 0 && dl.NewLineNo > 0 {
|
||||
lineNum = fmt.Sprintf("%6d %6d", dl.OldLineNo, dl.NewLineNo)
|
||||
} else {
|
||||
lineNum = " "
|
||||
}
|
||||
}
|
||||
|
||||
// Create the line prefix
|
||||
prefix := renderLinePrefix(dl, lineNum, marker, lineNumberStyle, t)
|
||||
|
||||
// Render the content
|
||||
prefixWidth := ansi.StringWidth(prefix)
|
||||
contentWidth := width - prefixWidth
|
||||
content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth)
|
||||
|
||||
return prefix + content
|
||||
}
|
||||
|
||||
// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
|
||||
func renderDiffColumnLine(
|
||||
fileName string,
|
||||
@@ -651,7 +775,7 @@ func renderDiffColumnLine(
|
||||
t theme.Theme,
|
||||
) string {
|
||||
if dl == nil {
|
||||
contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
|
||||
contextLineStyle := stylesi.NewStyle().Background(t.DiffContextBg())
|
||||
return contextLineStyle.Width(colWidth).Render("")
|
||||
}
|
||||
|
||||
@@ -659,10 +783,9 @@ func renderDiffColumnLine(
|
||||
|
||||
// Determine line style based on line type and column
|
||||
var marker string
|
||||
var bgStyle lipgloss.Style
|
||||
var bgStyle stylesi.Style
|
||||
var lineNum string
|
||||
var highlightType LineType
|
||||
var highlightColor lipgloss.AdaptiveColor
|
||||
var highlightColor compat.AdaptiveColor
|
||||
|
||||
if isLeftColumn {
|
||||
// Left column logic
|
||||
@@ -670,9 +793,8 @@ func renderDiffColumnLine(
|
||||
case LineRemoved:
|
||||
marker = "-"
|
||||
bgStyle = removedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
|
||||
highlightType = LineRemoved
|
||||
highlightColor = t.DiffHighlightRemoved()
|
||||
lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
|
||||
highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
|
||||
case LineAdded:
|
||||
marker = "?"
|
||||
bgStyle = contextLineStyle
|
||||
@@ -691,8 +813,7 @@ func renderDiffColumnLine(
|
||||
case LineAdded:
|
||||
marker = "+"
|
||||
bgStyle = addedLineStyle
|
||||
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
|
||||
highlightType = LineAdded
|
||||
lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
|
||||
highlightColor = t.DiffHighlightAdded()
|
||||
case LineRemoved:
|
||||
marker = "?"
|
||||
@@ -708,44 +829,24 @@ func renderDiffColumnLine(
|
||||
}
|
||||
}
|
||||
|
||||
// Style the marker based on line type
|
||||
var styledMarker string
|
||||
switch dl.Kind {
|
||||
case LineRemoved:
|
||||
styledMarker = removedLineStyle.Foreground(t.DiffRemoved()).Render(marker)
|
||||
case LineAdded:
|
||||
styledMarker = addedLineStyle.Foreground(t.DiffAdded()).Render(marker)
|
||||
case LineContext:
|
||||
styledMarker = contextLineStyle.Foreground(t.TextMuted()).Render(marker)
|
||||
default:
|
||||
styledMarker = marker
|
||||
}
|
||||
|
||||
// Create the line prefix
|
||||
prefix := lineNumberStyle.Render(lineNum + " " + styledMarker)
|
||||
prefix := renderLinePrefix(*dl, lineNum, marker, lineNumberStyle, t)
|
||||
|
||||
// Apply syntax highlighting
|
||||
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
|
||||
// Determine if we should render content
|
||||
shouldRenderContent := (dl.Kind == LineRemoved && isLeftColumn) ||
|
||||
(dl.Kind == LineAdded && !isLeftColumn) ||
|
||||
dl.Kind == LineContext
|
||||
|
||||
// Apply intra-line highlighting if needed
|
||||
if (dl.Kind == LineRemoved && isLeftColumn || dl.Kind == LineAdded && !isLeftColumn) && len(dl.Segments) > 0 {
|
||||
content = applyHighlighting(content, dl.Segments, highlightType, highlightColor)
|
||||
if !shouldRenderContent {
|
||||
return bgStyle.Width(colWidth).Render("")
|
||||
}
|
||||
|
||||
// Add a padding space for added/removed lines
|
||||
if (dl.Kind == LineRemoved && isLeftColumn) || (dl.Kind == LineAdded && !isLeftColumn) {
|
||||
content = bgStyle.Render(" ") + content
|
||||
}
|
||||
// Render the content
|
||||
prefixWidth := ansi.StringWidth(prefix)
|
||||
contentWidth := colWidth - prefixWidth
|
||||
content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth)
|
||||
|
||||
// Create the final line and truncate if needed
|
||||
lineText := prefix + content
|
||||
return bgStyle.MaxHeight(1).Width(colWidth).Render(
|
||||
ansi.Truncate(
|
||||
lineText,
|
||||
colWidth,
|
||||
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
|
||||
),
|
||||
)
|
||||
return prefix + content
|
||||
}
|
||||
|
||||
// renderLeftColumn formats the left side of a side-by-side diff
|
||||
@@ -762,8 +863,30 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// RenderUnifiedHunk formats a hunk for unified display
|
||||
func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
|
||||
// Apply options to create the configuration
|
||||
config := NewUnifiedConfig(opts...)
|
||||
|
||||
// Make a copy of the hunk so we don't modify the original
|
||||
hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
|
||||
copy(hunkCopy.Lines, h.Lines)
|
||||
|
||||
// Highlight changes within lines
|
||||
HighlightIntralineChanges(&hunkCopy)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(hunkCopy.Lines) * config.Width)
|
||||
|
||||
util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
|
||||
return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
|
||||
})
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// RenderSideBySideHunk formats a hunk for side-by-side display
|
||||
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
|
||||
func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
|
||||
// Apply options to create the configuration
|
||||
config := NewSideBySideConfig(opts...)
|
||||
|
||||
@@ -778,40 +901,57 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
|
||||
pairs := pairLines(hunkCopy.Lines)
|
||||
|
||||
// Calculate column width
|
||||
colWidth := config.TotalWidth / 2
|
||||
colWidth := config.Width / 2
|
||||
|
||||
leftWidth := colWidth
|
||||
rightWidth := config.TotalWidth - colWidth
|
||||
rightWidth := config.Width - colWidth
|
||||
var sb strings.Builder
|
||||
for _, p := range pairs {
|
||||
leftStr := renderLeftColumn(fileName, p.left, leftWidth)
|
||||
rightStr := renderRightColumn(fileName, p.right, rightWidth)
|
||||
sb.WriteString(leftStr + rightStr + "\n")
|
||||
}
|
||||
|
||||
util.WriteStringsPar(&sb, pairs, func(p linePair) string {
|
||||
wg := &sync.WaitGroup{}
|
||||
var leftStr, rightStr string
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
leftStr = renderLeftColumn(fileName, p.left, leftWidth)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
rightStr = renderRightColumn(fileName, p.right, rightWidth)
|
||||
}()
|
||||
wg.Wait()
|
||||
return leftStr + rightStr + "\n"
|
||||
})
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatDiff creates a side-by-side formatted view of a diff
|
||||
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
|
||||
// t := theme.CurrentTheme()
|
||||
// FormatUnifiedDiff creates a unified formatted view of a diff
|
||||
func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
|
||||
diffResult, err := ParseUnifiedDiff(diffText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
// config := NewSideBySideConfig(opts...)
|
||||
for _, h := range diffResult.Hunks {
|
||||
// sb.WriteString(
|
||||
// lipgloss.NewStyle().
|
||||
// Background(t.DiffHunkHeader()).
|
||||
// Foreground(t.Background()).
|
||||
// Width(config.TotalWidth).
|
||||
// Render(h.Header) + "\n",
|
||||
// )
|
||||
sb.WriteString(RenderSideBySideHunk(filename, h, opts...))
|
||||
}
|
||||
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
|
||||
return RenderUnifiedHunk(filename, h, opts...)
|
||||
})
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// FormatDiff creates a side-by-side formatted view of a diff
|
||||
func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
|
||||
diffResult, err := ParseUnifiedDiff(diffText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
|
||||
return RenderSideBySideHunk(filename, h, opts...)
|
||||
})
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
281
packages/client/tui/internal/components/fileviewer/fileviewer.go
Normal file
281
packages/client/tui/internal/components/fileviewer/fileviewer.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package fileviewer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/diff"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/internal/viewport"
|
||||
)
|
||||
|
||||
type DiffStyle int
|
||||
|
||||
const (
|
||||
DiffStyleSplit DiffStyle = iota
|
||||
DiffStyleUnified
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
viewport viewport.Model
|
||||
filename *string
|
||||
content *string
|
||||
isDiff *bool
|
||||
diffStyle DiffStyle
|
||||
}
|
||||
|
||||
type fileRenderedMsg struct {
|
||||
content string
|
||||
}
|
||||
|
||||
func New(app *app.App) Model {
|
||||
vp := viewport.New()
|
||||
m := Model{
|
||||
app: app,
|
||||
viewport: vp,
|
||||
diffStyle: DiffStyleUnified,
|
||||
}
|
||||
if app.State.SplitDiff {
|
||||
m.diffStyle = DiffStyleSplit
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return m.viewport.Init()
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case fileRenderedMsg:
|
||||
m.viewport.SetContent(msg.content)
|
||||
return m, util.CmdHandler(app.FileRenderedMsg{
|
||||
FilePath: *m.filename,
|
||||
})
|
||||
case dialog.ThemeSelectedMsg:
|
||||
return m, m.render()
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
vp, cmd := m.viewport.Update(msg)
|
||||
m.viewport = vp
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if !m.HasFile() {
|
||||
return ""
|
||||
}
|
||||
|
||||
header := *m.filename
|
||||
header = styles.NewStyle().
|
||||
Padding(1, 2).
|
||||
Width(m.width).
|
||||
Background(theme.CurrentTheme().BackgroundElement()).
|
||||
Foreground(theme.CurrentTheme().Text()).
|
||||
Render(header)
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
close := m.app.Key(commands.FileCloseCommand)
|
||||
diffToggle := m.app.Key(commands.FileDiffToggleCommand)
|
||||
if m.isDiff == nil || *m.isDiff == false {
|
||||
diffToggle = ""
|
||||
}
|
||||
layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand)
|
||||
|
||||
background := t.Background()
|
||||
footer := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &background,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifyCenter,
|
||||
Align: layout.AlignStretch,
|
||||
Width: m.width - 2,
|
||||
Gap: 5,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: close,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: layoutToggle,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: diffToggle,
|
||||
},
|
||||
)
|
||||
footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer)
|
||||
|
||||
return header + "\n" + m.viewport.View() + "\n" + footer
|
||||
}
|
||||
|
||||
func (m *Model) Clear() (Model, tea.Cmd) {
|
||||
m.filename = nil
|
||||
m.content = nil
|
||||
m.isDiff = nil
|
||||
return *m, m.render()
|
||||
}
|
||||
|
||||
func (m *Model) ToggleDiff() (Model, tea.Cmd) {
|
||||
switch m.diffStyle {
|
||||
case DiffStyleSplit:
|
||||
m.diffStyle = DiffStyleUnified
|
||||
default:
|
||||
m.diffStyle = DiffStyleSplit
|
||||
}
|
||||
return *m, m.render()
|
||||
}
|
||||
|
||||
func (m *Model) DiffStyle() DiffStyle {
|
||||
return m.diffStyle
|
||||
}
|
||||
|
||||
func (m Model) HasFile() bool {
|
||||
return m.filename != nil && m.content != nil
|
||||
}
|
||||
|
||||
func (m Model) Filename() string {
|
||||
if m.filename == nil {
|
||||
return ""
|
||||
}
|
||||
return *m.filename
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(width, height int) (Model, tea.Cmd) {
|
||||
if m.width != width || m.height != height {
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.viewport.SetWidth(width)
|
||||
m.viewport.SetHeight(height - 4)
|
||||
return *m, m.render()
|
||||
}
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) {
|
||||
m.filename = &filename
|
||||
m.content = &content
|
||||
m.isDiff = &isDiff
|
||||
return *m, m.render()
|
||||
}
|
||||
|
||||
func (m *Model) render() tea.Cmd {
|
||||
if m.filename == nil || m.content == nil {
|
||||
m.viewport.SetContent("")
|
||||
return nil
|
||||
}
|
||||
|
||||
return func() tea.Msg {
|
||||
t := theme.CurrentTheme()
|
||||
var rendered string
|
||||
|
||||
if m.isDiff != nil && *m.isDiff {
|
||||
diffResult := ""
|
||||
var err error
|
||||
if m.diffStyle == DiffStyleSplit {
|
||||
diffResult, err = diff.FormatDiff(
|
||||
*m.filename,
|
||||
*m.content,
|
||||
diff.WithWidth(m.width),
|
||||
)
|
||||
} else if m.diffStyle == DiffStyleUnified {
|
||||
diffResult, err = diff.FormatUnifiedDiff(
|
||||
*m.filename,
|
||||
*m.content,
|
||||
diff.WithWidth(m.width),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
rendered = styles.NewStyle().
|
||||
Foreground(t.Error()).
|
||||
Render(fmt.Sprintf("Error rendering diff: %v", err))
|
||||
} else {
|
||||
rendered = strings.TrimRight(diffResult, "\n")
|
||||
}
|
||||
} else {
|
||||
rendered = util.RenderFile(
|
||||
*m.filename,
|
||||
*m.content,
|
||||
m.width,
|
||||
)
|
||||
}
|
||||
|
||||
rendered = styles.NewStyle().
|
||||
Width(m.width).
|
||||
Background(t.BackgroundPanel()).
|
||||
Render(rendered)
|
||||
|
||||
return fileRenderedMsg{
|
||||
content: rendered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) ScrollTo(line int) {
|
||||
m.viewport.SetYOffset(line)
|
||||
}
|
||||
|
||||
func (m *Model) ScrollToBottom() {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
|
||||
func (m *Model) ScrollToTop() {
|
||||
m.viewport.GotoTop()
|
||||
}
|
||||
|
||||
func (m *Model) PageUp() (Model, tea.Cmd) {
|
||||
m.viewport.ViewUp()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) PageDown() (Model, tea.Cmd) {
|
||||
m.viewport.ViewDown()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) HalfPageUp() (Model, tea.Cmd) {
|
||||
m.viewport.HalfViewUp()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) HalfPageDown() (Model, tea.Cmd) {
|
||||
m.viewport.HalfViewDown()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m Model) AtTop() bool {
|
||||
return m.viewport.AtTop()
|
||||
}
|
||||
|
||||
func (m Model) AtBottom() bool {
|
||||
return m.viewport.AtBottom()
|
||||
}
|
||||
|
||||
func (m Model) ScrollPercent() float64 {
|
||||
return m.viewport.ScrollPercent()
|
||||
}
|
||||
|
||||
func (m Model) TotalLineCount() int {
|
||||
return m.viewport.TotalLineCount()
|
||||
}
|
||||
|
||||
func (m Model) VisibleLineCount() int {
|
||||
return m.viewport.VisibleLineCount()
|
||||
}
|
||||
431
packages/client/tui/internal/components/list/list.go
Normal file
431
packages/client/tui/internal/components/list/list.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
// Item interface that all list items must implement
|
||||
type Item interface {
|
||||
Render(selected bool, width int, baseStyle styles.Style) string
|
||||
Selectable() bool
|
||||
}
|
||||
|
||||
// RenderFunc defines how to render an item in the list
|
||||
type RenderFunc[T any] func(item T, selected bool, width int, baseStyle styles.Style) string
|
||||
|
||||
// SelectableFunc defines whether an item is selectable
|
||||
type SelectableFunc[T any] func(item T) bool
|
||||
|
||||
// Options holds configuration for the list component
|
||||
type Options[T any] struct {
|
||||
items []T
|
||||
maxVisibleHeight int
|
||||
fallbackMsg string
|
||||
useAlphaNumericKeys bool
|
||||
renderItem RenderFunc[T]
|
||||
isSelectable SelectableFunc[T]
|
||||
baseStyle styles.Style
|
||||
}
|
||||
|
||||
// Option is a function that configures the list component
|
||||
type Option[T any] func(*Options[T])
|
||||
|
||||
// WithItems sets the initial items for the list
|
||||
func WithItems[T any](items []T) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.items = items
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxVisibleHeight sets the maximum visible height in lines
|
||||
func WithMaxVisibleHeight[T any](height int) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.maxVisibleHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
// WithFallbackMessage sets the message to show when the list is empty
|
||||
func WithFallbackMessage[T any](msg string) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.fallbackMsg = msg
|
||||
}
|
||||
}
|
||||
|
||||
// WithAlphaNumericKeys enables j/k navigation keys
|
||||
func WithAlphaNumericKeys[T any](enabled bool) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.useAlphaNumericKeys = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// WithRenderFunc sets the function to render items
|
||||
func WithRenderFunc[T any](fn RenderFunc[T]) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.renderItem = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithSelectableFunc sets the function to determine if items are selectable
|
||||
func WithSelectableFunc[T any](fn SelectableFunc[T]) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.isSelectable = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithStyle sets the base style that gets passed to render functions
|
||||
func WithStyle[T any](style styles.Style) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.baseStyle = style
|
||||
}
|
||||
}
|
||||
|
||||
type List[T any] interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
SetMaxWidth(maxWidth int)
|
||||
GetSelectedItem() (item T, idx int)
|
||||
SetItems(items []T)
|
||||
GetItems() []T
|
||||
SetSelectedIndex(idx int)
|
||||
SetEmptyMessage(msg string)
|
||||
IsEmpty() bool
|
||||
GetMaxVisibleHeight() int
|
||||
}
|
||||
|
||||
type listComponent[T any] struct {
|
||||
fallbackMsg string
|
||||
items []T
|
||||
selectedIdx int
|
||||
maxWidth int
|
||||
maxVisibleHeight int
|
||||
useAlphaNumericKeys bool
|
||||
width int
|
||||
height int
|
||||
renderItem RenderFunc[T]
|
||||
isSelectable SelectableFunc[T]
|
||||
baseStyle styles.Style
|
||||
}
|
||||
|
||||
type listKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
UpAlpha key.Binding
|
||||
DownAlpha key.Binding
|
||||
}
|
||||
|
||||
var simpleListKeys = listKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "ctrl+p"),
|
||||
key.WithHelp("↑", "previous list item"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "ctrl+n"),
|
||||
key.WithHelp("↓", "next list item"),
|
||||
),
|
||||
UpAlpha: key.NewBinding(
|
||||
key.WithKeys("k"),
|
||||
key.WithHelp("k", "previous list item"),
|
||||
),
|
||||
DownAlpha: key.NewBinding(
|
||||
key.WithKeys("j"),
|
||||
key.WithHelp("j", "next list item"),
|
||||
),
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
|
||||
c.moveUp()
|
||||
return c, nil
|
||||
case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
|
||||
c.moveDown()
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// moveUp moves the selection up, skipping non-selectable items
|
||||
func (c *listComponent[T]) moveUp() {
|
||||
if len(c.items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the previous selectable item
|
||||
for i := c.selectedIdx - 1; i >= 0; i-- {
|
||||
if c.isSelectable(c.items[i]) {
|
||||
c.selectedIdx = i
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no selectable item found above, stay at current position
|
||||
}
|
||||
|
||||
// moveDown moves the selection down, skipping non-selectable items
|
||||
func (c *listComponent[T]) moveDown() {
|
||||
if len(c.items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
originalIdx := c.selectedIdx
|
||||
for {
|
||||
if c.selectedIdx < len(c.items)-1 {
|
||||
c.selectedIdx++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if c.isSelectable(c.items[c.selectedIdx]) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent infinite loop
|
||||
if c.selectedIdx == originalIdx {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) GetSelectedItem() (T, int) {
|
||||
if len(c.items) > 0 && c.isSelectable(c.items[c.selectedIdx]) {
|
||||
return c.items[c.selectedIdx], c.selectedIdx
|
||||
}
|
||||
|
||||
var zero T
|
||||
return zero, -1
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetItems(items []T) {
|
||||
c.items = items
|
||||
c.selectedIdx = 0
|
||||
|
||||
// Ensure initial selection is on a selectable item
|
||||
if len(items) > 0 && !c.isSelectable(items[0]) {
|
||||
c.moveDown()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) GetItems() []T {
|
||||
return c.items
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetEmptyMessage(msg string) {
|
||||
c.fallbackMsg = msg
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) IsEmpty() bool {
|
||||
return len(c.items) == 0
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetMaxWidth(width int) {
|
||||
c.maxWidth = width
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetSelectedIndex(idx int) {
|
||||
if idx >= 0 && idx < len(c.items) {
|
||||
c.selectedIdx = idx
|
||||
}
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) GetMaxVisibleHeight() int {
|
||||
return c.maxVisibleHeight
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) View() string {
|
||||
items := c.items
|
||||
maxWidth := c.maxWidth
|
||||
if maxWidth == 0 {
|
||||
maxWidth = 80 // Default width if not set
|
||||
}
|
||||
|
||||
if len(items) <= 0 {
|
||||
return c.fallbackMsg
|
||||
}
|
||||
|
||||
// Calculate viewport based on actual heights
|
||||
startIdx, endIdx := c.calculateViewport()
|
||||
|
||||
listItems := make([]string, 0, endIdx-startIdx)
|
||||
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
item := items[i]
|
||||
|
||||
// Special handling for HeaderItem to remove top margin on first item
|
||||
if i == startIdx {
|
||||
// Check if this is a HeaderItem
|
||||
if _, ok := any(item).(Item); ok {
|
||||
if headerItem, isHeader := any(item).(HeaderItem); isHeader {
|
||||
// Render header without top margin when it's first
|
||||
t := theme.CurrentTheme()
|
||||
truncatedStr := truncate.StringWithTail(string(headerItem), uint(maxWidth-1), "...")
|
||||
headerStyle := c.baseStyle.
|
||||
Foreground(t.Accent()).
|
||||
Bold(true).
|
||||
MarginBottom(0).
|
||||
PaddingLeft(1)
|
||||
listItems = append(listItems, headerStyle.Render(truncatedStr))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
title := c.renderItem(item, i == c.selectedIdx, maxWidth, c.baseStyle)
|
||||
listItems = append(listItems, title)
|
||||
}
|
||||
|
||||
return strings.Join(listItems, "\n")
|
||||
}
|
||||
|
||||
// calculateViewport determines which items to show based on available space
|
||||
func (c *listComponent[T]) calculateViewport() (startIdx, endIdx int) {
|
||||
items := c.items
|
||||
if len(items) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Calculate heights of all items
|
||||
itemHeights := make([]int, len(items))
|
||||
for i, item := range items {
|
||||
rendered := c.renderItem(item, false, c.maxWidth, c.baseStyle)
|
||||
itemHeights[i] = lipgloss.Height(rendered)
|
||||
}
|
||||
|
||||
// Find the range of items that fit within maxVisibleHeight
|
||||
// Start by trying to center the selected item
|
||||
start := 0
|
||||
end := len(items)
|
||||
|
||||
// Calculate height from start to selected
|
||||
heightToSelected := 0
|
||||
for i := 0; i <= c.selectedIdx && i < len(items); i++ {
|
||||
heightToSelected += itemHeights[i]
|
||||
}
|
||||
|
||||
// If selected item is beyond visible height, scroll to show it
|
||||
if heightToSelected > c.maxVisibleHeight {
|
||||
// Start from selected and work backwards to find start
|
||||
currentHeight := itemHeights[c.selectedIdx]
|
||||
start = c.selectedIdx
|
||||
|
||||
for i := c.selectedIdx - 1; i >= 0 && currentHeight+itemHeights[i] <= c.maxVisibleHeight; i-- {
|
||||
currentHeight += itemHeights[i]
|
||||
start = i
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate end based on start
|
||||
currentHeight := 0
|
||||
for i := start; i < len(items); i++ {
|
||||
if currentHeight+itemHeights[i] > c.maxVisibleHeight {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
currentHeight += itemHeights[i]
|
||||
}
|
||||
|
||||
return start, end
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func NewListComponent[T any](opts ...Option[T]) List[T] {
|
||||
options := &Options[T]{
|
||||
baseStyle: styles.NewStyle(), // Default empty style
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
return &listComponent[T]{
|
||||
fallbackMsg: options.fallbackMsg,
|
||||
items: options.items,
|
||||
maxVisibleHeight: options.maxVisibleHeight,
|
||||
useAlphaNumericKeys: options.useAlphaNumericKeys,
|
||||
selectedIdx: 0,
|
||||
renderItem: options.renderItem,
|
||||
isSelectable: options.isSelectable,
|
||||
baseStyle: options.baseStyle,
|
||||
}
|
||||
}
|
||||
|
||||
// StringItem is a simple implementation of Item for string values
|
||||
type StringItem string
|
||||
|
||||
func (s StringItem) Render(selected bool, width int, baseStyle styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
|
||||
|
||||
var itemStyle styles.Style
|
||||
if selected {
|
||||
itemStyle = baseStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingLeft(1)
|
||||
} else {
|
||||
itemStyle = baseStyle.
|
||||
Foreground(t.TextMuted()).
|
||||
PaddingLeft(1)
|
||||
}
|
||||
|
||||
return itemStyle.Render(truncatedStr)
|
||||
}
|
||||
|
||||
func (s StringItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// HeaderItem is a non-selectable header item for grouping
|
||||
type HeaderItem string
|
||||
|
||||
func (h HeaderItem) Render(selected bool, width int, baseStyle styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
|
||||
|
||||
headerStyle := baseStyle.
|
||||
Foreground(t.Accent()).
|
||||
Bold(true).
|
||||
MarginTop(1).
|
||||
MarginBottom(0).
|
||||
PaddingLeft(1)
|
||||
|
||||
return headerStyle.Render(truncatedStr)
|
||||
}
|
||||
|
||||
func (h HeaderItem) Selectable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Ensure StringItem and HeaderItem implement Item
|
||||
var _ Item = StringItem("")
|
||||
var _ Item = HeaderItem("")
|
||||
210
packages/client/tui/internal/components/list/list_test.go
Normal file
210
packages/client/tui/internal/components/list/list_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
)
|
||||
|
||||
// testItem is a simple test implementation of ListItem
|
||||
type testItem struct {
|
||||
value string
|
||||
}
|
||||
|
||||
func (t testItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
isFirstInViewport bool,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
return t.value
|
||||
}
|
||||
|
||||
func (t testItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// createTestList creates a list with test items for testing
|
||||
func createTestList() *listComponent[testItem] {
|
||||
items := []testItem{
|
||||
{value: "item1"},
|
||||
{value: "item2"},
|
||||
{value: "item3"},
|
||||
}
|
||||
list := NewListComponent(
|
||||
WithItems(items),
|
||||
WithMaxVisibleHeight[testItem](5),
|
||||
WithFallbackMessage[testItem]("empty"),
|
||||
WithAlphaNumericKeys[testItem](false),
|
||||
WithRenderFunc(
|
||||
func(item testItem, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, false, baseStyle)
|
||||
},
|
||||
),
|
||||
WithSelectableFunc(func(item testItem) bool {
|
||||
return item.Selectable()
|
||||
}),
|
||||
)
|
||||
|
||||
return list.(*listComponent[testItem])
|
||||
}
|
||||
|
||||
func TestArrowKeyNavigation(t *testing.T) {
|
||||
list := createTestList()
|
||||
|
||||
// Test down arrow navigation
|
||||
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
updatedModel, _ := list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 1 {
|
||||
t.Errorf("Expected selected index 1 after down arrow, got %d", idx)
|
||||
}
|
||||
|
||||
// Test up arrow navigation
|
||||
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
|
||||
updatedModel, _ = list.Update(upKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected selected index 0 after up arrow, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJKKeyNavigation(t *testing.T) {
|
||||
items := []testItem{
|
||||
{value: "item1"},
|
||||
{value: "item2"},
|
||||
{value: "item3"},
|
||||
}
|
||||
// Create list with alpha keys enabled
|
||||
list := NewListComponent(
|
||||
WithItems(items),
|
||||
WithMaxVisibleHeight[testItem](5),
|
||||
WithFallbackMessage[testItem]("empty"),
|
||||
WithAlphaNumericKeys[testItem](true),
|
||||
WithRenderFunc(
|
||||
func(item testItem, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, false, baseStyle)
|
||||
},
|
||||
),
|
||||
WithSelectableFunc(func(item testItem) bool {
|
||||
return item.Selectable()
|
||||
}),
|
||||
)
|
||||
|
||||
// Test j key (down)
|
||||
jKey := tea.KeyPressMsg{Code: 'j', Text: "j"}
|
||||
updatedModel, _ := list.Update(jKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 1 {
|
||||
t.Errorf("Expected selected index 1 after 'j' key, got %d", idx)
|
||||
}
|
||||
|
||||
// Test k key (up)
|
||||
kKey := tea.KeyPressMsg{Code: 'k', Text: "k"}
|
||||
updatedModel, _ = list.Update(kKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected selected index 0 after 'k' key, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlNavigation(t *testing.T) {
|
||||
list := createTestList()
|
||||
|
||||
// Test Ctrl-N (down)
|
||||
ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
|
||||
updatedModel, _ := list.Update(ctrlN)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 1 {
|
||||
t.Errorf("Expected selected index 1 after Ctrl-N, got %d", idx)
|
||||
}
|
||||
|
||||
// Test Ctrl-P (up)
|
||||
ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
|
||||
updatedModel, _ = list.Update(ctrlP)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected selected index 0 after Ctrl-P, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationBoundaries(t *testing.T) {
|
||||
list := createTestList()
|
||||
|
||||
// Test up arrow at first item (should stay at 0)
|
||||
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
|
||||
updatedModel, _ := list.Update(upKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected to stay at index 0 when pressing up at first item, got %d", idx)
|
||||
}
|
||||
|
||||
// Move to last item
|
||||
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
updatedModel, _ = list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 2 {
|
||||
t.Errorf("Expected to be at index 2, got %d", idx)
|
||||
}
|
||||
|
||||
// Test down arrow at last item (should stay at 2)
|
||||
updatedModel, _ = list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 2 {
|
||||
t.Errorf("Expected to stay at index 2 when pressing down at last item, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyList(t *testing.T) {
|
||||
emptyList := NewListComponent(
|
||||
WithItems([]testItem{}),
|
||||
WithMaxVisibleHeight[testItem](5),
|
||||
WithFallbackMessage[testItem]("empty"),
|
||||
WithAlphaNumericKeys[testItem](false),
|
||||
WithRenderFunc(
|
||||
func(item testItem, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, false, baseStyle)
|
||||
},
|
||||
),
|
||||
WithSelectableFunc(func(item testItem) bool {
|
||||
return item.Selectable()
|
||||
}),
|
||||
)
|
||||
|
||||
// Test navigation on empty list (should not crash)
|
||||
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
|
||||
ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
|
||||
ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
|
||||
|
||||
updatedModel, _ := emptyList.Update(downKey)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = emptyList.Update(upKey)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = emptyList.Update(ctrlN)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = emptyList.Update(ctrlP)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
|
||||
// Verify empty list behavior
|
||||
_, idx := emptyList.GetSelectedItem()
|
||||
if idx != -1 {
|
||||
t.Errorf("Expected index -1 for empty list, got %d", idx)
|
||||
}
|
||||
|
||||
if !emptyList.IsEmpty() {
|
||||
t.Error("Expected IsEmpty() to return true for empty list")
|
||||
}
|
||||
}
|
||||
145
packages/client/tui/internal/components/modal/modal.go
Normal file
145
packages/client/tui/internal/components/modal/modal.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package modal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
// CloseModalMsg is a message to signal that the active modal should be closed.
|
||||
type CloseModalMsg struct{}
|
||||
|
||||
// Modal is a reusable modal component that handles frame rendering and overlay placement
|
||||
type Modal struct {
|
||||
width int
|
||||
height int
|
||||
title string
|
||||
maxWidth int
|
||||
maxHeight int
|
||||
fitContent bool
|
||||
}
|
||||
|
||||
// ModalOption is a function that configures a Modal
|
||||
type ModalOption func(*Modal)
|
||||
|
||||
// WithTitle sets the modal title
|
||||
func WithTitle(title string) ModalOption {
|
||||
return func(m *Modal) {
|
||||
m.title = title
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxWidth sets the maximum width
|
||||
func WithMaxWidth(width int) ModalOption {
|
||||
return func(m *Modal) {
|
||||
m.maxWidth = width
|
||||
m.fitContent = false
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxHeight sets the maximum height
|
||||
func WithMaxHeight(height int) ModalOption {
|
||||
return func(m *Modal) {
|
||||
m.maxHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
func WithFitContent(fit bool) ModalOption {
|
||||
return func(m *Modal) {
|
||||
m.fitContent = fit
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new Modal with the given options
|
||||
func New(opts ...ModalOption) *Modal {
|
||||
m := &Modal{
|
||||
maxWidth: 0,
|
||||
maxHeight: 0,
|
||||
fitContent: true,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(m)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Modal) SetTitle(title string) {
|
||||
m.title = title
|
||||
}
|
||||
|
||||
// Render renders the modal centered on the screen
|
||||
func (m *Modal) Render(contentView string, background string) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
outerWidth := layout.Current.Container.Width - 8
|
||||
if m.maxWidth > 0 && outerWidth > m.maxWidth {
|
||||
outerWidth = m.maxWidth
|
||||
}
|
||||
|
||||
if m.fitContent {
|
||||
titleWidth := lipgloss.Width(m.title)
|
||||
contentWidth := lipgloss.Width(contentView)
|
||||
largestWidth := max(titleWidth+2, contentWidth)
|
||||
outerWidth = largestWidth + 6
|
||||
}
|
||||
|
||||
innerWidth := outerWidth - 4
|
||||
|
||||
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel())
|
||||
|
||||
var finalContent string
|
||||
if m.title != "" {
|
||||
titleStyle := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
escStyle := baseStyle.Foreground(t.TextMuted())
|
||||
escText := escStyle.Render("esc")
|
||||
|
||||
// Calculate position for esc text
|
||||
titleWidth := lipgloss.Width(m.title)
|
||||
escWidth := lipgloss.Width(escText)
|
||||
spacesNeeded := max(0, innerWidth-titleWidth-escWidth-2)
|
||||
spacer := strings.Repeat(" ", spacesNeeded)
|
||||
titleLine := m.title + spacer + escText
|
||||
titleLine = titleStyle.Render(titleLine)
|
||||
|
||||
finalContent = strings.Join([]string{titleLine, "", contentView}, "\n")
|
||||
} else {
|
||||
finalContent = contentView
|
||||
}
|
||||
|
||||
modalStyle := baseStyle.
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2)
|
||||
|
||||
modalView := modalStyle.
|
||||
Width(outerWidth).
|
||||
Render(finalContent)
|
||||
|
||||
// Calculate position for centering
|
||||
bgHeight := lipgloss.Height(background)
|
||||
bgWidth := lipgloss.Width(background)
|
||||
modalHeight := lipgloss.Height(modalView)
|
||||
modalWidth := lipgloss.Width(modalView)
|
||||
|
||||
row := (bgHeight - modalHeight) / 2
|
||||
col := (bgWidth - modalWidth) / 2
|
||||
|
||||
return layout.PlaceOverlay(
|
||||
col-1, // TODO: whyyyyy
|
||||
row,
|
||||
modalView,
|
||||
background,
|
||||
layout.WithOverlayBorder(),
|
||||
layout.WithOverlayBorderColor(t.BorderActive()),
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package qr
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"rsc.io/qr"
|
||||
)
|
||||
@@ -23,9 +23,7 @@ func Generate(text string) (string, int, error) {
|
||||
}
|
||||
|
||||
// Create lipgloss style for QR code with theme colors
|
||||
qrStyle := lipgloss.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.Background())
|
||||
qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
|
||||
|
||||
var result strings.Builder
|
||||
|
||||
148
packages/client/tui/internal/components/status/status.go
Normal file
148
packages/client/tui/internal/components/status/status.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type StatusComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
}
|
||||
|
||||
type statusComponent struct {
|
||||
app *app.App
|
||||
width int
|
||||
cwd string
|
||||
}
|
||||
|
||||
func (m statusComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m statusComponent) logo() string {
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render
|
||||
emphasis := styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundElement()).
|
||||
Bold(true).
|
||||
Render
|
||||
|
||||
open := base("open")
|
||||
code := emphasis("code ")
|
||||
version := base(m.app.Version)
|
||||
return styles.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Padding(0, 1).
|
||||
Render(open + code + version)
|
||||
}
|
||||
|
||||
func (m statusComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
logo := m.logo()
|
||||
|
||||
cwd := styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundPanel()).
|
||||
Padding(0, 1).
|
||||
Render(m.cwd)
|
||||
|
||||
var modeBackground compat.AdaptiveColor
|
||||
var modeForeground compat.AdaptiveColor
|
||||
switch m.app.ModeIndex {
|
||||
case 0:
|
||||
modeBackground = t.BackgroundElement()
|
||||
modeForeground = t.TextMuted()
|
||||
case 1:
|
||||
modeBackground = t.Secondary()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 2:
|
||||
modeBackground = t.Accent()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 3:
|
||||
modeBackground = t.Success()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 4:
|
||||
modeBackground = t.Warning()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 5:
|
||||
modeBackground = t.Primary()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
case 6:
|
||||
modeBackground = t.Error()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
default:
|
||||
modeBackground = t.Secondary()
|
||||
modeForeground = t.BackgroundPanel()
|
||||
}
|
||||
|
||||
command := m.app.Commands[commands.SwitchModeCommand]
|
||||
kb := command.Keybindings[0]
|
||||
key := kb.Key
|
||||
if kb.RequiresLeader {
|
||||
key = m.app.Config.Keybinds.Leader + " " + kb.Key
|
||||
}
|
||||
|
||||
modeStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
|
||||
modeNameStyle := modeStyle.Bold(true).Render
|
||||
modeDescStyle := modeStyle.Render
|
||||
mode := modeNameStyle(strings.ToUpper(m.app.Mode.Name)) + modeDescStyle(" MODE")
|
||||
mode = modeStyle.
|
||||
Padding(0, 1).
|
||||
BorderLeft(true).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderForeground(modeBackground).
|
||||
BorderBackground(t.BackgroundPanel()).
|
||||
Render(mode)
|
||||
|
||||
mode = styles.NewStyle().
|
||||
Faint(true).
|
||||
Background(t.BackgroundPanel()).
|
||||
Foreground(t.TextMuted()).
|
||||
Render(key+" ") +
|
||||
mode
|
||||
|
||||
space := max(
|
||||
0,
|
||||
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(mode),
|
||||
)
|
||||
spacer := styles.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
|
||||
|
||||
status := logo + cwd + spacer + mode
|
||||
|
||||
blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
|
||||
return blank + "\n" + status
|
||||
}
|
||||
|
||||
func NewStatusCmp(app *app.App) StatusComponent {
|
||||
statusComponent := &statusComponent{
|
||||
app: app,
|
||||
}
|
||||
|
||||
homePath, err := os.UserHomeDir()
|
||||
cwdPath := app.Info.Path.Cwd
|
||||
if err == nil && homePath != "" && strings.HasPrefix(cwdPath, homePath) {
|
||||
cwdPath = "~" + cwdPath[len(homePath):]
|
||||
}
|
||||
statusComponent.cwd = cwdPath
|
||||
|
||||
return statusComponent
|
||||
}
|
||||
125
packages/client/tui/internal/components/textarea/memoization.go
Normal file
125
packages/client/tui/internal/components/textarea/memoization.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Package memoization implement a simple memoization cache. It's designed to
|
||||
// improve performance in textarea.
|
||||
package textarea
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Hasher is an interface that requires a Hash method. The Hash method is
|
||||
// expected to return a string representation of the hash of the object.
|
||||
type Hasher interface {
|
||||
Hash() string
|
||||
}
|
||||
|
||||
// entry is a struct that holds a key-value pair. It is used as an element
|
||||
// in the evictionList of the MemoCache.
|
||||
type entry[T any] struct {
|
||||
key string
|
||||
value T
|
||||
}
|
||||
|
||||
// MemoCache is a struct that represents a cache with a set capacity. It
|
||||
// uses an LRU (Least Recently Used) eviction policy. It is safe for
|
||||
// concurrent use.
|
||||
type MemoCache[H Hasher, T any] struct {
|
||||
capacity int
|
||||
mutex sync.Mutex
|
||||
cache map[string]*list.Element // The cache holding the results
|
||||
evictionList *list.List // A list to keep track of the order for LRU
|
||||
hashableItems map[string]T // This map keeps track of the original hashable items (optional)
|
||||
}
|
||||
|
||||
// NewMemoCache is a function that creates a new MemoCache with a given
|
||||
// capacity. It returns a pointer to the created MemoCache.
|
||||
func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
|
||||
return &MemoCache[H, T]{
|
||||
capacity: capacity,
|
||||
cache: make(map[string]*list.Element),
|
||||
evictionList: list.New(),
|
||||
hashableItems: make(map[string]T),
|
||||
}
|
||||
}
|
||||
|
||||
// Capacity is a method that returns the capacity of the MemoCache.
|
||||
func (m *MemoCache[H, T]) Capacity() int {
|
||||
return m.capacity
|
||||
}
|
||||
|
||||
// Size is a method that returns the current size of the MemoCache. It is
|
||||
// the number of items currently stored in the cache.
|
||||
func (m *MemoCache[H, T]) Size() int {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
return m.evictionList.Len()
|
||||
}
|
||||
|
||||
// Get is a method that returns the value associated with the given
|
||||
// hashable item in the MemoCache. If there is no corresponding value, the
|
||||
// method returns nil.
|
||||
func (m *MemoCache[H, T]) Get(h H) (T, bool) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
hashedKey := h.Hash()
|
||||
if element, found := m.cache[hashedKey]; found {
|
||||
m.evictionList.MoveToFront(element)
|
||||
return element.Value.(*entry[T]).value, true
|
||||
}
|
||||
var result T
|
||||
return result, false
|
||||
}
|
||||
|
||||
// Set is a method that sets the value for the given hashable item in the
|
||||
// MemoCache. If the cache is at capacity, it evicts the least recently
|
||||
// used item before adding the new item.
|
||||
func (m *MemoCache[H, T]) Set(h H, value T) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
hashedKey := h.Hash()
|
||||
if element, found := m.cache[hashedKey]; found {
|
||||
m.evictionList.MoveToFront(element)
|
||||
element.Value.(*entry[T]).value = value
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the cache is at capacity
|
||||
if m.evictionList.Len() >= m.capacity {
|
||||
// Evict the least recently used item from the cache
|
||||
toEvict := m.evictionList.Back()
|
||||
if toEvict != nil {
|
||||
evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
|
||||
delete(m.cache, evictedEntry.key)
|
||||
delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
|
||||
}
|
||||
}
|
||||
|
||||
// Add the value to the cache and the evictionList
|
||||
newEntry := &entry[T]{
|
||||
key: hashedKey,
|
||||
value: value,
|
||||
}
|
||||
element := m.evictionList.PushFront(newEntry)
|
||||
m.cache[hashedKey] = element
|
||||
m.hashableItems[hashedKey] = value // if you're keeping track of original items
|
||||
}
|
||||
|
||||
// HString is a type that implements the Hasher interface for strings.
|
||||
type HString string
|
||||
|
||||
// Hash is a method that returns the hash of the string.
|
||||
func (h HString) Hash() string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
|
||||
}
|
||||
|
||||
// HInt is a type that implements the Hasher interface for integers.
|
||||
type HInt int
|
||||
|
||||
// Hash is a method that returns the hash of the integer.
|
||||
func (h HInt) Hash() string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h))))
|
||||
}
|
||||
102
packages/client/tui/internal/components/textarea/runeutil.go
Normal file
102
packages/client/tui/internal/components/textarea/runeutil.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Package runeutil provides utility functions for tidying up incoming runes
|
||||
// from Key messages.
|
||||
package textarea
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Sanitizer is a helper for bubble widgets that want to process
|
||||
// Runes from input key messages.
|
||||
type Sanitizer interface {
|
||||
// Sanitize removes control characters from runes in a KeyRunes
|
||||
// message, and optionally replaces newline/carriage return/tabs by a
|
||||
// specified character.
|
||||
//
|
||||
// The rune array is modified in-place if possible. In that case, the
|
||||
// returned slice is the original slice shortened after the control
|
||||
// characters have been removed/translated.
|
||||
Sanitize(runes []rune) []rune
|
||||
}
|
||||
|
||||
// NewSanitizer constructs a rune sanitizer.
|
||||
func NewSanitizer(opts ...Option) Sanitizer {
|
||||
s := sanitizer{
|
||||
replaceNewLine: []rune("\n"),
|
||||
replaceTab: []rune(" "),
|
||||
}
|
||||
for _, o := range opts {
|
||||
s = o(s)
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// Option is the type of option that can be passed to Sanitize().
|
||||
type Option func(sanitizer) sanitizer
|
||||
|
||||
// ReplaceTabs replaces tabs by the specified string.
|
||||
func ReplaceTabs(tabRepl string) Option {
|
||||
return func(s sanitizer) sanitizer {
|
||||
s.replaceTab = []rune(tabRepl)
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceNewlines replaces newline characters by the specified string.
|
||||
func ReplaceNewlines(nlRepl string) Option {
|
||||
return func(s sanitizer) sanitizer {
|
||||
s.replaceNewLine = []rune(nlRepl)
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sanitizer) Sanitize(runes []rune) []rune {
|
||||
// dstrunes are where we are storing the result.
|
||||
dstrunes := runes[:0:len(runes)]
|
||||
// copied indicates whether dstrunes is an alias of runes
|
||||
// or a copy. We need a copy when dst moves past src.
|
||||
// We use this as an optimization to avoid allocating
|
||||
// a new rune slice in the common case where the output
|
||||
// is smaller or equal to the input.
|
||||
copied := false
|
||||
|
||||
for src := 0; src < len(runes); src++ {
|
||||
r := runes[src]
|
||||
switch {
|
||||
case r == utf8.RuneError:
|
||||
// skip
|
||||
|
||||
case r == '\r' || r == '\n':
|
||||
if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
|
||||
dst := len(dstrunes)
|
||||
dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
|
||||
copy(dstrunes, runes[:dst])
|
||||
copied = true
|
||||
}
|
||||
dstrunes = append(dstrunes, s.replaceNewLine...)
|
||||
|
||||
case r == '\t':
|
||||
if len(dstrunes)+len(s.replaceTab) > src && !copied {
|
||||
dst := len(dstrunes)
|
||||
dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
|
||||
copy(dstrunes, runes[:dst])
|
||||
copied = true
|
||||
}
|
||||
dstrunes = append(dstrunes, s.replaceTab...)
|
||||
|
||||
case unicode.IsControl(r):
|
||||
// Other control characters: skip.
|
||||
|
||||
default:
|
||||
// Keep the character.
|
||||
dstrunes = append(dstrunes, runes[src])
|
||||
}
|
||||
}
|
||||
return dstrunes
|
||||
}
|
||||
|
||||
type sanitizer struct {
|
||||
replaceNewLine []rune
|
||||
replaceTab []rune
|
||||
}
|
||||
2164
packages/client/tui/internal/components/textarea/textarea.go
Normal file
2164
packages/client/tui/internal/components/textarea/textarea.go
Normal file
File diff suppressed because it is too large
Load Diff
266
packages/client/tui/internal/components/toast/toast.go
Normal file
266
packages/client/tui/internal/components/toast/toast.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package toast
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
// ShowToastMsg is a message to display a toast notification
|
||||
type ShowToastMsg struct {
|
||||
Message string
|
||||
Title *string
|
||||
Color compat.AdaptiveColor
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// DismissToastMsg is a message to dismiss a specific toast
|
||||
type DismissToastMsg struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
// Toast represents a single toast notification
|
||||
type Toast struct {
|
||||
ID string
|
||||
Message string
|
||||
Title *string
|
||||
Color compat.AdaptiveColor
|
||||
CreatedAt time.Time
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// ToastManager manages multiple toast notifications
|
||||
type ToastManager struct {
|
||||
toasts []Toast
|
||||
}
|
||||
|
||||
// NewToastManager creates a new toast manager
|
||||
func NewToastManager() *ToastManager {
|
||||
return &ToastManager{
|
||||
toasts: []Toast{},
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the toast manager
|
||||
func (tm *ToastManager) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the toast manager
|
||||
func (tm *ToastManager) Update(msg tea.Msg) (*ToastManager, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case ShowToastMsg:
|
||||
toast := Toast{
|
||||
ID: fmt.Sprintf("toast-%d", time.Now().UnixNano()),
|
||||
Title: msg.Title,
|
||||
Message: msg.Message,
|
||||
Color: msg.Color,
|
||||
CreatedAt: time.Now(),
|
||||
Duration: msg.Duration,
|
||||
}
|
||||
|
||||
tm.toasts = append(tm.toasts, toast)
|
||||
|
||||
// Return command to dismiss after duration
|
||||
return tm, tea.Tick(toast.Duration, func(t time.Time) tea.Msg {
|
||||
return DismissToastMsg{ID: toast.ID}
|
||||
})
|
||||
|
||||
case DismissToastMsg:
|
||||
var newToasts []Toast
|
||||
for _, t := range tm.toasts {
|
||||
if t.ID != msg.ID {
|
||||
newToasts = append(newToasts, t)
|
||||
}
|
||||
}
|
||||
tm.toasts = newToasts
|
||||
}
|
||||
|
||||
return tm, nil
|
||||
}
|
||||
|
||||
// renderSingleToast renders a single toast notification
|
||||
func (tm *ToastManager) renderSingleToast(toast Toast) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
baseStyle := styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundElement()).
|
||||
Padding(1, 2)
|
||||
|
||||
maxWidth := max(40, layout.Current.Viewport.Width/3)
|
||||
contentMaxWidth := max(maxWidth-6, 20)
|
||||
|
||||
// Build content with wrapping
|
||||
var content strings.Builder
|
||||
if toast.Title != nil {
|
||||
titleStyle := styles.NewStyle().Foreground(toast.Color).
|
||||
Bold(true)
|
||||
content.WriteString(titleStyle.Render(*toast.Title))
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
// Wrap message text
|
||||
messageStyle := styles.NewStyle()
|
||||
contentWidth := lipgloss.Width(toast.Message)
|
||||
if contentWidth > contentMaxWidth {
|
||||
messageStyle = messageStyle.Width(contentMaxWidth)
|
||||
}
|
||||
content.WriteString(messageStyle.Render(toast.Message))
|
||||
|
||||
// Render toast with max width
|
||||
return baseStyle.MaxWidth(maxWidth).Render(content.String())
|
||||
}
|
||||
|
||||
// View renders all active toasts
|
||||
func (tm *ToastManager) View() string {
|
||||
if len(tm.toasts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var toastViews []string
|
||||
for _, toast := range tm.toasts {
|
||||
toastView := tm.renderSingleToast(toast)
|
||||
toastViews = append(toastViews, toastView+"\n")
|
||||
}
|
||||
|
||||
return strings.Join(toastViews, "\n")
|
||||
}
|
||||
|
||||
// RenderOverlay renders the toasts as an overlay on the given background
|
||||
func (tm *ToastManager) RenderOverlay(background string) string {
|
||||
if len(tm.toasts) == 0 {
|
||||
return background
|
||||
}
|
||||
|
||||
bgWidth := lipgloss.Width(background)
|
||||
bgHeight := lipgloss.Height(background)
|
||||
result := background
|
||||
|
||||
// Start from top with 2 character padding
|
||||
currentY := 2
|
||||
|
||||
// Render each toast individually
|
||||
for _, toast := range tm.toasts {
|
||||
// Render individual toast
|
||||
toastView := tm.renderSingleToast(toast)
|
||||
toastWidth := lipgloss.Width(toastView)
|
||||
toastHeight := lipgloss.Height(toastView)
|
||||
|
||||
// Position at top-right with 2 character padding from right edge
|
||||
x := max(bgWidth-toastWidth-4, 0)
|
||||
|
||||
// Check if toast fits vertically
|
||||
if currentY+toastHeight > bgHeight-2 {
|
||||
// No more room for toasts
|
||||
break
|
||||
}
|
||||
|
||||
// Place this toast
|
||||
result = layout.PlaceOverlay(
|
||||
x,
|
||||
currentY,
|
||||
toastView,
|
||||
result,
|
||||
layout.WithOverlayBorder(),
|
||||
layout.WithOverlayBorderColor(toast.Color),
|
||||
)
|
||||
|
||||
// Move down for next toast (add 1 for spacing between toasts)
|
||||
currentY += toastHeight + 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type ToastOptions struct {
|
||||
Title string
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
type toastOptions struct {
|
||||
title *string
|
||||
duration *time.Duration
|
||||
color *compat.AdaptiveColor
|
||||
}
|
||||
|
||||
type ToastOption func(*toastOptions)
|
||||
|
||||
func WithTitle(title string) ToastOption {
|
||||
return func(t *toastOptions) {
|
||||
t.title = &title
|
||||
}
|
||||
}
|
||||
func WithDuration(duration time.Duration) ToastOption {
|
||||
return func(t *toastOptions) {
|
||||
t.duration = &duration
|
||||
}
|
||||
}
|
||||
|
||||
func WithColor(color compat.AdaptiveColor) ToastOption {
|
||||
return func(t *toastOptions) {
|
||||
t.color = &color
|
||||
}
|
||||
}
|
||||
|
||||
func NewToast(message string, options ...ToastOption) tea.Cmd {
|
||||
t := theme.CurrentTheme()
|
||||
duration := 5 * time.Second
|
||||
color := t.Primary()
|
||||
|
||||
opts := toastOptions{
|
||||
duration: &duration,
|
||||
color: &color,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(&opts)
|
||||
}
|
||||
|
||||
return func() tea.Msg {
|
||||
return ShowToastMsg{
|
||||
Message: message,
|
||||
Title: opts.title,
|
||||
Duration: *opts.duration,
|
||||
Color: *opts.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewInfoToast(message string, options ...ToastOption) tea.Cmd {
|
||||
options = append(options, WithColor(theme.CurrentTheme().Info()))
|
||||
return NewToast(
|
||||
message,
|
||||
options...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewSuccessToast(message string, options ...ToastOption) tea.Cmd {
|
||||
options = append(options, WithColor(theme.CurrentTheme().Success()))
|
||||
return NewToast(
|
||||
message,
|
||||
options...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewWarningToast(message string, options ...ToastOption) tea.Cmd {
|
||||
options = append(options, WithColor(theme.CurrentTheme().Warning()))
|
||||
return NewToast(
|
||||
message,
|
||||
options...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewErrorToast(message string, options ...ToastOption) tea.Cmd {
|
||||
options = append(options, WithColor(theme.CurrentTheme().Error()))
|
||||
return NewToast(
|
||||
message,
|
||||
options...,
|
||||
)
|
||||
}
|
||||
96
packages/client/tui/internal/id/id.go
Normal file
96
packages/client/tui/internal/id/id.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package id
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PrefixSession = "ses"
|
||||
PrefixMessage = "msg"
|
||||
PrefixUser = "usr"
|
||||
PrefixPart = "prt"
|
||||
)
|
||||
|
||||
const length = 26
|
||||
|
||||
var (
|
||||
lastTimestamp int64
|
||||
counter int64
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
type Prefix string
|
||||
|
||||
const (
|
||||
Session Prefix = PrefixSession
|
||||
Message Prefix = PrefixMessage
|
||||
User Prefix = PrefixUser
|
||||
Part Prefix = PrefixPart
|
||||
)
|
||||
|
||||
func ValidatePrefix(id string, prefix Prefix) bool {
|
||||
return strings.HasPrefix(id, string(prefix))
|
||||
}
|
||||
|
||||
func Ascending(prefix Prefix, given ...string) string {
|
||||
return generateID(prefix, false, given...)
|
||||
}
|
||||
|
||||
func Descending(prefix Prefix, given ...string) string {
|
||||
return generateID(prefix, true, given...)
|
||||
}
|
||||
|
||||
func generateID(prefix Prefix, descending bool, given ...string) string {
|
||||
if len(given) > 0 && given[0] != "" {
|
||||
if !strings.HasPrefix(given[0], string(prefix)) {
|
||||
panic(fmt.Sprintf("ID %s does not start with %s", given[0], string(prefix)))
|
||||
}
|
||||
return given[0]
|
||||
}
|
||||
|
||||
return generateNewID(prefix, descending)
|
||||
}
|
||||
|
||||
func randomBase62(length int) string {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
result := make([]byte, length)
|
||||
bytes := make([]byte, length)
|
||||
rand.Read(bytes)
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
result[i] = chars[bytes[i]%62]
|
||||
}
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func generateNewID(prefix Prefix, descending bool) string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
currentTimestamp := time.Now().UnixMilli()
|
||||
|
||||
if currentTimestamp != lastTimestamp {
|
||||
lastTimestamp = currentTimestamp
|
||||
counter = 0
|
||||
}
|
||||
counter++
|
||||
|
||||
now := uint64(currentTimestamp)*0x1000 + uint64(counter)
|
||||
|
||||
if descending {
|
||||
now = ^now
|
||||
}
|
||||
|
||||
timeBytes := make([]byte, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
timeBytes[i] = byte((now >> (40 - 8*i)) & 0xff)
|
||||
}
|
||||
|
||||
return string(prefix) + "_" + hex.EncodeToString(timeBytes) + randomBase62(length-12)
|
||||
}
|
||||
325
packages/client/tui/internal/layout/flex.go
Normal file
325
packages/client/tui/internal/layout/flex.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type Direction int
|
||||
|
||||
const (
|
||||
Row Direction = iota
|
||||
Column
|
||||
)
|
||||
|
||||
type Justify int
|
||||
|
||||
const (
|
||||
JustifyStart Justify = iota
|
||||
JustifyEnd
|
||||
JustifyCenter
|
||||
JustifySpaceBetween
|
||||
JustifySpaceAround
|
||||
)
|
||||
|
||||
type Align int
|
||||
|
||||
const (
|
||||
AlignStart Align = iota
|
||||
AlignEnd
|
||||
AlignCenter
|
||||
AlignStretch // Only applicable in the cross-axis
|
||||
)
|
||||
|
||||
type FlexOptions struct {
|
||||
Background *compat.AdaptiveColor
|
||||
Direction Direction
|
||||
Justify Justify
|
||||
Align Align
|
||||
Width int
|
||||
Height int
|
||||
Gap int
|
||||
}
|
||||
|
||||
type FlexItem struct {
|
||||
View string
|
||||
FixedSize int // Fixed size in the main axis (width for Row, height for Column)
|
||||
Grow bool // If true, the item will grow to fill available space
|
||||
}
|
||||
|
||||
// Render lays out a series of view strings based on flexbox-like rules.
|
||||
func Render(opts FlexOptions, items ...FlexItem) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
if opts.Background == nil {
|
||||
background := t.Background()
|
||||
opts.Background = &background
|
||||
}
|
||||
|
||||
// Calculate dimensions for each item
|
||||
mainAxisSize := opts.Width
|
||||
crossAxisSize := opts.Height
|
||||
if opts.Direction == Column {
|
||||
mainAxisSize = opts.Height
|
||||
crossAxisSize = opts.Width
|
||||
}
|
||||
|
||||
// Calculate total fixed size and count grow items
|
||||
totalFixedSize := 0
|
||||
growCount := 0
|
||||
for _, item := range items {
|
||||
if item.FixedSize > 0 {
|
||||
totalFixedSize += item.FixedSize
|
||||
} else if item.Grow {
|
||||
growCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Account for gaps between items
|
||||
totalGapSize := 0
|
||||
if len(items) > 1 && opts.Gap > 0 {
|
||||
totalGapSize = opts.Gap * (len(items) - 1)
|
||||
}
|
||||
|
||||
// Calculate available space for grow items
|
||||
availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0)
|
||||
|
||||
// Calculate size for each grow item
|
||||
growItemSize := 0
|
||||
if growCount > 0 && availableSpace > 0 {
|
||||
growItemSize = availableSpace / growCount
|
||||
}
|
||||
|
||||
// Prepare sized views
|
||||
sizedViews := make([]string, len(items))
|
||||
actualSizes := make([]int, len(items))
|
||||
|
||||
for i, item := range items {
|
||||
view := item.View
|
||||
|
||||
// Determine the size for this item
|
||||
itemSize := 0
|
||||
if item.FixedSize > 0 {
|
||||
itemSize = item.FixedSize
|
||||
} else if item.Grow && growItemSize > 0 {
|
||||
itemSize = growItemSize
|
||||
} else {
|
||||
// No fixed size and not growing - use natural size
|
||||
if opts.Direction == Row {
|
||||
itemSize = lipgloss.Width(view)
|
||||
} else {
|
||||
itemSize = lipgloss.Height(view)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply size constraints
|
||||
if opts.Direction == Row {
|
||||
// For row direction, constrain width and handle height alignment
|
||||
if itemSize > 0 {
|
||||
view = styles.NewStyle().
|
||||
Background(*opts.Background).
|
||||
Width(itemSize).
|
||||
Height(crossAxisSize).
|
||||
Render(view)
|
||||
}
|
||||
|
||||
// Apply cross-axis alignment
|
||||
switch opts.Align {
|
||||
case AlignCenter:
|
||||
view = lipgloss.PlaceVertical(
|
||||
crossAxisSize,
|
||||
lipgloss.Center,
|
||||
view,
|
||||
styles.WhitespaceStyle(*opts.Background),
|
||||
)
|
||||
case AlignEnd:
|
||||
view = lipgloss.PlaceVertical(
|
||||
crossAxisSize,
|
||||
lipgloss.Bottom,
|
||||
view,
|
||||
styles.WhitespaceStyle(*opts.Background),
|
||||
)
|
||||
case AlignStart:
|
||||
view = lipgloss.PlaceVertical(
|
||||
crossAxisSize,
|
||||
lipgloss.Top,
|
||||
view,
|
||||
styles.WhitespaceStyle(*opts.Background),
|
||||
)
|
||||
case AlignStretch:
|
||||
// Already stretched by Height setting above
|
||||
}
|
||||
} else {
|
||||
// For column direction, constrain height and handle width alignment
|
||||
if itemSize > 0 {
|
||||
style := styles.NewStyle().
|
||||
Background(*opts.Background).
|
||||
Height(itemSize)
|
||||
// Only set width for stretch alignment
|
||||
if opts.Align == AlignStretch {
|
||||
style = style.Width(crossAxisSize)
|
||||
}
|
||||
view = style.Render(view)
|
||||
}
|
||||
|
||||
// Apply cross-axis alignment
|
||||
switch opts.Align {
|
||||
case AlignCenter:
|
||||
view = lipgloss.PlaceHorizontal(
|
||||
crossAxisSize,
|
||||
lipgloss.Center,
|
||||
view,
|
||||
styles.WhitespaceStyle(*opts.Background),
|
||||
)
|
||||
case AlignEnd:
|
||||
view = lipgloss.PlaceHorizontal(
|
||||
crossAxisSize,
|
||||
lipgloss.Right,
|
||||
view,
|
||||
styles.WhitespaceStyle(*opts.Background),
|
||||
)
|
||||
case AlignStart:
|
||||
view = lipgloss.PlaceHorizontal(
|
||||
crossAxisSize,
|
||||
lipgloss.Left,
|
||||
view,
|
||||
styles.WhitespaceStyle(*opts.Background),
|
||||
)
|
||||
case AlignStretch:
|
||||
// Already stretched by Width setting above
|
||||
}
|
||||
}
|
||||
|
||||
sizedViews[i] = view
|
||||
if opts.Direction == Row {
|
||||
actualSizes[i] = lipgloss.Width(view)
|
||||
} else {
|
||||
actualSizes[i] = lipgloss.Height(view)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total actual size including gaps
|
||||
totalActualSize := 0
|
||||
for _, size := range actualSizes {
|
||||
totalActualSize += size
|
||||
}
|
||||
if len(items) > 1 && opts.Gap > 0 {
|
||||
totalActualSize += opts.Gap * (len(items) - 1)
|
||||
}
|
||||
|
||||
// Apply justification
|
||||
remainingSpace := max(mainAxisSize-totalActualSize, 0)
|
||||
|
||||
// Calculate spacing based on justification
|
||||
var spaceBefore, spaceBetween, spaceAfter int
|
||||
switch opts.Justify {
|
||||
case JustifyStart:
|
||||
spaceAfter = remainingSpace
|
||||
case JustifyEnd:
|
||||
spaceBefore = remainingSpace
|
||||
case JustifyCenter:
|
||||
spaceBefore = remainingSpace / 2
|
||||
spaceAfter = remainingSpace - spaceBefore
|
||||
case JustifySpaceBetween:
|
||||
if len(items) > 1 {
|
||||
spaceBetween = remainingSpace / (len(items) - 1)
|
||||
} else {
|
||||
spaceAfter = remainingSpace
|
||||
}
|
||||
case JustifySpaceAround:
|
||||
if len(items) > 0 {
|
||||
spaceAround := remainingSpace / (len(items) * 2)
|
||||
spaceBefore = spaceAround
|
||||
spaceAfter = spaceAround
|
||||
spaceBetween = spaceAround * 2
|
||||
}
|
||||
}
|
||||
|
||||
// Build the final layout
|
||||
var parts []string
|
||||
|
||||
spaceStyle := styles.NewStyle().Background(*opts.Background)
|
||||
// Add space before if needed
|
||||
if spaceBefore > 0 {
|
||||
if opts.Direction == Row {
|
||||
space := strings.Repeat(" ", spaceBefore)
|
||||
parts = append(parts, spaceStyle.Render(space))
|
||||
} else {
|
||||
// For vertical layout, add empty lines as separate parts
|
||||
for range spaceBefore {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add items with spacing
|
||||
for i, view := range sizedViews {
|
||||
parts = append(parts, view)
|
||||
|
||||
// Add space between items (not after the last one)
|
||||
if i < len(sizedViews)-1 {
|
||||
// Add gap first, then any additional spacing from justification
|
||||
totalSpacing := opts.Gap + spaceBetween
|
||||
if totalSpacing > 0 {
|
||||
if opts.Direction == Row {
|
||||
space := strings.Repeat(" ", totalSpacing)
|
||||
parts = append(parts, spaceStyle.Render(space))
|
||||
} else {
|
||||
// For vertical layout, add empty lines as separate parts
|
||||
for range totalSpacing {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add space after if needed
|
||||
if spaceAfter > 0 {
|
||||
if opts.Direction == Row {
|
||||
space := strings.Repeat(" ", spaceAfter)
|
||||
parts = append(parts, spaceStyle.Render(space))
|
||||
} else {
|
||||
// For vertical layout, add empty lines as separate parts
|
||||
for range spaceAfter {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join the parts
|
||||
if opts.Direction == Row {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
|
||||
} else {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create a simple vertical layout
|
||||
func Vertical(width, height int, items ...FlexItem) string {
|
||||
return Render(FlexOptions{
|
||||
Direction: Column,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Justify: JustifyStart,
|
||||
Align: AlignStretch,
|
||||
}, items...)
|
||||
}
|
||||
|
||||
// Helper function to create a simple horizontal layout
|
||||
func Horizontal(width, height int, items ...FlexItem) string {
|
||||
return Render(FlexOptions{
|
||||
Direction: Row,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Justify: JustifyStart,
|
||||
Align: AlignStretch,
|
||||
}, items...)
|
||||
}
|
||||
32
packages/client/tui/internal/layout/layout.go
Normal file
32
packages/client/tui/internal/layout/layout.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
)
|
||||
|
||||
var Current *LayoutInfo
|
||||
|
||||
func init() {
|
||||
Current = &LayoutInfo{
|
||||
Viewport: Dimensions{Width: 80, Height: 25},
|
||||
Container: Dimensions{Width: 80, Height: 25},
|
||||
}
|
||||
}
|
||||
|
||||
type LayoutSize string
|
||||
|
||||
type Dimensions struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
type LayoutInfo struct {
|
||||
Viewport Dimensions
|
||||
Container Dimensions
|
||||
}
|
||||
|
||||
type Modal interface {
|
||||
tea.Model
|
||||
Render(background string) string
|
||||
Close() tea.Cmd
|
||||
}
|
||||
382
packages/client/tui/internal/layout/overlay.go
Normal file
382
packages/client/tui/internal/layout/overlay.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
chAnsi "github.com/charmbracelet/x/ansi"
|
||||
"github.com/muesli/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
var (
|
||||
// ANSI escape sequence regex
|
||||
ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
)
|
||||
|
||||
// Split a string into lines, additionally returning the size of the widest line.
|
||||
func getLines(s string) (lines []string, widest int) {
|
||||
lines = strings.Split(s, "\n")
|
||||
for _, l := range lines {
|
||||
w := ansi.PrintableRuneWidth(l)
|
||||
if widest < w {
|
||||
widest = w
|
||||
}
|
||||
}
|
||||
return lines, widest
|
||||
}
|
||||
|
||||
// overlayOptions holds configuration for overlay rendering
|
||||
type overlayOptions struct {
|
||||
whitespace *whitespace
|
||||
border bool
|
||||
borderColor *compat.AdaptiveColor
|
||||
}
|
||||
|
||||
// OverlayOption sets options for overlay rendering
|
||||
type OverlayOption func(*overlayOptions)
|
||||
|
||||
// PlaceOverlay places fg on top of bg.
|
||||
func PlaceOverlay(
|
||||
x, y int,
|
||||
fg, bg string,
|
||||
opts ...OverlayOption,
|
||||
) string {
|
||||
fgLines, fgWidth := getLines(fg)
|
||||
bgLines, bgWidth := getLines(bg)
|
||||
bgHeight := len(bgLines)
|
||||
fgHeight := len(fgLines)
|
||||
|
||||
// Parse options
|
||||
options := &overlayOptions{
|
||||
whitespace: &whitespace{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
// Adjust for borders if enabled
|
||||
if options.border {
|
||||
// Add space for left and right borders
|
||||
adjustedFgWidth := fgWidth + 2
|
||||
// Adjust placement to account for borders
|
||||
x = util.Clamp(x, 0, bgWidth-adjustedFgWidth)
|
||||
y = util.Clamp(y, 0, bgHeight-fgHeight)
|
||||
|
||||
// Pad all foreground lines to the same width for consistent borders
|
||||
for i := range fgLines {
|
||||
lineWidth := ansi.PrintableRuneWidth(fgLines[i])
|
||||
if lineWidth < fgWidth {
|
||||
fgLines[i] += strings.Repeat(" ", fgWidth-lineWidth)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if fgWidth >= bgWidth && fgHeight >= bgHeight {
|
||||
// FIXME: return fg or bg?
|
||||
return fg
|
||||
}
|
||||
// TODO: allow placement outside of the bg box?
|
||||
x = util.Clamp(x, 0, bgWidth-fgWidth)
|
||||
y = util.Clamp(y, 0, bgHeight-fgHeight)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for i, bgLine := range bgLines {
|
||||
if i > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if i < y || i >= y+fgHeight {
|
||||
b.WriteString(bgLine)
|
||||
continue
|
||||
}
|
||||
|
||||
pos := 0
|
||||
|
||||
// Handle left side of the line up to the overlay
|
||||
if x > 0 {
|
||||
left := truncate.String(bgLine, uint(x))
|
||||
pos = ansi.PrintableRuneWidth(left)
|
||||
b.WriteString(left)
|
||||
if pos < x {
|
||||
b.WriteString(options.whitespace.render(x - pos))
|
||||
pos = x
|
||||
}
|
||||
}
|
||||
|
||||
// Render the overlay content with optional borders
|
||||
if options.border {
|
||||
// Get the foreground line
|
||||
fgLine := fgLines[i-y]
|
||||
fgLineWidth := ansi.PrintableRuneWidth(fgLine)
|
||||
|
||||
// Extract the styles at the border positions
|
||||
// We need to get the style just before the border position to preserve background
|
||||
leftStyle := ansiStyle{}
|
||||
if pos > 0 {
|
||||
leftStyle = getStyleAtPosition(bgLine, pos-1)
|
||||
} else {
|
||||
leftStyle = getStyleAtPosition(bgLine, pos)
|
||||
}
|
||||
rightStyle := getStyleAtPosition(bgLine, pos+fgLineWidth)
|
||||
|
||||
// Left border - combine background from original with border foreground
|
||||
leftSeq := combineStyles(leftStyle, options.borderColor)
|
||||
if leftSeq != "" {
|
||||
b.WriteString(leftSeq)
|
||||
}
|
||||
b.WriteString("┃")
|
||||
if leftSeq != "" {
|
||||
b.WriteString("\x1b[0m") // Reset all styles only if we applied any
|
||||
}
|
||||
pos++
|
||||
|
||||
// Content
|
||||
b.WriteString(fgLine)
|
||||
pos += fgLineWidth
|
||||
|
||||
// Right border - combine background from original with border foreground
|
||||
rightSeq := combineStyles(rightStyle, options.borderColor)
|
||||
if rightSeq != "" {
|
||||
b.WriteString(rightSeq)
|
||||
}
|
||||
b.WriteString("┃")
|
||||
if rightSeq != "" {
|
||||
b.WriteString("\x1b[0m") // Reset all styles only if we applied any
|
||||
}
|
||||
pos++
|
||||
} else {
|
||||
// No border, just render the content
|
||||
fgLine := fgLines[i-y]
|
||||
b.WriteString(fgLine)
|
||||
pos += ansi.PrintableRuneWidth(fgLine)
|
||||
}
|
||||
|
||||
// Handle right side of the line after the overlay
|
||||
right := cutLeft(bgLine, pos)
|
||||
bgWidth := ansi.PrintableRuneWidth(bgLine)
|
||||
rightWidth := ansi.PrintableRuneWidth(right)
|
||||
if rightWidth <= bgWidth-pos {
|
||||
b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
|
||||
}
|
||||
|
||||
b.WriteString(right)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cutLeft cuts printable characters from the left.
|
||||
// This function is heavily based on muesli's ansi and truncate packages.
|
||||
func cutLeft(s string, cutWidth int) string {
|
||||
return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
|
||||
}
|
||||
|
||||
// ansiStyle represents parsed ANSI style attributes
|
||||
type ansiStyle struct {
|
||||
fgColor string
|
||||
bgColor string
|
||||
attrs []string
|
||||
}
|
||||
|
||||
// parseANSISequence parses an ANSI escape sequence into its components
|
||||
func parseANSISequence(seq string) ansiStyle {
|
||||
style := ansiStyle{}
|
||||
|
||||
// Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
|
||||
if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
|
||||
return style
|
||||
}
|
||||
|
||||
params := seq[2 : len(seq)-1]
|
||||
if params == "" {
|
||||
return style
|
||||
}
|
||||
|
||||
parts := strings.Split(params, ";")
|
||||
i := 0
|
||||
for i < len(parts) {
|
||||
switch parts[i] {
|
||||
case "0": // Reset
|
||||
// Mark this as a reset by adding it to attrs
|
||||
style.attrs = append(style.attrs, "0")
|
||||
// Don't clear the style here, let the caller handle it
|
||||
case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
|
||||
style.attrs = append(style.attrs, parts[i])
|
||||
case "38": // Foreground color
|
||||
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
|
||||
// 256 color mode
|
||||
style.fgColor = strings.Join(parts[i:i+3], ";")
|
||||
i += 2
|
||||
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
|
||||
// RGB color mode
|
||||
style.fgColor = strings.Join(parts[i:i+5], ";")
|
||||
i += 4
|
||||
}
|
||||
case "48": // Background color
|
||||
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
|
||||
// 256 color mode
|
||||
style.bgColor = strings.Join(parts[i:i+3], ";")
|
||||
i += 2
|
||||
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
|
||||
// RGB color mode
|
||||
style.bgColor = strings.Join(parts[i:i+5], ";")
|
||||
i += 4
|
||||
}
|
||||
case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
|
||||
style.fgColor = parts[i]
|
||||
case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
|
||||
style.bgColor = parts[i]
|
||||
case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
|
||||
style.fgColor = parts[i]
|
||||
case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
|
||||
style.bgColor = parts[i]
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return style
|
||||
}
|
||||
|
||||
// combineStyles creates an ANSI sequence that combines background from one style with foreground from another
|
||||
func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
|
||||
if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
// Add attributes
|
||||
parts = append(parts, bgStyle.attrs...)
|
||||
|
||||
// Add background color from the original style
|
||||
if bgStyle.bgColor != "" {
|
||||
parts = append(parts, bgStyle.bgColor)
|
||||
}
|
||||
|
||||
// Add foreground color if specified
|
||||
if fgColor != nil {
|
||||
// Use the adaptive color which automatically selects based on terminal background
|
||||
// The RGBA method already handles light/dark selection
|
||||
r, g, b, _ := fgColor.RGBA()
|
||||
// RGBA returns 16-bit values, we need 8-bit
|
||||
parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
|
||||
}
|
||||
|
||||
// getStyleAtPosition extracts the active ANSI style at a given visual position
|
||||
func getStyleAtPosition(s string, targetPos int) ansiStyle {
|
||||
visualPos := 0
|
||||
currentStyle := ansiStyle{}
|
||||
|
||||
i := 0
|
||||
for i < len(s) && visualPos <= targetPos {
|
||||
// Check if we're at an ANSI escape sequence
|
||||
if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
|
||||
// Found an ANSI sequence at current position
|
||||
seq := s[i : i+match[1]]
|
||||
parsedStyle := parseANSISequence(seq)
|
||||
|
||||
// Check if this is a reset sequence
|
||||
if len(parsedStyle.attrs) > 0 && parsedStyle.attrs[0] == "0" {
|
||||
// Reset all styles
|
||||
currentStyle = ansiStyle{}
|
||||
} else {
|
||||
// Update current style (merge with existing)
|
||||
if parsedStyle.fgColor != "" {
|
||||
currentStyle.fgColor = parsedStyle.fgColor
|
||||
}
|
||||
if parsedStyle.bgColor != "" {
|
||||
currentStyle.bgColor = parsedStyle.bgColor
|
||||
}
|
||||
if len(parsedStyle.attrs) > 0 {
|
||||
currentStyle.attrs = parsedStyle.attrs
|
||||
}
|
||||
}
|
||||
|
||||
i += match[1]
|
||||
} else if i < len(s) {
|
||||
// Regular character
|
||||
if visualPos == targetPos {
|
||||
return currentStyle
|
||||
}
|
||||
_, size := utf8.DecodeRuneInString(s[i:])
|
||||
i += size
|
||||
visualPos++
|
||||
}
|
||||
}
|
||||
|
||||
return currentStyle
|
||||
}
|
||||
|
||||
type whitespace struct {
|
||||
style termenv.Style
|
||||
chars string
|
||||
}
|
||||
|
||||
// Render whitespaces.
|
||||
func (w whitespace) render(width int) string {
|
||||
if w.chars == "" {
|
||||
w.chars = " "
|
||||
}
|
||||
|
||||
r := []rune(w.chars)
|
||||
j := 0
|
||||
b := strings.Builder{}
|
||||
|
||||
// Cycle through runes and print them into the whitespace.
|
||||
for i := 0; i < width; {
|
||||
b.WriteRune(r[j])
|
||||
j++
|
||||
if j >= len(r) {
|
||||
j = 0
|
||||
}
|
||||
i += ansi.PrintableRuneWidth(string(r[j]))
|
||||
}
|
||||
|
||||
// Fill any extra gaps white spaces. This might be necessary if any runes
|
||||
// are more than one cell wide, which could leave a one-rune gap.
|
||||
short := width - ansi.PrintableRuneWidth(b.String())
|
||||
if short > 0 {
|
||||
b.WriteString(strings.Repeat(" ", short))
|
||||
}
|
||||
|
||||
return w.style.Styled(b.String())
|
||||
}
|
||||
|
||||
// WhitespaceOption sets a styling rule for rendering whitespace.
|
||||
type WhitespaceOption func(*whitespace)
|
||||
|
||||
// WithWhitespace sets whitespace options for the overlay
|
||||
func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
|
||||
return func(o *overlayOptions) {
|
||||
for _, opt := range opts {
|
||||
opt(o.whitespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithOverlayBorder enables border rendering for the overlay
|
||||
func WithOverlayBorder() OverlayOption {
|
||||
return func(o *overlayOptions) {
|
||||
o.border = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithOverlayBorderColor sets the border color for the overlay
|
||||
func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
|
||||
return func(o *overlayOptions) {
|
||||
o.borderColor = &color
|
||||
}
|
||||
}
|
||||
17
packages/client/tui/internal/styles/background.go
Normal file
17
packages/client/tui/internal/styles/background.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package styles
|
||||
|
||||
import "image/color"
|
||||
|
||||
type TerminalInfo struct {
|
||||
Background color.Color
|
||||
BackgroundIsDark bool
|
||||
}
|
||||
|
||||
var Terminal *TerminalInfo
|
||||
|
||||
func init() {
|
||||
Terminal = &TerminalInfo{
|
||||
Background: color.Black,
|
||||
BackgroundIsDark: true,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user