Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: oven-sh/setup-bun@v1
|
- uses: oven-sh/setup-bun@v1
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: 1.2.17
|
||||||
|
|
||||||
- run: bun install
|
- run: bun install
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: 1.2.16
|
bun-version: 1.2.17
|
||||||
|
|
||||||
- name: Install makepkg
|
- name: Install makepkg
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -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 "Update download stats $(date -I)"
|
||||||
|
git push
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://opencode.ai">
|
<a href="https://opencode.ai">
|
||||||
<picture>
|
<picture>
|
||||||
<source srcset="packages/web/src/assets/logo-dark.svg" media="(prefers-color-scheme: dark)">
|
<source srcset="packages/web/src/assets/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||||
<source srcset="packages/web/src/assets/logo-light.svg" media="(prefers-color-scheme: light)">
|
<source srcset="packages/web/src/assets/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||||
<img src="packages/web/src/assets/logo-light.svg" alt="opencode logo">
|
<img src="packages/web/src/assets/logo-ornate-light.svg" alt="opencode logo">
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
<a href="https://github.com/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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -38,6 +38,11 @@ For more info on how to configure opencode [**head over to our docs**](https://o
|
|||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
|
For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes.
|
||||||
|
|
||||||
|
> **Note**: Please talk to us via github issues before spending time working on
|
||||||
|
> a new feature
|
||||||
|
|
||||||
To run opencode locally you need.
|
To run opencode locally you need.
|
||||||
|
|
||||||
- Bun
|
- Bun
|
||||||
@@ -52,14 +57,7 @@ $ bun run packages/opencode/src/index.ts
|
|||||||
|
|
||||||
#### Development Notes
|
#### Development Notes
|
||||||
|
|
||||||
**API Client Generation**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you need to regenerate the Go client and OpenAPI specification:
|
**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.
|
||||||
|
|
||||||
```bash
|
|
||||||
$ cd packages/tui
|
|
||||||
$ go generate ./pkg/client/
|
|
||||||
```
|
|
||||||
|
|
||||||
This updates the generated Go client code that the TUI uses to communicate with the backend server.
|
|
||||||
|
|
||||||
### FAQ
|
### FAQ
|
||||||
|
|
||||||
@@ -72,10 +70,6 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
|
|||||||
- 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 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.
|
- 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.
|
||||||
|
|
||||||
#### What about Windows support?
|
|
||||||
|
|
||||||
There are some minor problems blocking opencode from working on windows. We are working on on them now. You'll need to use WSL for now.
|
|
||||||
|
|
||||||
#### What's the other repo?
|
#### What's the other repo?
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 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) |
|
||||||
@@ -19,7 +19,10 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "0.0.0",
|
"version": "0.0.5",
|
||||||
|
"bin": {
|
||||||
|
"opencode": "./bin/opencode",
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "0.11.0",
|
"@clack/prompts": "0.11.0",
|
||||||
"@flystorage/file-storage": "1.1.0",
|
"@flystorage/file-storage": "1.1.0",
|
||||||
@@ -33,6 +36,7 @@
|
|||||||
"env-paths": "3.0.0",
|
"env-paths": "3.0.0",
|
||||||
"hono": "4.7.10",
|
"hono": "4.7.10",
|
||||||
"hono-openapi": "0.4.8",
|
"hono-openapi": "0.4.8",
|
||||||
|
"isomorphic-git": "1.32.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"remeda": "2.22.3",
|
"remeda": "2.22.3",
|
||||||
"ts-lsp-client": "1.0.3",
|
"ts-lsp-client": "1.0.3",
|
||||||
@@ -78,7 +82,7 @@
|
|||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"shiki": "3.4.2",
|
"shiki": "3.4.2",
|
||||||
"solid-js": "1.9.7",
|
"solid-js": "1.9.7",
|
||||||
"toolbeam-docs-theme": "0.3.0",
|
"toolbeam-docs-theme": "0.4.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
@@ -458,7 +462,7 @@
|
|||||||
|
|
||||||
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
|
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="],
|
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
|
||||||
|
|
||||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||||
|
|
||||||
@@ -538,6 +542,8 @@
|
|||||||
|
|
||||||
"astro-expressive-code": ["astro-expressive-code@0.41.2", "", { "dependencies": { "rehype-expressive-code": "^0.41.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-HN0jWTnhr7mIV/2e6uu4PPRNNo/k4UEgTLZqbp3MrHU+caCARveG2yZxaZVBmxyiVdYqW5Pd3u3n2zjnshixbw=="],
|
"astro-expressive-code": ["astro-expressive-code@0.41.2", "", { "dependencies": { "rehype-expressive-code": "^0.41.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-HN0jWTnhr7mIV/2e6uu4PPRNNo/k4UEgTLZqbp3MrHU+caCARveG2yZxaZVBmxyiVdYqW5Pd3u3n2zjnshixbw=="],
|
||||||
|
|
||||||
|
"async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="],
|
||||||
|
|
||||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||||
|
|
||||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||||
@@ -596,7 +602,7 @@
|
|||||||
|
|
||||||
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
|
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="],
|
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
|
||||||
|
|
||||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||||
|
|
||||||
@@ -630,6 +636,8 @@
|
|||||||
|
|
||||||
"ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="],
|
"ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="],
|
||||||
|
|
||||||
|
"clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="],
|
||||||
|
|
||||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||||
|
|
||||||
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||||
@@ -666,6 +674,8 @@
|
|||||||
|
|
||||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||||
|
|
||||||
|
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
||||||
|
|
||||||
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
||||||
|
|
||||||
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
|
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
|
||||||
@@ -722,6 +732,8 @@
|
|||||||
|
|
||||||
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
|
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
|
||||||
|
|
||||||
|
"diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="],
|
||||||
|
|
||||||
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
|
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
|
||||||
|
|
||||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||||
@@ -936,6 +948,8 @@
|
|||||||
|
|
||||||
"ieee754": ["ieee754@1.1.13", "", {}, "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="],
|
"ieee754": ["ieee754@1.1.13", "", {}, "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="],
|
||||||
|
|
||||||
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
|
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
|
||||||
|
|
||||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
@@ -984,6 +998,8 @@
|
|||||||
|
|
||||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||||
|
|
||||||
|
"isomorphic-git": ["isomorphic-git@1.32.1", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "path-browserify": "^1.0.1", "pify": "^4.0.1", "readable-stream": "^3.4.0", "sha.js": "^2.4.9", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-NZCS7qpLkCZ1M/IrujYBD31sM6pd/fMVArK4fz4I7h6m0rUW2AsYU7S7zXeABuHL6HIfW6l53b4UQ/K441CQjg=="],
|
||||||
|
|
||||||
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
|
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
|
||||||
|
|
||||||
"jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
"jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
||||||
@@ -1166,6 +1182,8 @@
|
|||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
|
"minimisted": ["minimisted@2.0.1", "", { "dependencies": { "minimist": "^1.2.5" } }, "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA=="],
|
||||||
|
|
||||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||||
|
|
||||||
"mri": ["mri@1.1.4", "", {}, "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w=="],
|
"mri": ["mri@1.1.4", "", {}, "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w=="],
|
||||||
@@ -1244,7 +1262,7 @@
|
|||||||
|
|
||||||
"pagefind": ["pagefind@1.3.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.3.0", "@pagefind/darwin-x64": "1.3.0", "@pagefind/linux-arm64": "1.3.0", "@pagefind/linux-x64": "1.3.0", "@pagefind/windows-x64": "1.3.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw=="],
|
"pagefind": ["pagefind@1.3.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.3.0", "@pagefind/darwin-x64": "1.3.0", "@pagefind/linux-arm64": "1.3.0", "@pagefind/linux-x64": "1.3.0", "@pagefind/windows-x64": "1.3.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw=="],
|
||||||
|
|
||||||
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||||
|
|
||||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||||
|
|
||||||
@@ -1254,6 +1272,8 @@
|
|||||||
|
|
||||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
|
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
@@ -1264,6 +1284,8 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||||
|
|
||||||
|
"pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
|
||||||
|
|
||||||
"pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="],
|
"pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="],
|
||||||
|
|
||||||
"pino-abstract-transport": ["pino-abstract-transport@0.5.0", "", { "dependencies": { "duplexify": "^4.1.2", "split2": "^4.0.0" } }, "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ=="],
|
"pino-abstract-transport": ["pino-abstract-transport@0.5.0", "", { "dependencies": { "duplexify": "^4.1.2", "split2": "^4.0.0" } }, "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ=="],
|
||||||
@@ -1414,6 +1436,8 @@
|
|||||||
|
|
||||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||||
|
|
||||||
|
"sha.js": ["sha.js@2.4.11", "", { "dependencies": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" }, "bin": { "sha.js": "./bin.js" } }, "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ=="],
|
||||||
|
|
||||||
"sharp": ["sharp@0.32.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ=="],
|
"sharp": ["sharp@0.32.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ=="],
|
||||||
|
|
||||||
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
|
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
|
||||||
@@ -1522,7 +1546,7 @@
|
|||||||
|
|
||||||
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
|
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
|
||||||
|
|
||||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="],
|
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.1", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-lTI4dHZaVNQky29m7sb36Oy4tWPwxsCuFxFjF8hgGW0vpV+S6qPvI9SwsJFvdE/OHO5DoI7VMbryV1pxZHkkHQ=="],
|
||||||
|
|
||||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
@@ -1790,6 +1814,8 @@
|
|||||||
|
|
||||||
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
|
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||||
|
|
||||||
"unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
"unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="],
|
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="],
|
||||||
|
|||||||
+17
-1
@@ -1,3 +1,19 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json"
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"experimental": {
|
||||||
|
"hook": {
|
||||||
|
"file_edited": {
|
||||||
|
".json": [
|
||||||
|
{
|
||||||
|
"command": ["bun", "run", "prettier", "$FILE"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"session_completed": [
|
||||||
|
{
|
||||||
|
"command": ["touch", "./node_modules/foo"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ export class SyncServer extends DurableObject<Env> {
|
|||||||
ws.close(code, "Durable Object is closing WebSocket")
|
ws.close(code, "Durable Object is closing WebSocket")
|
||||||
}
|
}
|
||||||
|
|
||||||
async publish(secret: string, key: string, content: any) {
|
async publish(key: string, content: any) {
|
||||||
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
|
|
||||||
const sessionID = await this.getSessionID()
|
const sessionID = await this.getSessionID()
|
||||||
if (
|
if (
|
||||||
!key.startsWith(`session/info/${sessionID}`) &&
|
!key.startsWith(`session/info/${sessionID}`) &&
|
||||||
@@ -76,6 +75,10 @@ export class SyncServer extends DurableObject<Env> {
|
|||||||
.map(([key, content]) => ({ key, content }))
|
.map(([key, content]) => ({ key, content }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async assertSecret(secret: string) {
|
||||||
|
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
|
||||||
|
}
|
||||||
|
|
||||||
private async getSecret() {
|
private async getSecret() {
|
||||||
return this.ctx.storage.get<string>("secret")
|
return this.ctx.storage.get<string>("secret")
|
||||||
}
|
}
|
||||||
@@ -84,15 +87,19 @@ export class SyncServer extends DurableObject<Env> {
|
|||||||
return this.ctx.storage.get<string>("sessionID")
|
return this.ctx.storage.get<string>("sessionID")
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(secret: string) {
|
async clear() {
|
||||||
await this.assertSecret(secret)
|
const sessionID = await this.getSessionID()
|
||||||
|
const list = await this.env.Bucket.list({
|
||||||
|
prefix: `session/message/${sessionID}/`,
|
||||||
|
limit: 1000,
|
||||||
|
})
|
||||||
|
for (const item of list.objects) {
|
||||||
|
await this.env.Bucket.delete(item.key)
|
||||||
|
}
|
||||||
|
await this.env.Bucket.delete(`session/info/${sessionID}`)
|
||||||
await this.ctx.storage.deleteAll()
|
await this.ctx.storage.deleteAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertSecret(secret: string) {
|
|
||||||
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
static shortName(id: string) {
|
static shortName(id: string) {
|
||||||
return id.substring(id.length - 8)
|
return id.substring(id.length - 8)
|
||||||
}
|
}
|
||||||
@@ -134,7 +141,17 @@ export default {
|
|||||||
const secret = body.secret
|
const secret = body.secret
|
||||||
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
|
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
|
||||||
const stub = env.SYNC_SERVER.get(id)
|
const stub = env.SYNC_SERVER.get(id)
|
||||||
await stub.clear(secret)
|
await stub.assertSecret(secret)
|
||||||
|
await stub.clear()
|
||||||
|
return new Response(JSON.stringify({}), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && method === "share_delete_admin") {
|
||||||
|
const id = env.SYNC_SERVER.idFromName("oVF8Rsiv")
|
||||||
|
const stub = env.SYNC_SERVER.get(id)
|
||||||
|
await stub.clear()
|
||||||
return new Response(JSON.stringify({}), {
|
return new Response(JSON.stringify({}), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
})
|
||||||
@@ -150,7 +167,8 @@ export default {
|
|||||||
const name = SyncServer.shortName(body.sessionID)
|
const name = SyncServer.shortName(body.sessionID)
|
||||||
const id = env.SYNC_SERVER.idFromName(name)
|
const id = env.SYNC_SERVER.idFromName(name)
|
||||||
const stub = env.SYNC_SERVER.get(id)
|
const stub = env.SYNC_SERVER.get(id)
|
||||||
await stub.publish(body.secret, body.key, body.content)
|
await stub.assertSecret(body.secret)
|
||||||
|
await stub.publish(body.key, body.content)
|
||||||
return new Response(JSON.stringify({}), {
|
return new Response(JSON.stringify({}), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+8
-8
@@ -6,20 +6,20 @@
|
|||||||
import "sst"
|
import "sst"
|
||||||
declare module "sst" {
|
declare module "sst" {
|
||||||
export interface Resource {
|
export interface Resource {
|
||||||
"Web": {
|
Web: {
|
||||||
"type": "sst.cloudflare.Astro"
|
type: "sst.cloudflare.Astro"
|
||||||
"url": string
|
url: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// cloudflare
|
// cloudflare
|
||||||
import * as cloudflare from "@cloudflare/workers-types";
|
import * as cloudflare from "@cloudflare/workers-types"
|
||||||
declare module "sst" {
|
declare module "sst" {
|
||||||
export interface Resource {
|
export interface Resource {
|
||||||
"Api": cloudflare.Service
|
Api: cloudflare.Service
|
||||||
"Bucket": cloudflare.R2Bucket
|
Bucket: cloudflare.R2Bucket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import "sst"
|
import "sst"
|
||||||
export {}
|
export {}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
- **Typecheck**: `bun run typecheck` (npm run typecheck)
|
- **Typecheck**: `bun run typecheck` (npm run typecheck)
|
||||||
- **Test**: `bun test` (runs all tests)
|
- **Test**: `bun test` (runs all tests)
|
||||||
- **Single test**: `bun test test/tool/tool.test.ts` (specific test file)
|
- **Single test**: `bun test test/tool/tool.test.ts` (specific test file)
|
||||||
- **API Client Generation**: `cd packages/tui && go generate ./pkg/client/` (after changes to server endpoints)
|
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
@@ -38,4 +37,4 @@
|
|||||||
- **Validation**: All inputs validated with Zod schemas
|
- **Validation**: All inputs validated with Zod schemas
|
||||||
- **Logging**: Use `Log.create({ service: "name" })` pattern
|
- **Logging**: Use `Log.create({ service: "name" })` pattern
|
||||||
- **Storage**: Use `Storage` namespace for persistence
|
- **Storage**: Use `Storage` namespace for persistence
|
||||||
- **API Client**: Go TUI communicates with TypeScript server via generated client. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, run `cd packages/tui && go generate ./pkg/client/` to update the Go client code and OpenAPI spec.
|
- **API Client**: Go TUI communicates with TypeScript server via stainless SDK. When adding/modifying server endpoints in `packages/opencode/src/server/server.ts`, ask the user to generate a new client SDK to proceed with client-side changes.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ else
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ -z "$resolved" ]; then
|
if [ -z "$resolved" ]; then
|
||||||
printf "It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
|
printf "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
if defined OPENCODE_BIN_PATH (
|
||||||
|
set "resolved=%OPENCODE_BIN_PATH%"
|
||||||
|
goto :execute
|
||||||
|
)
|
||||||
|
|
||||||
|
rem Get the directory of this script
|
||||||
|
set "script_dir=%~dp0"
|
||||||
|
set "script_dir=%script_dir:~0,-1%"
|
||||||
|
|
||||||
|
rem Detect platform and architecture
|
||||||
|
set "platform=win32"
|
||||||
|
|
||||||
|
rem Detect architecture
|
||||||
|
if "%PROCESSOR_ARCHITECTURE%"=="AMD64" (
|
||||||
|
set "arch=x64"
|
||||||
|
) else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
|
||||||
|
set "arch=arm64"
|
||||||
|
) else if "%PROCESSOR_ARCHITECTURE%"=="x86" (
|
||||||
|
set "arch=x86"
|
||||||
|
) else (
|
||||||
|
set "arch=x64"
|
||||||
|
)
|
||||||
|
|
||||||
|
set "name=opencode-!platform!-!arch!"
|
||||||
|
set "binary=opencode.exe"
|
||||||
|
|
||||||
|
rem Search for the binary starting from script location
|
||||||
|
set "resolved="
|
||||||
|
set "current_dir=%script_dir%"
|
||||||
|
|
||||||
|
:search_loop
|
||||||
|
set "candidate=%current_dir%\node_modules\%name%\bin\%binary%"
|
||||||
|
if exist "%candidate%" (
|
||||||
|
set "resolved=%candidate%"
|
||||||
|
goto :execute
|
||||||
|
)
|
||||||
|
|
||||||
|
rem Move up one directory
|
||||||
|
for %%i in ("%current_dir%") do set "parent_dir=%%~dpi"
|
||||||
|
set "parent_dir=%parent_dir:~0,-1%"
|
||||||
|
|
||||||
|
rem Check if we've reached the root
|
||||||
|
if "%current_dir%"=="%parent_dir%" goto :not_found
|
||||||
|
set "current_dir=%parent_dir%"
|
||||||
|
goto :search_loop
|
||||||
|
|
||||||
|
:not_found
|
||||||
|
echo It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "%name%" package >&2
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
rem Execute the binary with all arguments
|
||||||
|
"%resolved%" %*
|
||||||
@@ -183,6 +183,9 @@
|
|||||||
"temperature": {
|
"temperature": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"tool_call": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"cost": {
|
"cost": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -199,10 +202,7 @@
|
|||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["input", "output"],
|
||||||
"input",
|
|
||||||
"output"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"limit": {
|
"limit": {
|
||||||
@@ -215,14 +215,15 @@
|
|||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["context", "output"],
|
||||||
"context",
|
|
||||||
"output"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -233,9 +234,7 @@
|
|||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["models"],
|
||||||
"models"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"description": "Custom provider configurations and model overrides"
|
"description": "Custom provider configurations and model overrides"
|
||||||
@@ -265,12 +264,13 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Environment variables to set when running the MCP server"
|
"description": "Environment variables to set when running the MCP server"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Enable or disable the MCP server on startup"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["type", "command"],
|
||||||
"type",
|
|
||||||
"command"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -284,19 +284,79 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "URL of the remote MCP server"
|
"description": "URL of the remote MCP server"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Enable or disable the MCP server on startup"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["type", "url"],
|
||||||
"type",
|
|
||||||
"url"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "MCP (Model Context Protocol) server configurations"
|
"description": "MCP (Model Context Protocol) server configurations"
|
||||||
|
},
|
||||||
|
"experimental": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"hook": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_edited": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["command"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session_completed": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["command"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "0.0.0",
|
"version": "0.0.5",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"dev": "bun run ./src/index.ts"
|
"dev": "bun run ./src/index.ts"
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"opencode": "./bin/opencode"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
},
|
},
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
"env-paths": "3.0.0",
|
"env-paths": "3.0.0",
|
||||||
"hono": "4.7.10",
|
"hono": "4.7.10",
|
||||||
"hono-openapi": "0.4.8",
|
"hono-openapi": "0.4.8",
|
||||||
|
"isomorphic-git": "1.32.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"remeda": "2.22.3",
|
"remeda": "2.22.3",
|
||||||
"ts-lsp-client": "1.0.3",
|
"ts-lsp-client": "1.0.3",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const targets = [
|
|||||||
["linux", "x64"],
|
["linux", "x64"],
|
||||||
["darwin", "x64"],
|
["darwin", "x64"],
|
||||||
["darwin", "arm64"],
|
["darwin", "arm64"],
|
||||||
// ["windows", "x64"],
|
["windows", "x64"],
|
||||||
]
|
]
|
||||||
|
|
||||||
await $`rm -rf dist`
|
await $`rm -rf dist`
|
||||||
@@ -110,6 +110,7 @@ if (!snapshot) {
|
|||||||
return (
|
return (
|
||||||
!lower.includes("ignore:") &&
|
!lower.includes("ignore:") &&
|
||||||
!lower.includes("ci:") &&
|
!lower.includes("ci:") &&
|
||||||
|
!lower.includes("wip:") &&
|
||||||
!lower.includes("docs:") &&
|
!lower.includes("docs:") &&
|
||||||
!lower.includes("doc:")
|
!lower.includes("doc:")
|
||||||
)
|
)
|
||||||
@@ -142,7 +143,7 @@ if (!snapshot) {
|
|||||||
"# Maintainer: dax",
|
"# Maintainer: dax",
|
||||||
"# Maintainer: adam",
|
"# Maintainer: adam",
|
||||||
"",
|
"",
|
||||||
"pkgname='opencode-bin'",
|
"pkgname='${pkg}'",
|
||||||
`pkgver=${version.split("-")[0]}`,
|
`pkgver=${version.split("-")[0]}`,
|
||||||
"options=('!debug' '!strip')",
|
"options=('!debug' '!strip')",
|
||||||
"pkgrel=1",
|
"pkgrel=1",
|
||||||
@@ -166,14 +167,17 @@ if (!snapshot) {
|
|||||||
"",
|
"",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
await $`rm -rf ./dist/aur-opencode-bin`
|
for (const pkg of ["opencode", "opencode-bin"]) {
|
||||||
|
await $`rm -rf ./dist/aur-${pkg}`
|
||||||
await $`git clone ssh://aur@aur.archlinux.org/opencode-bin.git ./dist/aur-opencode-bin`
|
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
|
||||||
await Bun.file("./dist/aur-opencode-bin/PKGBUILD").write(pkgbuild)
|
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(
|
||||||
await $`cd ./dist/aur-opencode-bin && makepkg --printsrcinfo > .SRCINFO`
|
pkgbuild.replace("${pkg}", pkg),
|
||||||
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO`
|
)
|
||||||
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${version}"`
|
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
|
||||||
if (!dry) await $`cd ./dist/aur-opencode-bin && git push`
|
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
|
||||||
|
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
|
||||||
|
if (!dry) await $`cd ./dist/aur-${pkg} && git push`
|
||||||
|
}
|
||||||
|
|
||||||
// Homebrew formula
|
// Homebrew formula
|
||||||
const homebrewFormula = [
|
const homebrewFormula = [
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "zod-openapi/extend"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Context } from "../util/context"
|
import { Context } from "../util/context"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
@@ -12,6 +13,7 @@ export namespace App {
|
|||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
user: z.string(),
|
user: z.string(),
|
||||||
|
hostname: z.string(),
|
||||||
git: z.boolean(),
|
git: z.boolean(),
|
||||||
path: z.object({
|
path: z.object({
|
||||||
config: z.string(),
|
config: z.string(),
|
||||||
@@ -25,15 +27,28 @@ export namespace App {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "App.Info",
|
ref: "App",
|
||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
const ctx = Context.create<Awaited<ReturnType<typeof create>>>("app")
|
const ctx = Context.create<{
|
||||||
|
info: Info
|
||||||
|
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
|
||||||
|
}>("app")
|
||||||
|
|
||||||
|
export const use = ctx.use
|
||||||
|
|
||||||
const APP_JSON = "app.json"
|
const APP_JSON = "app.json"
|
||||||
|
|
||||||
async function create(input: { cwd: string }) {
|
export type Input = {
|
||||||
|
cwd: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const provideExisting = ctx.provide
|
||||||
|
export async function provide<T>(
|
||||||
|
input: Input,
|
||||||
|
cb: (app: App.Info) => Promise<T>,
|
||||||
|
) {
|
||||||
log.info("creating", {
|
log.info("creating", {
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
})
|
})
|
||||||
@@ -45,7 +60,7 @@ export namespace App {
|
|||||||
const data = path.join(
|
const data = path.join(
|
||||||
Global.Path.data,
|
Global.Path.data,
|
||||||
"project",
|
"project",
|
||||||
git ? git.split(path.sep).join("-") : "global",
|
git ? directory(git) : "global",
|
||||||
)
|
)
|
||||||
const stateFile = Bun.file(path.join(data, APP_JSON))
|
const stateFile = Bun.file(path.join(data, APP_JSON))
|
||||||
const state = (await stateFile.json().catch(() => ({}))) as {
|
const state = (await stateFile.json().catch(() => ({}))) as {
|
||||||
@@ -61,8 +76,11 @@ export namespace App {
|
|||||||
}
|
}
|
||||||
>()
|
>()
|
||||||
|
|
||||||
|
const root = git ?? input.cwd
|
||||||
|
|
||||||
const info: Info = {
|
const info: Info = {
|
||||||
user: os.userInfo().username,
|
user: os.userInfo().username,
|
||||||
|
hostname: os.hostname(),
|
||||||
time: {
|
time: {
|
||||||
initialized: state.initialized,
|
initialized: state.initialized,
|
||||||
},
|
},
|
||||||
@@ -71,16 +89,27 @@ export namespace App {
|
|||||||
config: Global.Path.config,
|
config: Global.Path.config,
|
||||||
state: Global.Path.state,
|
state: Global.Path.state,
|
||||||
data,
|
data,
|
||||||
root: git ?? input.cwd,
|
root,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const result = {
|
const app = {
|
||||||
services,
|
services,
|
||||||
info,
|
info,
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return ctx.provide(app, async () => {
|
||||||
|
try {
|
||||||
|
const result = await cb(app.info)
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
for (const [key, entry] of app.services.entries()) {
|
||||||
|
if (!entry.shutdown) continue
|
||||||
|
log.info("shutdown", { name: key })
|
||||||
|
await entry.shutdown?.(await entry.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function state<State>(
|
export function state<State>(
|
||||||
@@ -106,22 +135,6 @@ export namespace App {
|
|||||||
return ctx.use().info
|
return ctx.use().info
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function provide<T>(
|
|
||||||
input: { cwd: string },
|
|
||||||
cb: (app: Info) => Promise<T>,
|
|
||||||
) {
|
|
||||||
const app = await create(input)
|
|
||||||
return ctx.provide(app, async () => {
|
|
||||||
const result = await cb(app.info)
|
|
||||||
for (const [key, entry] of app.services.entries()) {
|
|
||||||
if (!entry.shutdown) continue
|
|
||||||
log.info("shutdown", { name: key })
|
|
||||||
await entry.shutdown?.(await entry.state)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initialize() {
|
export async function initialize() {
|
||||||
const { info } = ctx.use()
|
const { info } = ctx.use()
|
||||||
info.time.initialized = Date.now()
|
info.time.initialized = Date.now()
|
||||||
@@ -132,4 +145,12 @@ export namespace App {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function directory(input: string): string {
|
||||||
|
return input
|
||||||
|
.split(path.sep)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("-")
|
||||||
|
.replace(/[^A-Za-z0-9_]/g, "-")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Global } from "../global"
|
||||||
|
import { lazy } from "../util/lazy"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export const AuthCopilot = lazy(async () => {
|
||||||
|
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
|
||||||
|
const response = fetch(
|
||||||
|
"https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts",
|
||||||
|
)
|
||||||
|
.then((x) => Bun.write(file, x))
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
if (!file.exists()) {
|
||||||
|
const worked = await response
|
||||||
|
if (!worked) return
|
||||||
|
}
|
||||||
|
const result = await import(file.name!).catch(() => {})
|
||||||
|
if (!result) return
|
||||||
|
return result.AuthCopilot
|
||||||
|
})
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { Auth } from "./index"
|
||||||
|
import { NamedError } from "../util/error"
|
||||||
|
|
||||||
|
export namespace AuthGithubCopilot {
|
||||||
|
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
|
||||||
|
const DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||||
|
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||||
|
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
|
||||||
|
|
||||||
|
interface DeviceCodeResponse {
|
||||||
|
device_code: string
|
||||||
|
user_code: string
|
||||||
|
verification_uri: string
|
||||||
|
expires_in: number
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccessTokenResponse {
|
||||||
|
access_token?: string
|
||||||
|
error?: string
|
||||||
|
error_description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CopilotTokenResponse {
|
||||||
|
token: string
|
||||||
|
expires_at: number
|
||||||
|
refresh_in: number
|
||||||
|
endpoints: {
|
||||||
|
api: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authorize() {
|
||||||
|
const deviceResponse = await fetch(DEVICE_CODE_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
scope: "read:user",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const deviceData: DeviceCodeResponse = await deviceResponse.json()
|
||||||
|
return {
|
||||||
|
device: deviceData.device_code,
|
||||||
|
user: deviceData.user_code,
|
||||||
|
verification: deviceData.verification_uri,
|
||||||
|
interval: deviceData.interval || 5,
|
||||||
|
expiry: deviceData.expires_in,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function poll(device_code: string) {
|
||||||
|
const response = await fetch(ACCESS_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
device_code,
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) return "failed"
|
||||||
|
|
||||||
|
const data: AccessTokenResponse = await response.json()
|
||||||
|
|
||||||
|
if (data.access_token) {
|
||||||
|
// Store the GitHub OAuth token
|
||||||
|
await Auth.set("github-copilot", {
|
||||||
|
type: "oauth",
|
||||||
|
refresh: data.access_token,
|
||||||
|
access: "",
|
||||||
|
expires: 0,
|
||||||
|
})
|
||||||
|
return "complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error === "authorization_pending") return "pending"
|
||||||
|
|
||||||
|
if (data.error) return "failed"
|
||||||
|
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function access() {
|
||||||
|
const info = await Auth.get("github-copilot")
|
||||||
|
if (!info || info.type !== "oauth") return
|
||||||
|
if (info.access && info.expires > Date.now()) return info.access
|
||||||
|
|
||||||
|
// Get new Copilot API token
|
||||||
|
const response = await fetch(COPILOT_API_KEY_URL, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${info.refresh}`,
|
||||||
|
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||||
|
"Editor-Version": "vscode/1.99.3",
|
||||||
|
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) return
|
||||||
|
|
||||||
|
const tokenData: CopilotTokenResponse = await response.json()
|
||||||
|
|
||||||
|
// Store the Copilot API token
|
||||||
|
await Auth.set("github-copilot", {
|
||||||
|
type: "oauth",
|
||||||
|
refresh: info.refresh,
|
||||||
|
access: tokenData.token,
|
||||||
|
expires: tokenData.expires_at * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return tokenData.token
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeviceCodeError = NamedError.create(
|
||||||
|
"DeviceCodeError",
|
||||||
|
z.object({}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const TokenExchangeError = NamedError.create(
|
||||||
|
"TokenExchangeError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const AuthenticationError = NamedError.create(
|
||||||
|
"AuthenticationError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const CopilotTokenError = NamedError.create(
|
||||||
|
"CopilotTokenError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Global } from "../global"
|
|||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
|
import { readableStreamToText } from "bun"
|
||||||
|
|
||||||
export namespace BunProc {
|
export namespace BunProc {
|
||||||
const log = Log.create({ service: "bun" })
|
const log = Log.create({ service: "bun" })
|
||||||
@@ -13,7 +14,7 @@ export namespace BunProc {
|
|||||||
) {
|
) {
|
||||||
log.info("running", {
|
log.info("running", {
|
||||||
cmd: [which(), ...cmd],
|
cmd: [which(), ...cmd],
|
||||||
options,
|
...options,
|
||||||
})
|
})
|
||||||
const result = Bun.spawn([which(), ...cmd], {
|
const result = Bun.spawn([which(), ...cmd], {
|
||||||
...options,
|
...options,
|
||||||
@@ -25,7 +26,14 @@ export namespace BunProc {
|
|||||||
BUN_BE_BUN: "1",
|
BUN_BE_BUN: "1",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const code = await result.exited
|
const code = await result.exited;
|
||||||
|
const stdout = result.stdout ? typeof result.stdout === "number" ? result.stdout : await readableStreamToText(result.stdout) : undefined
|
||||||
|
const stderr = result.stderr ? typeof result.stderr === "number" ? result.stderr : await readableStreamToText(result.stderr) : undefined
|
||||||
|
log.info("done", {
|
||||||
|
code,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
})
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
throw new Error(`Command failed with exit code ${result.exitCode}`)
|
throw new Error(`Command failed with exit code ${result.exitCode}`)
|
||||||
}
|
}
|
||||||
@@ -53,10 +61,10 @@ export namespace BunProc {
|
|||||||
if (parsed.dependencies[pkg] === version) return mod
|
if (parsed.dependencies[pkg] === version) return mod
|
||||||
parsed.dependencies[pkg] = version
|
parsed.dependencies[pkg] = version
|
||||||
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
|
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
|
||||||
await BunProc.run(["install"], {
|
await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
|
||||||
cwd: Global.Path.cache,
|
cwd: Global.Path.cache,
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
new InstallFailedError(
|
throw new InstallFailedError(
|
||||||
{ pkg, version },
|
{ pkg, version },
|
||||||
{
|
{
|
||||||
cause: e,
|
cause: e,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export namespace Bus {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publish<Definition extends EventDefinition>(
|
export async function publish<Definition extends EventDefinition>(
|
||||||
def: Definition,
|
def: Definition,
|
||||||
properties: z.output<Definition["properties"]>,
|
properties: z.output<Definition["properties"]>,
|
||||||
) {
|
) {
|
||||||
@@ -60,12 +60,14 @@ export namespace Bus {
|
|||||||
log.info("publishing", {
|
log.info("publishing", {
|
||||||
type: def.type,
|
type: def.type,
|
||||||
})
|
})
|
||||||
|
const pending = []
|
||||||
for (const key of [def.type, "*"]) {
|
for (const key of [def.type, "*"]) {
|
||||||
const match = state().subscriptions.get(key)
|
const match = state().subscriptions.get(key)
|
||||||
for (const sub of match ?? []) {
|
for (const sub of match ?? []) {
|
||||||
sub(payload)
|
pending.push(sub(payload))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return Promise.all(pending)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribe<Definition extends EventDefinition>(
|
export function subscribe<Definition extends EventDefinition>(
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { App } from "../app/app"
|
||||||
|
import { ConfigHooks } from "../config/hooks"
|
||||||
|
import { FileWatcher } from "../file/watch"
|
||||||
|
import { Format } from "../format"
|
||||||
|
import { LSP } from "../lsp"
|
||||||
|
import { Share } from "../share/share"
|
||||||
|
|
||||||
|
export async function bootstrap<T>(
|
||||||
|
input: App.Input,
|
||||||
|
cb: (app: App.Info) => Promise<T>,
|
||||||
|
) {
|
||||||
|
return App.provide(input, async (app) => {
|
||||||
|
Share.init()
|
||||||
|
Format.init()
|
||||||
|
ConfigHooks.init()
|
||||||
|
LSP.init()
|
||||||
|
FileWatcher.init()
|
||||||
|
|
||||||
|
return cb(app)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AuthAnthropic } from "../../auth/anthropic"
|
import { AuthAnthropic } from "../../auth/anthropic"
|
||||||
|
import { AuthCopilot } from "../../auth/copilot"
|
||||||
import { Auth } from "../../auth"
|
import { Auth } from "../../auth"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
@@ -6,17 +7,20 @@ import open from "open"
|
|||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { ModelsDev } from "../../provider/models"
|
import { ModelsDev } from "../../provider/models"
|
||||||
import { map, pipe, sortBy, values } from "remeda"
|
import { map, pipe, sortBy, values } from "remeda"
|
||||||
|
import path from "path"
|
||||||
|
import os from "os"
|
||||||
|
import { Global } from "../../global"
|
||||||
|
|
||||||
export const AuthCommand = cmd({
|
export const AuthCommand = cmd({
|
||||||
command: "auth",
|
command: "auth",
|
||||||
describe: "Manage credentials",
|
describe: "manage credentials",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs
|
yargs
|
||||||
.command(AuthLoginCommand)
|
.command(AuthLoginCommand)
|
||||||
.command(AuthLogoutCommand)
|
.command(AuthLogoutCommand)
|
||||||
.command(AuthListCommand)
|
.command(AuthListCommand)
|
||||||
.demandCommand(),
|
.demandCommand(),
|
||||||
async handler() { },
|
async handler() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const AuthListCommand = cmd({
|
export const AuthListCommand = cmd({
|
||||||
@@ -25,30 +29,61 @@ export const AuthListCommand = cmd({
|
|||||||
describe: "list providers",
|
describe: "list providers",
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Credentials")
|
const authPath = path.join(Global.Path.data, "auth.json")
|
||||||
|
const homedir = os.homedir()
|
||||||
|
const displayPath = authPath.startsWith(homedir)
|
||||||
|
? authPath.replace(homedir, "~")
|
||||||
|
: authPath
|
||||||
|
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||||
const results = await Auth.all().then((x) => Object.entries(x))
|
const results = await Auth.all().then((x) => Object.entries(x))
|
||||||
const database = await ModelsDev.get()
|
const database = await ModelsDev.get()
|
||||||
|
|
||||||
for (const [providerID, result] of results) {
|
for (const [providerID, result] of results) {
|
||||||
const name = database[providerID]?.name || providerID
|
const name = database[providerID]?.name || providerID
|
||||||
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}(${result.type})`)
|
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts.outro(`${results.length} credentials`)
|
prompts.outro(`${results.length} credentials`)
|
||||||
|
|
||||||
|
// Environment variables section
|
||||||
|
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||||
|
|
||||||
|
for (const [providerID, provider] of Object.entries(database)) {
|
||||||
|
for (const envVar of provider.env) {
|
||||||
|
if (process.env[envVar]) {
|
||||||
|
activeEnvVars.push({
|
||||||
|
provider: provider.name || providerID,
|
||||||
|
envVar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeEnvVars.length > 0) {
|
||||||
|
UI.empty()
|
||||||
|
prompts.intro("Environment")
|
||||||
|
|
||||||
|
for (const { provider, envVar } of activeEnvVars) {
|
||||||
|
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts.outro(`${activeEnvVars.length} environment variables`)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const AuthLoginCommand = cmd({
|
export const AuthLoginCommand = cmd({
|
||||||
command: "login",
|
command: "login",
|
||||||
describe: "login to a provider",
|
describe: "log in to a provider",
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Add credential")
|
prompts.intro("Add credential")
|
||||||
const providers = await ModelsDev.get()
|
const providers = await ModelsDev.get()
|
||||||
const priority: Record<string, number> = {
|
const priority: Record<string, number> = {
|
||||||
anthropic: 0,
|
anthropic: 0,
|
||||||
openai: 1,
|
"github-copilot": 1,
|
||||||
google: 2,
|
openai: 2,
|
||||||
|
google: 3,
|
||||||
}
|
}
|
||||||
let provider = await prompts.select({
|
let provider = await prompts.select({
|
||||||
message: "Select provider",
|
message: "Select provider",
|
||||||
@@ -146,6 +181,44 @@ export const AuthLoginCommand = cmd({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copilot = await AuthCopilot()
|
||||||
|
if (provider === "github-copilot" && copilot) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
const deviceInfo = await copilot.authorize()
|
||||||
|
|
||||||
|
prompts.note(
|
||||||
|
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const spinner = prompts.spinner()
|
||||||
|
spinner.start("Waiting for authorization...")
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, deviceInfo.interval * 1000),
|
||||||
|
)
|
||||||
|
const response = await copilot.poll(deviceInfo.device)
|
||||||
|
if (response.status === "pending") continue
|
||||||
|
if (response.status === "success") {
|
||||||
|
await Auth.set("github-copilot", {
|
||||||
|
type: "oauth",
|
||||||
|
refresh: response.refresh,
|
||||||
|
access: response.access,
|
||||||
|
expires: response.expires,
|
||||||
|
})
|
||||||
|
spinner.stop("Login successful")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (response.status === "failed") {
|
||||||
|
spinner.stop("Failed to authorize", 1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts.outro("Done")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const key = await prompts.password({
|
const key = await prompts.password({
|
||||||
message: "Enter your API key",
|
message: "Enter your API key",
|
||||||
validate: (x) => (x.length > 0 ? undefined : "Required"),
|
validate: (x) => (x.length > 0 ? undefined : "Required"),
|
||||||
@@ -162,7 +235,7 @@ export const AuthLoginCommand = cmd({
|
|||||||
|
|
||||||
export const AuthLogoutCommand = cmd({
|
export const AuthLogoutCommand = cmd({
|
||||||
command: "logout",
|
command: "logout",
|
||||||
describe: "logout from a configured provider",
|
describe: "log out from a configured provider",
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
const credentials = await Auth.all().then((x) => Object.entries(x))
|
const credentials = await Auth.all().then((x) => Object.entries(x))
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { File } from "../../../file"
|
||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
|
const FileReadCommand = cmd({
|
||||||
|
command: "read <path>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("path", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
description: "File path to read",
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
const content = await File.read(args.path)
|
||||||
|
console.log(content)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const FileStatusCommand = cmd({
|
||||||
|
command: "status",
|
||||||
|
builder: (yargs) => yargs,
|
||||||
|
async handler() {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
const status = await File.status()
|
||||||
|
console.log(JSON.stringify(status, null, 2))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const FileCommand = cmd({
|
||||||
|
command: "file",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
|
||||||
|
async handler() {},
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
import { FileCommand } from "./file"
|
||||||
|
import { LSPCommand } from "./lsp"
|
||||||
|
import { RipgrepCommand } from "./ripgrep"
|
||||||
|
import { SnapshotCommand } from "./snapshot"
|
||||||
|
|
||||||
|
export const DebugCommand = cmd({
|
||||||
|
command: "debug",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.command(LSPCommand)
|
||||||
|
.command(RipgrepCommand)
|
||||||
|
.command(FileCommand)
|
||||||
|
.command(SnapshotCommand)
|
||||||
|
.command({
|
||||||
|
command: "wait",
|
||||||
|
async handler() {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, 1_000 * 60 * 60 * 24),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.demandCommand(),
|
||||||
|
async handler() {},
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { LSP } from "../../../lsp"
|
||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
import { Log } from "../../../util/log"
|
||||||
|
|
||||||
|
export const LSPCommand = cmd({
|
||||||
|
command: "lsp",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
|
||||||
|
async handler() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DiagnosticsCommand = cmd({
|
||||||
|
command: "diagnostics <file>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("file", { type: "string", demandOption: true }),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
await LSP.touchFile(args.file, true)
|
||||||
|
console.log(await LSP.diagnostics())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SymbolsCommand = cmd({
|
||||||
|
command: "symbols <query>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("query", { type: "string", demandOption: true }),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
await LSP.touchFile("./src/index.ts", true)
|
||||||
|
using _ = Log.Default.time("symbols")
|
||||||
|
const results = await LSP.workspaceSymbol(args.query)
|
||||||
|
console.log(JSON.stringify(results, null, 2))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { App } from "../../../app/app"
|
||||||
|
import { Ripgrep } from "../../../file/ripgrep"
|
||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
|
export const RipgrepCommand = cmd({
|
||||||
|
command: "rg",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.command(TreeCommand)
|
||||||
|
.command(FilesCommand)
|
||||||
|
.command(SearchCommand)
|
||||||
|
.demandCommand(),
|
||||||
|
async handler() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TreeCommand = cmd({
|
||||||
|
command: "tree",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.option("limit", {
|
||||||
|
type: "number",
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
const app = App.info()
|
||||||
|
console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const FilesCommand = cmd({
|
||||||
|
command: "files",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.option("query", {
|
||||||
|
type: "string",
|
||||||
|
description: "Filter files by query",
|
||||||
|
})
|
||||||
|
.option("glob", {
|
||||||
|
type: "string",
|
||||||
|
description: "Glob pattern to match files",
|
||||||
|
})
|
||||||
|
.option("limit", {
|
||||||
|
type: "number",
|
||||||
|
description: "Limit number of results",
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
const app = App.info()
|
||||||
|
const files = await Ripgrep.files({
|
||||||
|
cwd: app.path.cwd,
|
||||||
|
query: args.query,
|
||||||
|
glob: args.glob,
|
||||||
|
limit: args.limit,
|
||||||
|
})
|
||||||
|
console.log(files.join("\n"))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const SearchCommand = cmd({
|
||||||
|
command: "search <pattern>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.positional("pattern", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
description: "Search pattern",
|
||||||
|
})
|
||||||
|
.option("glob", {
|
||||||
|
type: "array",
|
||||||
|
description: "File glob patterns",
|
||||||
|
})
|
||||||
|
.option("limit", {
|
||||||
|
type: "number",
|
||||||
|
description: "Limit number of results",
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
const results = await Ripgrep.search({
|
||||||
|
cwd: process.cwd(),
|
||||||
|
pattern: args.pattern,
|
||||||
|
glob: args.glob as string[] | undefined,
|
||||||
|
limit: args.limit,
|
||||||
|
})
|
||||||
|
console.log(JSON.stringify(results, null, 2))
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Snapshot } from "../../../snapshot"
|
||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
|
export const SnapshotCommand = cmd({
|
||||||
|
command: "snapshot",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.command(SnapshotCreateCommand)
|
||||||
|
.command(SnapshotRestoreCommand)
|
||||||
|
.demandCommand(),
|
||||||
|
async handler() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SnapshotCreateCommand = cmd({
|
||||||
|
command: "create",
|
||||||
|
async handler() {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
const result = await Snapshot.create("test")
|
||||||
|
console.log(result)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SnapshotRestoreCommand = cmd({
|
||||||
|
command: "restore <commit>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("commit", {
|
||||||
|
type: "string",
|
||||||
|
description: "commit",
|
||||||
|
demandOption: true,
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
await Snapshot.restore("test", args.commit)
|
||||||
|
console.log("restored")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { AuthAnthropic } from "../../auth/anthropic"
|
|
||||||
import { UI } from "../ui"
|
|
||||||
|
|
||||||
// Example: https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=MdFtFgFap23AWDSN0oa3-eaKjQRFE4CaEhXx8M9fHZg&code_challenge_method=S256&state=rKLtaDzm88GSwekyEqdi0wXX-YqIr13tSzYymSzpvfs
|
|
||||||
|
|
||||||
export const LoginAnthropicCommand = {
|
|
||||||
command: "anthropic",
|
|
||||||
describe: "Login to Anthropic",
|
|
||||||
handler: async () => {
|
|
||||||
const { url, verifier } = await AuthAnthropic.authorize()
|
|
||||||
|
|
||||||
UI.println("Login to Anthropic")
|
|
||||||
UI.println("Open the following URL in your browser:")
|
|
||||||
UI.println(url)
|
|
||||||
UI.println("")
|
|
||||||
|
|
||||||
const code = await UI.input("Paste the authorization code here: ")
|
|
||||||
await AuthAnthropic.exchange(code, verifier)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { App } from "../../app/app"
|
||||||
|
import { Provider } from "../../provider/provider"
|
||||||
|
import { cmd } from "./cmd"
|
||||||
|
|
||||||
|
export const ModelsCommand = cmd({
|
||||||
|
command: "models",
|
||||||
|
describe: "list all available models",
|
||||||
|
handler: async () => {
|
||||||
|
await App.provide({ cwd: process.cwd() }, async () => {
|
||||||
|
const providers = await Provider.list()
|
||||||
|
|
||||||
|
for (const [providerID, provider] of Object.entries(providers)) {
|
||||||
|
for (const modelID of Object.keys(provider.info.models)) {
|
||||||
|
console.log(`${providerID}/${modelID}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import type { Argv } from "yargs"
|
import type { Argv } from "yargs"
|
||||||
import { App } from "../../app/app"
|
|
||||||
import { Bus } from "../../bus"
|
import { Bus } from "../../bus"
|
||||||
import { Provider } from "../../provider/provider"
|
import { Provider } from "../../provider/provider"
|
||||||
import { Session } from "../../session"
|
import { Session } from "../../session"
|
||||||
import { Share } from "../../share/share"
|
|
||||||
import { Message } from "../../session/message"
|
import { Message } from "../../session/message"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { Flag } from "../../flag/flag"
|
import { Flag } from "../../flag/flag"
|
||||||
import { Config } from "../../config/config"
|
import { Config } from "../../config/config"
|
||||||
|
import { bootstrap } from "../bootstrap"
|
||||||
|
|
||||||
const TOOL: Record<string, [string, string]> = {
|
const TOOL: Record<string, [string, string]> = {
|
||||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||||
@@ -25,7 +24,7 @@ const TOOL: Record<string, [string, string]> = {
|
|||||||
|
|
||||||
export const RunCommand = cmd({
|
export const RunCommand = cmd({
|
||||||
command: "run [message..]",
|
command: "run [message..]",
|
||||||
describe: "Run opencode with a message",
|
describe: "run opencode with a message",
|
||||||
builder: (yargs: Argv) => {
|
builder: (yargs: Argv) => {
|
||||||
return yargs
|
return yargs
|
||||||
.positional("message", {
|
.positional("message", {
|
||||||
@@ -36,12 +35,12 @@ export const RunCommand = cmd({
|
|||||||
})
|
})
|
||||||
.option("continue", {
|
.option("continue", {
|
||||||
alias: ["c"],
|
alias: ["c"],
|
||||||
describe: "Continue the last session",
|
describe: "continue the last session",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
})
|
})
|
||||||
.option("session", {
|
.option("session", {
|
||||||
alias: ["s"],
|
alias: ["s"],
|
||||||
describe: "Session ID to continue",
|
describe: "session id to continue",
|
||||||
type: "string",
|
type: "string",
|
||||||
})
|
})
|
||||||
.option("share", {
|
.option("share", {
|
||||||
@@ -51,115 +50,114 @@ export const RunCommand = cmd({
|
|||||||
.option("model", {
|
.option("model", {
|
||||||
type: "string",
|
type: "string",
|
||||||
alias: ["m"],
|
alias: ["m"],
|
||||||
describe: "Model to use in the format of provider/model",
|
describe: "model to use in the format of provider/model",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
const message = args.message.join(" ")
|
const message = args.message.join(" ")
|
||||||
await App.provide(
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
{
|
const session = await (async () => {
|
||||||
cwd: process.cwd(),
|
if (args.continue) {
|
||||||
},
|
const first = await Session.list().next()
|
||||||
async () => {
|
if (first.done) return
|
||||||
await Share.init()
|
return first.value
|
||||||
const session = await (async () => {
|
|
||||||
if (args.continue) {
|
|
||||||
const first = await Session.list().next()
|
|
||||||
if (first.done) return
|
|
||||||
return first.value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.session) return Session.get(args.session)
|
|
||||||
|
|
||||||
return Session.create()
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
UI.error("Session not found")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
UI.empty()
|
if (args.session) return Session.get(args.session)
|
||||||
UI.println(UI.logo())
|
|
||||||
UI.empty()
|
|
||||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
|
||||||
UI.empty()
|
|
||||||
|
|
||||||
const cfg = await Config.get()
|
return Session.create()
|
||||||
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
})()
|
||||||
await Session.share(session.id)
|
|
||||||
UI.println(
|
if (!session) {
|
||||||
UI.Style.TEXT_INFO_BOLD +
|
UI.error("Session not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPiped = !process.stdout.isTTY
|
||||||
|
|
||||||
|
UI.empty()
|
||||||
|
UI.println(UI.logo())
|
||||||
|
UI.empty()
|
||||||
|
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||||
|
UI.empty()
|
||||||
|
|
||||||
|
const cfg = await Config.get()
|
||||||
|
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
||||||
|
await Session.share(session.id)
|
||||||
|
UI.println(
|
||||||
|
UI.Style.TEXT_INFO_BOLD +
|
||||||
"~ https://opencode.ai/s/" +
|
"~ https://opencode.ai/s/" +
|
||||||
session.id.slice(-8),
|
session.id.slice(-8),
|
||||||
)
|
|
||||||
}
|
|
||||||
UI.empty()
|
|
||||||
|
|
||||||
const { providerID, modelID } = args.model
|
|
||||||
? Provider.parseModel(args.model)
|
|
||||||
: await Provider.defaultModel()
|
|
||||||
UI.println(
|
|
||||||
UI.Style.TEXT_NORMAL_BOLD + "@ ",
|
|
||||||
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
|
|
||||||
)
|
)
|
||||||
UI.empty()
|
}
|
||||||
|
UI.empty()
|
||||||
|
|
||||||
function printEvent(color: string, type: string, title: string) {
|
const { providerID, modelID } = args.model
|
||||||
UI.println(
|
? Provider.parseModel(args.model)
|
||||||
color + `|`,
|
: await Provider.defaultModel()
|
||||||
UI.Style.TEXT_NORMAL +
|
UI.println(
|
||||||
UI.Style.TEXT_DIM +
|
UI.Style.TEXT_NORMAL_BOLD + "@ ",
|
||||||
` ${type.padEnd(7, " ")}`,
|
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
|
||||||
"",
|
)
|
||||||
UI.Style.TEXT_NORMAL + title,
|
UI.empty()
|
||||||
)
|
|
||||||
|
function printEvent(color: string, type: string, title: string) {
|
||||||
|
UI.println(
|
||||||
|
color + `|`,
|
||||||
|
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
|
||||||
|
"",
|
||||||
|
UI.Style.TEXT_NORMAL + title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
|
||||||
|
if (evt.properties.sessionID !== session.id) return
|
||||||
|
const part = evt.properties.part
|
||||||
|
const message = await Session.getMessage(
|
||||||
|
evt.properties.sessionID,
|
||||||
|
evt.properties.messageID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
part.type === "tool-invocation" &&
|
||||||
|
part.toolInvocation.state === "result"
|
||||||
|
) {
|
||||||
|
const metadata = message.metadata.tool[part.toolInvocation.toolCallId]
|
||||||
|
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
|
||||||
|
part.toolInvocation.toolName,
|
||||||
|
UI.Style.TEXT_INFO_BOLD,
|
||||||
|
]
|
||||||
|
printEvent(color, tool, metadata?.title || "Unknown")
|
||||||
}
|
}
|
||||||
|
|
||||||
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
|
if (part.type === "text") {
|
||||||
if (evt.properties.sessionID !== session.id) return
|
if (part.text.includes("\n")) {
|
||||||
const part = evt.properties.part
|
UI.empty()
|
||||||
const message = await Session.getMessage(
|
UI.println(part.text)
|
||||||
evt.properties.sessionID,
|
UI.empty()
|
||||||
evt.properties.messageID,
|
return
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
part.type === "tool-invocation" &&
|
|
||||||
part.toolInvocation.state === "result"
|
|
||||||
) {
|
|
||||||
const metadata =
|
|
||||||
message.metadata.tool[part.toolInvocation.toolCallId]
|
|
||||||
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
|
|
||||||
part.toolInvocation.toolName,
|
|
||||||
UI.Style.TEXT_INFO_BOLD,
|
|
||||||
]
|
|
||||||
printEvent(color, tool, metadata.title)
|
|
||||||
}
|
}
|
||||||
|
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (part.type === "text") {
|
const result = await Session.chat({
|
||||||
if (part.text.includes("\n")) {
|
sessionID: session.id,
|
||||||
UI.empty()
|
providerID,
|
||||||
UI.println(part.text)
|
modelID,
|
||||||
UI.empty()
|
parts: [
|
||||||
return
|
{
|
||||||
}
|
type: "text",
|
||||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
text: message,
|
||||||
}
|
},
|
||||||
})
|
],
|
||||||
await Session.chat({
|
})
|
||||||
sessionID: session.id,
|
|
||||||
providerID,
|
if (isPiped) {
|
||||||
modelID,
|
const match = result.parts.findLast((x) => x.type === "text")
|
||||||
parts: [
|
if (match) process.stdout.write(match.text)
|
||||||
{
|
}
|
||||||
type: "text",
|
UI.empty()
|
||||||
text: message,
|
})
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
UI.empty()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { App } from "../../app/app"
|
|
||||||
import { LSP } from "../../lsp"
|
|
||||||
import { cmd } from "./cmd"
|
|
||||||
|
|
||||||
export const ScrapCommand = cmd({
|
|
||||||
command: "scrap <file>",
|
|
||||||
builder: (yargs) =>
|
|
||||||
yargs.positional("file", { type: "string", demandOption: true }),
|
|
||||||
async handler(args) {
|
|
||||||
await App.provide(
|
|
||||||
{ cwd: process.cwd() },
|
|
||||||
async () => {
|
|
||||||
await LSP.touchFile(args.file, true)
|
|
||||||
console.log(await LSP.diagnostics())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Provider } from "../../provider/provider"
|
||||||
|
import { Server } from "../../server/server"
|
||||||
|
import { Share } from "../../share/share"
|
||||||
|
import { bootstrap } from "../bootstrap"
|
||||||
|
import { cmd } from "./cmd"
|
||||||
|
|
||||||
|
export const ServeCommand = cmd({
|
||||||
|
command: "serve",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.option("port", {
|
||||||
|
alias: ["p"],
|
||||||
|
type: "number",
|
||||||
|
describe: "port to listen on",
|
||||||
|
default: 4096,
|
||||||
|
})
|
||||||
|
.option("hostname", {
|
||||||
|
alias: ["h"],
|
||||||
|
type: "string",
|
||||||
|
describe: "hostname to listen on",
|
||||||
|
default: "127.0.0.1",
|
||||||
|
}),
|
||||||
|
describe: "starts a headless opencode server",
|
||||||
|
handler: async (args) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
await bootstrap({ cwd }, async () => {
|
||||||
|
const providers = await Provider.list()
|
||||||
|
if (Object.keys(providers).length === 0) {
|
||||||
|
return "needs_provider"
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = args.hostname
|
||||||
|
const port = args.port
|
||||||
|
|
||||||
|
await Share.init()
|
||||||
|
const server = Server.listen({
|
||||||
|
port,
|
||||||
|
hostname,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`opencode server listening on http://${server.hostname}:${server.port}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await new Promise(() => {})
|
||||||
|
|
||||||
|
server.stop()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Global } from "../../global"
|
||||||
|
import { Provider } from "../../provider/provider"
|
||||||
|
import { Server } from "../../server/server"
|
||||||
|
import { bootstrap } from "../bootstrap"
|
||||||
|
import { UI } from "../ui"
|
||||||
|
import { cmd } from "./cmd"
|
||||||
|
import path from "path"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import { Installation } from "../../installation"
|
||||||
|
import { Config } from "../../config/config"
|
||||||
|
import { Bus } from "../../bus"
|
||||||
|
|
||||||
|
export const TuiCommand = cmd({
|
||||||
|
command: "$0 [project]",
|
||||||
|
describe: "start opencode tui",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("project", {
|
||||||
|
type: "string",
|
||||||
|
describe: "path to start opencode in",
|
||||||
|
}),
|
||||||
|
handler: async (args) => {
|
||||||
|
while (true) {
|
||||||
|
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
||||||
|
try {
|
||||||
|
process.chdir(cwd)
|
||||||
|
} catch (e) {
|
||||||
|
UI.error("Failed to change directory to " + cwd)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await bootstrap({ cwd }, async (app) => {
|
||||||
|
const providers = await Provider.list()
|
||||||
|
if (Object.keys(providers).length === 0) {
|
||||||
|
return "needs_provider"
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = Server.listen({
|
||||||
|
port: 0,
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
})
|
||||||
|
|
||||||
|
let cmd = ["go", "run", "./main.go"]
|
||||||
|
let cwd = Bun.fileURLToPath(
|
||||||
|
new URL("../../../../tui/cmd/opencode", import.meta.url),
|
||||||
|
)
|
||||||
|
if (Bun.embeddedFiles.length > 0) {
|
||||||
|
const blob = Bun.embeddedFiles[0] as File
|
||||||
|
let binaryName = blob.name
|
||||||
|
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
|
||||||
|
binaryName += ".exe"
|
||||||
|
}
|
||||||
|
const binary = path.join(Global.Path.cache, "tui", binaryName)
|
||||||
|
const file = Bun.file(binary)
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
await Bun.write(file, blob, { mode: 0o755 })
|
||||||
|
await fs.chmod(binary, 0o755)
|
||||||
|
}
|
||||||
|
cwd = process.cwd()
|
||||||
|
cmd = [binary]
|
||||||
|
}
|
||||||
|
const proc = Bun.spawn({
|
||||||
|
cmd: [...cmd, ...process.argv.slice(2)],
|
||||||
|
cwd,
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
stdin: "inherit",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
OPENCODE_SERVER: server.url.toString(),
|
||||||
|
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||||
|
},
|
||||||
|
onExit: () => {
|
||||||
|
server.stop()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
if (Installation.VERSION === "dev") return
|
||||||
|
if (Installation.isSnapshot()) return
|
||||||
|
const config = await Config.global()
|
||||||
|
if (config.autoupdate === false) return
|
||||||
|
const latest = await Installation.latest().catch(() => {})
|
||||||
|
if (!latest) return
|
||||||
|
if (Installation.VERSION === latest) return
|
||||||
|
const method = await Installation.method()
|
||||||
|
if (method === "unknown") return
|
||||||
|
await Installation.upgrade(method, latest)
|
||||||
|
.then(() => {
|
||||||
|
Bus.publish(Installation.Event.Updated, { version: latest })
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
})()
|
||||||
|
|
||||||
|
await proc.exited
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
return "done"
|
||||||
|
})
|
||||||
|
if (result === "done") break
|
||||||
|
if (result === "needs_provider") {
|
||||||
|
UI.empty()
|
||||||
|
UI.println(UI.logo(" "))
|
||||||
|
const result = await Bun.spawn({
|
||||||
|
cmd: [process.execPath, "auth", "login"],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
stdin: "inherit",
|
||||||
|
}).exited
|
||||||
|
if (result !== 0) return
|
||||||
|
UI.empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -5,19 +5,27 @@ import { Installation } from "../../installation"
|
|||||||
|
|
||||||
export const UpgradeCommand = {
|
export const UpgradeCommand = {
|
||||||
command: "upgrade [target]",
|
command: "upgrade [target]",
|
||||||
describe: "upgrade opencode to the latest version or a specific version",
|
describe: "upgrade opencode to the latest or a specific version",
|
||||||
builder: (yargs: Argv) => {
|
builder: (yargs: Argv) => {
|
||||||
return yargs.positional("target", {
|
return yargs
|
||||||
describe: "specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')",
|
.positional("target", {
|
||||||
type: "string",
|
describe: "version to upgrade to, for ex '0.1.48' or 'v0.1.48'",
|
||||||
})
|
type: "string",
|
||||||
|
})
|
||||||
|
.option("method", {
|
||||||
|
alias: "m",
|
||||||
|
describe: "installation method to use",
|
||||||
|
type: "string",
|
||||||
|
choices: ["curl", "npm", "pnpm", "bun", "brew"],
|
||||||
|
})
|
||||||
},
|
},
|
||||||
handler: async (args: { target?: string }) => {
|
handler: async (args: { target?: string; method?: string }) => {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
UI.println(UI.logo(" "))
|
UI.println(UI.logo(" "))
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Upgrade")
|
prompts.intro("Upgrade")
|
||||||
const method = await Installation.method()
|
const detectedMethod = await Installation.method()
|
||||||
|
const method = (args.method as Installation.Method) ?? detectedMethod
|
||||||
if (method === "unknown") {
|
if (method === "unknown") {
|
||||||
prompts.log.error(
|
prompts.log.error(
|
||||||
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
|
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
|
||||||
@@ -25,6 +33,7 @@ export const UpgradeCommand = {
|
|||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
prompts.log.info("Using method: " + method)
|
||||||
const target = args.target ?? (await Installation.latest())
|
const target = args.target ?? (await Installation.latest())
|
||||||
prompts.log.info(`From ${Installation.VERSION} → ${target}`)
|
prompts.log.info(`From ${Installation.VERSION} → ${target}`)
|
||||||
const spinner = prompts.spinner()
|
const spinner = prompts.spinner()
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
|
import { MCP } from "../mcp"
|
||||||
|
import { UI } from "./ui"
|
||||||
|
|
||||||
export function FormatError(input: unknown) {
|
export function FormatError(input: unknown) {
|
||||||
|
if (MCP.Failed.isInstance(input))
|
||||||
|
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||||
if (Config.JsonError.isInstance(input))
|
if (Config.JsonError.isInstance(input))
|
||||||
return `Config file at ${input.data.path} is not valid JSON`
|
return `Config file at ${input.data.path} is not valid JSON`
|
||||||
if (Config.InvalidError.isInstance(input))
|
if (Config.InvalidError.isInstance(input))
|
||||||
@@ -10,4 +14,6 @@ export function FormatError(input: unknown) {
|
|||||||
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
|
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
|
||||||
) ?? []),
|
) ?? []),
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
|
if (UI.CancelledError.isInstance(input)) return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import { EOL } from "os"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
|
|
||||||
export namespace UI {
|
export namespace UI {
|
||||||
@@ -29,7 +30,7 @@ export namespace UI {
|
|||||||
|
|
||||||
export function println(...message: string[]) {
|
export function println(...message: string[]) {
|
||||||
print(...message)
|
print(...message)
|
||||||
Bun.stderr.write("\n")
|
Bun.stderr.write(EOL)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function print(...message: string[]) {
|
export function print(...message: string[]) {
|
||||||
@@ -52,7 +53,7 @@ export namespace UI {
|
|||||||
result.push(row[0])
|
result.push(row[0])
|
||||||
result.push("\x1b[0m")
|
result.push("\x1b[0m")
|
||||||
result.push(row[1])
|
result.push(row[1])
|
||||||
result.push("\n")
|
result.push(EOL)
|
||||||
}
|
}
|
||||||
return result.join("").trimEnd()
|
return result.join("").trimEnd()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export namespace Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.info("loaded", result)
|
log.info("loaded", result)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,20 +37,28 @@ export namespace Config {
|
|||||||
.record(z.string(), z.string())
|
.record(z.string(), z.string())
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Environment variables to set when running the MCP server"),
|
.describe("Environment variables to set when running the MCP server"),
|
||||||
|
enabled: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("Enable or disable the MCP server on startup"),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Config.McpLocal",
|
ref: "McpLocalConfig",
|
||||||
})
|
})
|
||||||
|
|
||||||
export const McpRemote = z
|
export const McpRemote = z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("remote").describe("Type of MCP server connection"),
|
type: z.literal("remote").describe("Type of MCP server connection"),
|
||||||
url: z.string().describe("URL of the remote MCP server"),
|
url: z.string().describe("URL of the remote MCP server"),
|
||||||
|
enabled: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("Enable or disable the MCP server on startup"),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Config.McpRemote",
|
ref: "McpRemoteConfig",
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
||||||
@@ -123,7 +132,7 @@ export namespace Config {
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Config.Keybinds",
|
ref: "KeybindsConfig",
|
||||||
})
|
})
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
@@ -167,10 +176,36 @@ export namespace Config {
|
|||||||
.record(z.string(), Mcp)
|
.record(z.string(), Mcp)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("MCP (Model Context Protocol) server configurations"),
|
.describe("MCP (Model Context Protocol) server configurations"),
|
||||||
|
experimental: z
|
||||||
|
.object({
|
||||||
|
hook: z
|
||||||
|
.object({
|
||||||
|
file_edited: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
command: z.string().array(),
|
||||||
|
environment: z.record(z.string(), z.string()).optional(),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
session_completed: z
|
||||||
|
.object({
|
||||||
|
command: z.string().array(),
|
||||||
|
environment: z.record(z.string(), z.string()).optional(),
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Config.Info",
|
ref: "Config",
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Info = z.output<typeof Info>
|
export type Info = z.output<typeof Info>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { App } from "../app/app"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { File } from "../file"
|
||||||
|
import { Session } from "../session"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { Config } from "./config"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export namespace ConfigHooks {
|
||||||
|
const log = Log.create({ service: "config.hooks" })
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
log.info("init")
|
||||||
|
const app = App.info()
|
||||||
|
|
||||||
|
Bus.subscribe(File.Event.Edited, async (payload) => {
|
||||||
|
const cfg = await Config.get()
|
||||||
|
const ext = path.extname(payload.properties.file)
|
||||||
|
for (const item of cfg.experimental?.hook?.file_edited?.[ext] ?? []) {
|
||||||
|
log.info("file_edited", {
|
||||||
|
file: payload.properties.file,
|
||||||
|
command: item.command,
|
||||||
|
})
|
||||||
|
Bun.spawn({
|
||||||
|
cmd: item.command.map((x) =>
|
||||||
|
x.replace("$FILE", payload.properties.file),
|
||||||
|
),
|
||||||
|
env: item.environment,
|
||||||
|
cwd: app.path.cwd,
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Bus.subscribe(Session.Event.Idle, async () => {
|
||||||
|
const cfg = await Config.get()
|
||||||
|
if (cfg.experimental?.hook?.session_completed) {
|
||||||
|
for (const item of cfg.experimental.hook.session_completed) {
|
||||||
|
log.info("session_completed", {
|
||||||
|
command: item.command,
|
||||||
|
})
|
||||||
|
Bun.spawn({
|
||||||
|
cmd: item.command,
|
||||||
|
cwd: App.info().path.cwd,
|
||||||
|
env: item.environment,
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
-129
@@ -1,129 +0,0 @@
|
|||||||
import path from "path"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import fs from "fs/promises"
|
|
||||||
import { z } from "zod"
|
|
||||||
import { NamedError } from "../util/error"
|
|
||||||
import { lazy } from "../util/lazy"
|
|
||||||
import { $ } from "bun"
|
|
||||||
import { Fzf } from "./fzf"
|
|
||||||
|
|
||||||
export namespace Ripgrep {
|
|
||||||
const PLATFORM = {
|
|
||||||
darwin: { platform: "apple-darwin", extension: "tar.gz" },
|
|
||||||
linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
|
|
||||||
win32: { platform: "pc-windows-msvc", extension: "zip" },
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const ExtractionFailedError = NamedError.create(
|
|
||||||
"RipgrepExtractionFailedError",
|
|
||||||
z.object({
|
|
||||||
filepath: z.string(),
|
|
||||||
stderr: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const UnsupportedPlatformError = NamedError.create(
|
|
||||||
"RipgrepUnsupportedPlatformError",
|
|
||||||
z.object({
|
|
||||||
platform: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const DownloadFailedError = NamedError.create(
|
|
||||||
"RipgrepDownloadFailedError",
|
|
||||||
z.object({
|
|
||||||
url: z.string(),
|
|
||||||
status: z.number(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const state = lazy(async () => {
|
|
||||||
let filepath = Bun.which("rg")
|
|
||||||
if (filepath) return { filepath }
|
|
||||||
filepath = path.join(
|
|
||||||
Global.Path.bin,
|
|
||||||
"rg" + (process.platform === "win32" ? ".exe" : ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
const file = Bun.file(filepath)
|
|
||||||
if (!(await file.exists())) {
|
|
||||||
const archMap = { x64: "x86_64", arm64: "aarch64" } as const
|
|
||||||
const arch = archMap[process.arch as keyof typeof archMap] ?? process.arch
|
|
||||||
|
|
||||||
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
|
|
||||||
if (!config)
|
|
||||||
throw new UnsupportedPlatformError({ platform: process.platform })
|
|
||||||
|
|
||||||
const version = "14.1.1"
|
|
||||||
const filename = `ripgrep-${version}-${arch}-${config.platform}.${config.extension}`
|
|
||||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
|
|
||||||
|
|
||||||
const response = await fetch(url)
|
|
||||||
if (!response.ok)
|
|
||||||
throw new DownloadFailedError({ url, status: response.status })
|
|
||||||
|
|
||||||
const buffer = await response.arrayBuffer()
|
|
||||||
const archivePath = path.join(Global.Path.bin, filename)
|
|
||||||
await Bun.write(archivePath, buffer)
|
|
||||||
if (config.extension === "tar.gz") {
|
|
||||||
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
|
|
||||||
|
|
||||||
if (process.platform === "darwin") args.push("--include=*/rg")
|
|
||||||
if (process.platform === "linux") args.push("--wildcards", "*/rg")
|
|
||||||
|
|
||||||
const proc = Bun.spawn(args, {
|
|
||||||
cwd: Global.Path.bin,
|
|
||||||
stderr: "pipe",
|
|
||||||
stdout: "pipe",
|
|
||||||
})
|
|
||||||
await proc.exited
|
|
||||||
if (proc.exitCode !== 0)
|
|
||||||
throw new ExtractionFailedError({
|
|
||||||
filepath,
|
|
||||||
stderr: await Bun.readableStreamToText(proc.stderr),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (config.extension === "zip") {
|
|
||||||
const proc = Bun.spawn(
|
|
||||||
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
|
|
||||||
{
|
|
||||||
cwd: Global.Path.bin,
|
|
||||||
stderr: "pipe",
|
|
||||||
stdout: "ignore",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await proc.exited
|
|
||||||
if (proc.exitCode !== 0)
|
|
||||||
throw new ExtractionFailedError({
|
|
||||||
filepath: archivePath,
|
|
||||||
stderr: await Bun.readableStreamToText(proc.stderr),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await fs.unlink(archivePath)
|
|
||||||
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
filepath,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function filepath() {
|
|
||||||
const { filepath } = await state()
|
|
||||||
return filepath
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function files(input: {
|
|
||||||
cwd: string
|
|
||||||
query?: string
|
|
||||||
limit?: number
|
|
||||||
}) {
|
|
||||||
const commands = [`${await filepath()} --files --hidden --glob='!.git/*'`]
|
|
||||||
if (input.query)
|
|
||||||
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
|
|
||||||
if (input.limit) commands.push(`head -n ${input.limit}`)
|
|
||||||
const joined = commands.join(" | ")
|
|
||||||
const result = await $`${{ raw: joined }}`.cwd(input.cwd).text()
|
|
||||||
return result.split("\n").filter(Boolean)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import { z } from "zod"
|
|||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
import { lazy } from "../util/lazy"
|
import { lazy } from "../util/lazy"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { $ } from "bun"
|
|
||||||
|
|
||||||
export namespace Fzf {
|
export namespace Fzf {
|
||||||
const log = Log.create({ service: "fzf" })
|
const log = Log.create({ service: "fzf" })
|
||||||
@@ -115,24 +114,4 @@ export namespace Fzf {
|
|||||||
const { filepath } = await state()
|
const { filepath } = await state()
|
||||||
return filepath
|
return filepath
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function search(input: {
|
|
||||||
cwd: string
|
|
||||||
query: string
|
|
||||||
limit?: number
|
|
||||||
}) {
|
|
||||||
const results = await $`${await filepath()} --filter=${input.query}`
|
|
||||||
.quiet()
|
|
||||||
.throws(false)
|
|
||||||
.cwd(input.cwd)
|
|
||||||
.text()
|
|
||||||
const split = results
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
log.info("results", {
|
|
||||||
count: split.length,
|
|
||||||
})
|
|
||||||
return split
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { $ } from "bun"
|
||||||
|
import { createPatch } from "diff"
|
||||||
|
import path from "path"
|
||||||
|
import * as git from "isomorphic-git"
|
||||||
|
import { App } from "../app/app"
|
||||||
|
import fs from "fs"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
|
export namespace File {
|
||||||
|
const log = Log.create({ service: "file" })
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Edited: Bus.event(
|
||||||
|
"file.edited",
|
||||||
|
z.object({
|
||||||
|
file: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function status() {
|
||||||
|
const app = App.info()
|
||||||
|
if (!app.git) return []
|
||||||
|
|
||||||
|
const diffOutput = await $`git diff --numstat HEAD`
|
||||||
|
.cwd(app.path.cwd)
|
||||||
|
.quiet()
|
||||||
|
.nothrow()
|
||||||
|
.text()
|
||||||
|
|
||||||
|
const changedFiles = []
|
||||||
|
|
||||||
|
if (diffOutput.trim()) {
|
||||||
|
const lines = diffOutput.trim().split("\n")
|
||||||
|
for (const line of lines) {
|
||||||
|
const [added, removed, filepath] = line.split("\t")
|
||||||
|
changedFiles.push({
|
||||||
|
file: filepath,
|
||||||
|
added: added === "-" ? 0 : parseInt(added, 10),
|
||||||
|
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||||
|
status: "modified",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const untrackedOutput = await $`git ls-files --others --exclude-standard`
|
||||||
|
.cwd(app.path.cwd)
|
||||||
|
.quiet()
|
||||||
|
.nothrow()
|
||||||
|
.text()
|
||||||
|
|
||||||
|
if (untrackedOutput.trim()) {
|
||||||
|
const untrackedFiles = untrackedOutput.trim().split("\n")
|
||||||
|
for (const filepath of untrackedFiles) {
|
||||||
|
try {
|
||||||
|
const content = await Bun.file(
|
||||||
|
path.join(app.path.root, filepath),
|
||||||
|
).text()
|
||||||
|
const lines = content.split("\n").length
|
||||||
|
changedFiles.push({
|
||||||
|
file: filepath,
|
||||||
|
added: lines,
|
||||||
|
removed: 0,
|
||||||
|
status: "added",
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get deleted files
|
||||||
|
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
|
||||||
|
.cwd(app.path.cwd)
|
||||||
|
.quiet()
|
||||||
|
.nothrow()
|
||||||
|
.text()
|
||||||
|
|
||||||
|
if (deletedOutput.trim()) {
|
||||||
|
const deletedFiles = deletedOutput.trim().split("\n")
|
||||||
|
for (const filepath of deletedFiles) {
|
||||||
|
changedFiles.push({
|
||||||
|
file: filepath,
|
||||||
|
added: 0,
|
||||||
|
removed: 0, // Could get original line count but would require another git command
|
||||||
|
status: "deleted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changedFiles.map((x) => ({
|
||||||
|
...x,
|
||||||
|
file: path.relative(app.path.cwd, path.join(app.path.root, x.file)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function read(file: string) {
|
||||||
|
using _ = log.time("read", { file })
|
||||||
|
const app = App.info()
|
||||||
|
const full = path.join(app.path.cwd, file)
|
||||||
|
const content = await Bun.file(full)
|
||||||
|
.text()
|
||||||
|
.catch(() => "")
|
||||||
|
.then((x) => x.trim())
|
||||||
|
if (app.git) {
|
||||||
|
const rel = path.relative(app.path.root, full)
|
||||||
|
const diff = await git.status({
|
||||||
|
fs,
|
||||||
|
dir: app.path.root,
|
||||||
|
filepath: rel,
|
||||||
|
})
|
||||||
|
if (diff !== "unmodified") {
|
||||||
|
const original = await $`git show HEAD:${rel}`
|
||||||
|
.cwd(app.path.root)
|
||||||
|
.quiet()
|
||||||
|
.nothrow()
|
||||||
|
.text()
|
||||||
|
const patch = createPatch(file, original, content, "old", "new", {
|
||||||
|
context: Infinity,
|
||||||
|
})
|
||||||
|
return { type: "patch", content: patch }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: "raw", content }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
// Ripgrep utility functions
|
||||||
|
import path from "path"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { NamedError } from "../util/error"
|
||||||
|
import { lazy } from "../util/lazy"
|
||||||
|
import { $ } from "bun"
|
||||||
|
import { Fzf } from "./fzf"
|
||||||
|
|
||||||
|
export namespace Ripgrep {
|
||||||
|
const Stats = z.object({
|
||||||
|
elapsed: z.object({
|
||||||
|
secs: z.number(),
|
||||||
|
nanos: z.number(),
|
||||||
|
human: z.string(),
|
||||||
|
}),
|
||||||
|
searches: z.number(),
|
||||||
|
searches_with_match: z.number(),
|
||||||
|
bytes_searched: z.number(),
|
||||||
|
bytes_printed: z.number(),
|
||||||
|
matched_lines: z.number(),
|
||||||
|
matches: z.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Begin = z.object({
|
||||||
|
type: z.literal("begin"),
|
||||||
|
data: z.object({
|
||||||
|
path: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Match = z.object({
|
||||||
|
type: z.literal("match"),
|
||||||
|
data: z.object({
|
||||||
|
path: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
lines: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
line_number: z.number(),
|
||||||
|
absolute_offset: z.number(),
|
||||||
|
submatches: z.array(
|
||||||
|
z.object({
|
||||||
|
match: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
start: z.number(),
|
||||||
|
end: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const End = z.object({
|
||||||
|
type: z.literal("end"),
|
||||||
|
data: z.object({
|
||||||
|
path: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
binary_offset: z.number().nullable(),
|
||||||
|
stats: Stats,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Summary = z.object({
|
||||||
|
type: z.literal("summary"),
|
||||||
|
data: z.object({
|
||||||
|
elapsed_total: z.object({
|
||||||
|
human: z.string(),
|
||||||
|
nanos: z.number(),
|
||||||
|
secs: z.number(),
|
||||||
|
}),
|
||||||
|
stats: Stats,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Result = z.union([Begin, Match, End, Summary])
|
||||||
|
|
||||||
|
export type Result = z.infer<typeof Result>
|
||||||
|
export type Match = z.infer<typeof Match>
|
||||||
|
export type Begin = z.infer<typeof Begin>
|
||||||
|
export type End = z.infer<typeof End>
|
||||||
|
export type Summary = z.infer<typeof Summary>
|
||||||
|
const PLATFORM = {
|
||||||
|
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
|
||||||
|
"arm64-linux": {
|
||||||
|
platform: "aarch64-unknown-linux-gnu",
|
||||||
|
extension: "tar.gz",
|
||||||
|
},
|
||||||
|
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
|
||||||
|
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
|
||||||
|
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const ExtractionFailedError = NamedError.create(
|
||||||
|
"RipgrepExtractionFailedError",
|
||||||
|
z.object({
|
||||||
|
filepath: z.string(),
|
||||||
|
stderr: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const UnsupportedPlatformError = NamedError.create(
|
||||||
|
"RipgrepUnsupportedPlatformError",
|
||||||
|
z.object({
|
||||||
|
platform: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const DownloadFailedError = NamedError.create(
|
||||||
|
"RipgrepDownloadFailedError",
|
||||||
|
z.object({
|
||||||
|
url: z.string(),
|
||||||
|
status: z.number(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const state = lazy(async () => {
|
||||||
|
let filepath = Bun.which("rg")
|
||||||
|
if (filepath) return { filepath }
|
||||||
|
filepath = path.join(
|
||||||
|
Global.Path.bin,
|
||||||
|
"rg" + (process.platform === "win32" ? ".exe" : ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
const file = Bun.file(filepath)
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
const platformKey =
|
||||||
|
`${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||||
|
const config = PLATFORM[platformKey]
|
||||||
|
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
|
||||||
|
|
||||||
|
const version = "14.1.1"
|
||||||
|
const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
|
||||||
|
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok)
|
||||||
|
throw new DownloadFailedError({ url, status: response.status })
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer()
|
||||||
|
const archivePath = path.join(Global.Path.bin, filename)
|
||||||
|
await Bun.write(archivePath, buffer)
|
||||||
|
if (config.extension === "tar.gz") {
|
||||||
|
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
|
||||||
|
|
||||||
|
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
|
||||||
|
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
|
||||||
|
|
||||||
|
const proc = Bun.spawn(args, {
|
||||||
|
cwd: Global.Path.bin,
|
||||||
|
stderr: "pipe",
|
||||||
|
stdout: "pipe",
|
||||||
|
})
|
||||||
|
await proc.exited
|
||||||
|
if (proc.exitCode !== 0)
|
||||||
|
throw new ExtractionFailedError({
|
||||||
|
filepath,
|
||||||
|
stderr: await Bun.readableStreamToText(proc.stderr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (config.extension === "zip") {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
["unzip", "-j", archivePath, "*/rg.exe", "-d", Global.Path.bin],
|
||||||
|
{
|
||||||
|
cwd: Global.Path.bin,
|
||||||
|
stderr: "pipe",
|
||||||
|
stdout: "ignore",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await proc.exited
|
||||||
|
if (proc.exitCode !== 0)
|
||||||
|
throw new ExtractionFailedError({
|
||||||
|
filepath: archivePath,
|
||||||
|
stderr: await Bun.readableStreamToText(proc.stderr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await fs.unlink(archivePath)
|
||||||
|
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filepath,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function filepath() {
|
||||||
|
const { filepath } = await state()
|
||||||
|
return filepath
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function files(input: {
|
||||||
|
cwd: string
|
||||||
|
query?: string
|
||||||
|
glob?: string
|
||||||
|
limit?: number
|
||||||
|
}) {
|
||||||
|
const commands = [
|
||||||
|
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
|
||||||
|
]
|
||||||
|
if (input.query)
|
||||||
|
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
|
||||||
|
if (input.limit) commands.push(`head -n ${input.limit}`)
|
||||||
|
const joined = commands.join(" | ")
|
||||||
|
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
|
||||||
|
return result.split("\n").filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tree(input: { cwd: string; limit?: number }) {
|
||||||
|
const files = await Ripgrep.files({ cwd: input.cwd })
|
||||||
|
interface Node {
|
||||||
|
path: string[]
|
||||||
|
children: Node[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPath(node: Node, parts: string[], create: boolean) {
|
||||||
|
if (parts.length === 0) return node
|
||||||
|
let current = node
|
||||||
|
for (const part of parts) {
|
||||||
|
let existing = current.children.find((x) => x.path.at(-1) === part)
|
||||||
|
if (!existing) {
|
||||||
|
if (!create) return
|
||||||
|
existing = {
|
||||||
|
path: current.path.concat(part),
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
current.children.push(existing)
|
||||||
|
}
|
||||||
|
current = existing
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
const root: Node = {
|
||||||
|
path: [],
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
for (const file of files) {
|
||||||
|
const parts = file.split(path.sep)
|
||||||
|
getPath(root, parts, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sort(node: Node) {
|
||||||
|
node.children.sort((a, b) => {
|
||||||
|
if (!a.children.length && b.children.length) return 1
|
||||||
|
if (!b.children.length && a.children.length) return -1
|
||||||
|
return a.path.at(-1)!.localeCompare(b.path.at(-1)!)
|
||||||
|
})
|
||||||
|
for (const child of node.children) {
|
||||||
|
sort(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort(root)
|
||||||
|
|
||||||
|
let current = [root]
|
||||||
|
const result: Node = {
|
||||||
|
path: [],
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
let processed = 0
|
||||||
|
const limit = input.limit ?? 50
|
||||||
|
while (current.length > 0) {
|
||||||
|
const next = []
|
||||||
|
for (const node of current) {
|
||||||
|
if (node.children.length) next.push(...node.children)
|
||||||
|
}
|
||||||
|
const max = Math.max(...current.map((x) => x.children.length))
|
||||||
|
for (let i = 0; i < max && processed < limit; i++) {
|
||||||
|
for (const node of current) {
|
||||||
|
const child = node.children[i]
|
||||||
|
if (!child) continue
|
||||||
|
getPath(result, child.path, true)
|
||||||
|
processed++
|
||||||
|
if (processed >= limit) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (processed >= limit) {
|
||||||
|
for (const node of [...current, ...next]) {
|
||||||
|
const compare = getPath(result, node.path, false)
|
||||||
|
if (!compare) continue
|
||||||
|
if (compare?.children.length !== node.children.length) {
|
||||||
|
const diff = node.children.length - compare.children.length
|
||||||
|
compare.children.push({
|
||||||
|
path: compare.path.concat(`[${diff} truncated]`),
|
||||||
|
children: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
function render(node: Node, depth: number) {
|
||||||
|
const indent = "\t".repeat(depth)
|
||||||
|
lines.push(indent + node.path.at(-1) + (node.children.length ? "/" : ""))
|
||||||
|
for (const child of node.children) {
|
||||||
|
render(child, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.children.map((x) => render(x, 0))
|
||||||
|
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function search(input: {
|
||||||
|
cwd: string
|
||||||
|
pattern: string
|
||||||
|
glob?: string[]
|
||||||
|
limit?: number
|
||||||
|
}) {
|
||||||
|
const args = [
|
||||||
|
`${await filepath()}`,
|
||||||
|
"--json",
|
||||||
|
"--hidden",
|
||||||
|
"--glob='!.git/*'",
|
||||||
|
]
|
||||||
|
|
||||||
|
if (input.glob) {
|
||||||
|
for (const g of input.glob) {
|
||||||
|
args.push(`--glob=${g}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.limit) {
|
||||||
|
args.push(`--max-count=${input.limit}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(input.pattern)
|
||||||
|
|
||||||
|
const command = args.join(" ")
|
||||||
|
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = result.text().trim().split("\n").filter(Boolean)
|
||||||
|
// Parse JSON lines from ripgrep output
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line) => JSON.parse(line))
|
||||||
|
.map((parsed) => Result.parse(parsed))
|
||||||
|
.filter((r) => r.type === "match")
|
||||||
|
.map((r) => r.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { App } from "../../app/app"
|
import { App } from "../app/app"
|
||||||
|
|
||||||
export namespace FileTimes {
|
export namespace FileTime {
|
||||||
export const state = App.state("tool.filetimes", () => {
|
export const state = App.state("tool.filetimes", () => {
|
||||||
const read: {
|
const read: {
|
||||||
[sessionID: string]: {
|
[sessionID: string]: {
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import fs from "fs"
|
||||||
|
import { App } from "../app/app"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
|
export namespace FileWatcher {
|
||||||
|
const log = Log.create({ service: "file.watcher" })
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Updated: Bus.event(
|
||||||
|
"file.watcher.updated",
|
||||||
|
z.object({
|
||||||
|
file: z.string(),
|
||||||
|
event: z.union([z.literal("rename"), z.literal("change")]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
App.state(
|
||||||
|
"file.watcher",
|
||||||
|
() => {
|
||||||
|
const app = App.use()
|
||||||
|
try {
|
||||||
|
const watcher = fs.watch(
|
||||||
|
app.info.path.cwd,
|
||||||
|
{ recursive: true },
|
||||||
|
(event, file) => {
|
||||||
|
log.info("change", { file, event })
|
||||||
|
if (!file) return
|
||||||
|
// for some reason async local storage is lost here
|
||||||
|
// https://github.com/oven-sh/bun/issues/20754
|
||||||
|
App.provideExisting(app, async () => {
|
||||||
|
Bus.publish(Event.Updated, {
|
||||||
|
file,
|
||||||
|
event,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return { watcher }
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (state) => {
|
||||||
|
state.watcher?.close()
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { App } from "../app/app"
|
||||||
|
import { BunProc } from "../bun"
|
||||||
|
|
||||||
|
export interface Info {
|
||||||
|
name: string
|
||||||
|
command: string[]
|
||||||
|
environment?: Record<string, string>
|
||||||
|
extensions: string[]
|
||||||
|
enabled(): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gofmt: Info = {
|
||||||
|
name: "gofmt",
|
||||||
|
command: ["gofmt", "-w", "$FILE"],
|
||||||
|
extensions: [".go"],
|
||||||
|
async enabled() {
|
||||||
|
return Bun.which("gofmt") !== null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mix: Info = {
|
||||||
|
name: "mix",
|
||||||
|
command: ["mix", "format", "$FILE"],
|
||||||
|
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||||
|
async enabled() {
|
||||||
|
return Bun.which("mix") !== null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prettier: Info = {
|
||||||
|
name: "prettier",
|
||||||
|
command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
|
||||||
|
environment: {
|
||||||
|
BUN_BE_BUN: "1",
|
||||||
|
},
|
||||||
|
extensions: [
|
||||||
|
".js",
|
||||||
|
".jsx",
|
||||||
|
".mjs",
|
||||||
|
".cjs",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".mts",
|
||||||
|
".cts",
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".css",
|
||||||
|
".scss",
|
||||||
|
".sass",
|
||||||
|
".less",
|
||||||
|
".vue",
|
||||||
|
".svelte",
|
||||||
|
".json",
|
||||||
|
".jsonc",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".toml",
|
||||||
|
".xml",
|
||||||
|
".md",
|
||||||
|
".mdx",
|
||||||
|
".graphql",
|
||||||
|
".gql",
|
||||||
|
],
|
||||||
|
async enabled() {
|
||||||
|
// this is more complicated because we only want to use prettier if it's
|
||||||
|
// being used with the current project
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn({
|
||||||
|
cmd: [BunProc.which(), "run", "prettier", "--version"],
|
||||||
|
cwd: App.info().path.cwd,
|
||||||
|
env: {
|
||||||
|
BUN_BE_BUN: "1",
|
||||||
|
},
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
const exit = await proc.exited
|
||||||
|
return exit === 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const zig: Info = {
|
||||||
|
name: "zig",
|
||||||
|
command: ["zig", "fmt", "$FILE"],
|
||||||
|
extensions: [".zig", ".zon"],
|
||||||
|
async enabled() {
|
||||||
|
return Bun.which("zig") !== null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clang: Info = {
|
||||||
|
name: "clang-format",
|
||||||
|
command: ["clang-format", "-i", "$FILE"],
|
||||||
|
extensions: [
|
||||||
|
".c",
|
||||||
|
".cc",
|
||||||
|
".cpp",
|
||||||
|
".cxx",
|
||||||
|
".c++",
|
||||||
|
".h",
|
||||||
|
".hh",
|
||||||
|
".hpp",
|
||||||
|
".hxx",
|
||||||
|
".h++",
|
||||||
|
".ino",
|
||||||
|
".C",
|
||||||
|
".H",
|
||||||
|
],
|
||||||
|
async enabled() {
|
||||||
|
return Bun.which("clang-format") !== null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ktlint: Info = {
|
||||||
|
name: "ktlint",
|
||||||
|
command: ["ktlint", "-F", "$FILE"],
|
||||||
|
extensions: [".kt", ".kts"],
|
||||||
|
async enabled() {
|
||||||
|
return Bun.which("ktlint") !== null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ruff: Info = {
|
||||||
|
name: "ruff",
|
||||||
|
command: ["ruff", "format", "$FILE"],
|
||||||
|
extensions: [".py", ".pyi"],
|
||||||
|
async enabled() {
|
||||||
|
return Bun.which("ruff") !== null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rubocop: Info = {
|
||||||
|
name: "rubocop",
|
||||||
|
command: ["rubocop", "--autocorrect", "$FILE"],
|
||||||
|
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||||
|
async enabled() {
|
||||||
|
return Bun.which("rubocop") !== null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const standardrb: Info = {
|
||||||
|
name: "standardrb",
|
||||||
|
command: ["standardrb", "--fix", "$FILE"],
|
||||||
|
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||||
|
async enabled() {
|
||||||
|
return Bun.which("standardrb") !== null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const htmlbeautifier: Info = {
|
||||||
|
name: "htmlbeautifier",
|
||||||
|
command: ["htmlbeautifier", "$FILE"],
|
||||||
|
extensions: [".erb", ".html.erb"],
|
||||||
|
async enabled() {
|
||||||
|
return Bun.which("htmlbeautifier") !== null
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { App } from "../app/app"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { File } from "../file"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
import * as Formatter from "./formatter"
|
||||||
|
|
||||||
|
export namespace Format {
|
||||||
|
const log = Log.create({ service: "format" })
|
||||||
|
|
||||||
|
const state = App.state("format", () => {
|
||||||
|
const enabled: Record<string, boolean> = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function isEnabled(item: Formatter.Info) {
|
||||||
|
const s = state()
|
||||||
|
let status = s.enabled[item.name]
|
||||||
|
if (status === undefined) {
|
||||||
|
status = await item.enabled()
|
||||||
|
s.enabled[item.name] = status
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFormatter(ext: string) {
|
||||||
|
const result = []
|
||||||
|
for (const item of Object.values(Formatter)) {
|
||||||
|
if (!item.extensions.includes(ext)) continue
|
||||||
|
if (!(await isEnabled(item))) continue
|
||||||
|
result.push(item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
log.info("init")
|
||||||
|
Bus.subscribe(File.Event.Edited, async (payload) => {
|
||||||
|
const file = payload.properties.file
|
||||||
|
log.info("formatting", { file })
|
||||||
|
const ext = path.extname(file)
|
||||||
|
|
||||||
|
for (const item of await getFormatter(ext)) {
|
||||||
|
log.info("running", { command: item.command })
|
||||||
|
const proc = Bun.spawn({
|
||||||
|
cmd: item.command.map((x) => x.replace("$FILE", file)),
|
||||||
|
cwd: App.info().path.cwd,
|
||||||
|
env: item.environment,
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
const exit = await proc.exited
|
||||||
|
if (exit !== 0)
|
||||||
|
log.error("failed", {
|
||||||
|
command: item.command,
|
||||||
|
...item.environment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,41 @@
|
|||||||
import "zod-openapi/extend"
|
import "zod-openapi/extend"
|
||||||
import { App } from "./app/app"
|
|
||||||
import { Server } from "./server/server"
|
|
||||||
import fs from "fs/promises"
|
|
||||||
import path from "path"
|
|
||||||
import { Share } from "./share/share"
|
|
||||||
import { Global } from "./global"
|
|
||||||
import yargs from "yargs"
|
import yargs from "yargs"
|
||||||
import { hideBin } from "yargs/helpers"
|
import { hideBin } from "yargs/helpers"
|
||||||
import { RunCommand } from "./cli/cmd/run"
|
import { RunCommand } from "./cli/cmd/run"
|
||||||
import { GenerateCommand } from "./cli/cmd/generate"
|
import { GenerateCommand } from "./cli/cmd/generate"
|
||||||
import { ScrapCommand } from "./cli/cmd/scrap"
|
|
||||||
import { Log } from "./util/log"
|
import { Log } from "./util/log"
|
||||||
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
|
import { AuthCommand } from "./cli/cmd/auth"
|
||||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||||
import { Provider } from "./provider/provider"
|
import { ModelsCommand } from "./cli/cmd/models"
|
||||||
import { UI } from "./cli/ui"
|
import { UI } from "./cli/ui"
|
||||||
import { Installation } from "./installation"
|
import { Installation } from "./installation"
|
||||||
import { Bus } from "./bus"
|
|
||||||
import { Config } from "./config/config"
|
|
||||||
import { NamedError } from "./util/error"
|
import { NamedError } from "./util/error"
|
||||||
import { FormatError } from "./cli/error"
|
import { FormatError } from "./cli/error"
|
||||||
|
import { ServeCommand } from "./cli/cmd/serve"
|
||||||
|
import { TuiCommand } from "./cli/cmd/tui"
|
||||||
|
import { DebugCommand } from "./cli/cmd/debug"
|
||||||
|
|
||||||
|
const cancel = new AbortController()
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (e) => {
|
||||||
|
Log.Default.error("rejection", {
|
||||||
|
e: e instanceof Error ? e.message : e,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on("uncaughtException", (e) => {
|
||||||
|
Log.Default.error("exception", {
|
||||||
|
e: e instanceof Error ? e.message : e,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const cli = yargs(hideBin(process.argv))
|
const cli = yargs(hideBin(process.argv))
|
||||||
.scriptName("opencode")
|
.scriptName("opencode")
|
||||||
.version(Installation.VERSION)
|
.help("help", "show help")
|
||||||
|
.version("version", "show version number", Installation.VERSION)
|
||||||
|
.alias("version", "v")
|
||||||
.option("print-logs", {
|
.option("print-logs", {
|
||||||
describe: "Print logs to stderr",
|
describe: "print logs to stderr",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
})
|
})
|
||||||
.middleware(async () => {
|
.middleware(async () => {
|
||||||
@@ -36,92 +46,14 @@ const cli = yargs(hideBin(process.argv))
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.usage("\n" + UI.logo())
|
.usage("\n" + UI.logo())
|
||||||
.command({
|
.command(TuiCommand)
|
||||||
command: "$0 [project]",
|
|
||||||
describe: "start opencode TUI",
|
|
||||||
builder: (yargs) =>
|
|
||||||
yargs.positional("project", {
|
|
||||||
type: "string",
|
|
||||||
describe: "path to start opencode in",
|
|
||||||
}),
|
|
||||||
handler: async (args) => {
|
|
||||||
while (true) {
|
|
||||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
|
||||||
process.chdir(cwd)
|
|
||||||
const result = await App.provide({ cwd }, async (app) => {
|
|
||||||
const providers = await Provider.list()
|
|
||||||
if (Object.keys(providers).length === 0) {
|
|
||||||
return "needs_provider"
|
|
||||||
}
|
|
||||||
|
|
||||||
await Share.init()
|
|
||||||
const server = Server.listen()
|
|
||||||
|
|
||||||
let cmd = ["go", "run", "./main.go"]
|
|
||||||
let cwd = new URL("../../tui/cmd/opencode", import.meta.url).pathname
|
|
||||||
if (Bun.embeddedFiles.length > 0) {
|
|
||||||
const blob = Bun.embeddedFiles[0] as File
|
|
||||||
const binary = path.join(Global.Path.cache, "tui", blob.name)
|
|
||||||
const file = Bun.file(binary)
|
|
||||||
if (!(await file.exists())) {
|
|
||||||
await Bun.write(file, blob, { mode: 0o755 })
|
|
||||||
await fs.chmod(binary, 0o755)
|
|
||||||
}
|
|
||||||
cwd = process.cwd()
|
|
||||||
cmd = [binary]
|
|
||||||
}
|
|
||||||
const proc = Bun.spawn({
|
|
||||||
cmd: [...cmd, ...process.argv.slice(2)],
|
|
||||||
cwd,
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
stdin: "inherit",
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
OPENCODE_SERVER: server.url.toString(),
|
|
||||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
|
||||||
},
|
|
||||||
onExit: () => {
|
|
||||||
server.stop()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
if (Installation.VERSION === "dev") return
|
|
||||||
if (Installation.isSnapshot()) return
|
|
||||||
const config = await Config.global()
|
|
||||||
if (config.autoupdate === false) return
|
|
||||||
const latest = await Installation.latest()
|
|
||||||
if (Installation.VERSION === latest) return
|
|
||||||
const method = await Installation.method()
|
|
||||||
if (method === "unknown") return
|
|
||||||
await Installation.upgrade(method, latest)
|
|
||||||
.then(() => {
|
|
||||||
Bus.publish(Installation.Event.Updated, { version: latest })
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
})()
|
|
||||||
|
|
||||||
await proc.exited
|
|
||||||
server.stop()
|
|
||||||
|
|
||||||
return "done"
|
|
||||||
})
|
|
||||||
if (result === "done") break
|
|
||||||
if (result === "needs_provider") {
|
|
||||||
UI.empty()
|
|
||||||
UI.println(UI.logo(" "))
|
|
||||||
UI.empty()
|
|
||||||
await AuthLoginCommand.handler(args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.command(RunCommand)
|
.command(RunCommand)
|
||||||
.command(GenerateCommand)
|
.command(GenerateCommand)
|
||||||
.command(ScrapCommand)
|
.command(DebugCommand)
|
||||||
.command(AuthCommand)
|
.command(AuthCommand)
|
||||||
.command(UpgradeCommand)
|
.command(UpgradeCommand)
|
||||||
|
.command(ServeCommand)
|
||||||
|
.command(ModelsCommand)
|
||||||
.fail((msg) => {
|
.fail((msg) => {
|
||||||
if (
|
if (
|
||||||
msg.startsWith("Unknown argument") ||
|
msg.startsWith("Unknown argument") ||
|
||||||
@@ -135,13 +67,14 @@ const cli = yargs(hideBin(process.argv))
|
|||||||
try {
|
try {
|
||||||
await cli.parse()
|
await cli.parse()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const data: Record<string, any> = {}
|
let data: Record<string, any> = {}
|
||||||
if (e instanceof NamedError) {
|
if (e instanceof NamedError) {
|
||||||
const obj = e.toObject()
|
const obj = e.toObject()
|
||||||
Object.assign(data, {
|
Object.assign(data, {
|
||||||
...obj.data,
|
...obj.data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
Object.assign(data, {
|
Object.assign(data, {
|
||||||
name: e.name,
|
name: e.name,
|
||||||
@@ -149,11 +82,26 @@ try {
|
|||||||
cause: e.cause?.toString(),
|
cause: e.cause?.toString(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e instanceof ResolveMessage) {
|
||||||
|
Object.assign(data, {
|
||||||
|
name: e.name,
|
||||||
|
message: e.message,
|
||||||
|
code: e.code,
|
||||||
|
specifier: e.specifier,
|
||||||
|
referrer: e.referrer,
|
||||||
|
position: e.position,
|
||||||
|
importKind: e.importKind,
|
||||||
|
})
|
||||||
|
}
|
||||||
Log.Default.error("fatal", data)
|
Log.Default.error("fatal", data)
|
||||||
const formatted = FormatError(e)
|
const formatted = FormatError(e)
|
||||||
if (formatted) UI.error(formatted)
|
if (formatted) UI.error(formatted)
|
||||||
if (!formatted)
|
if (formatted === undefined)
|
||||||
UI.error(
|
UI.error(
|
||||||
"Unexpected error, check log file at " + Log.file() + " for more details",
|
"Unexpected error, check log file at " + Log.file() + " for more details",
|
||||||
)
|
)
|
||||||
|
process.exitCode = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancel.abort()
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { $ } from "bun"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
const OPENCODE_VERSION: string
|
const OPENCODE_VERSION: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Installation {
|
export namespace Installation {
|
||||||
|
const log = Log.create({ service: "installation" })
|
||||||
|
|
||||||
export type Method = Awaited<ReturnType<typeof method>>
|
export type Method = Awaited<ReturnType<typeof method>>
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
@@ -66,6 +69,10 @@ export namespace Installation {
|
|||||||
name: "bun" as const,
|
name: "bun" as const,
|
||||||
command: () => $`bun pm ls -g`.throws(false).text(),
|
command: () => $`bun pm ls -g`.throws(false).text(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "brew" as const,
|
||||||
|
command: () => $`brew list --formula opencode-ai`.throws(false).text(),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
checks.sort((a, b) => {
|
checks.sort((a, b) => {
|
||||||
@@ -97,18 +104,31 @@ export namespace Installation {
|
|||||||
const cmd = (() => {
|
const cmd = (() => {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case "curl":
|
case "curl":
|
||||||
return $`curl -fsSL https://opencode.ai/install | bash`
|
return $`curl -fsSL https://opencode.ai/install | bash`.env({
|
||||||
|
...process.env,
|
||||||
|
VERSION: target,
|
||||||
|
})
|
||||||
case "npm":
|
case "npm":
|
||||||
return $`npm install -g opencode-ai@${target}`
|
return $`npm install -g opencode-ai@${target}`
|
||||||
case "pnpm":
|
case "pnpm":
|
||||||
return $`pnpm install -g opencode-ai@${target}`
|
return $`pnpm install -g opencode-ai@${target}`
|
||||||
case "bun":
|
case "bun":
|
||||||
return $`bun install -g opencode-ai@${target}`
|
return $`bun install -g opencode-ai@${target}`
|
||||||
|
case "brew":
|
||||||
|
return $`brew install sst/tap/opencode`.env({
|
||||||
|
HOMEBREW_NO_AUTO_UPDATE: "1",
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown method: ${method}`)
|
throw new Error(`Unknown method: ${method}`)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
const result = await cmd.quiet().throws(false)
|
const result = await cmd.quiet().throws(false)
|
||||||
|
log.info("upgraded", {
|
||||||
|
method,
|
||||||
|
target,
|
||||||
|
stdout: result.stdout.toString(),
|
||||||
|
stderr: result.stderr.toString(),
|
||||||
|
})
|
||||||
if (result.exitCode !== 0)
|
if (result.exitCode !== 0)
|
||||||
throw new UpgradeFailedError({
|
throw new UpgradeFailedError({
|
||||||
stderr: result.stderr.toString("utf8"),
|
stderr: result.stderr.toString("utf8"),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Bus } from "../bus"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { LSPServer } from "./server"
|
import type { LSPServer } from "./server"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
|
import { withTimeout } from "../util/timeout"
|
||||||
|
|
||||||
export namespace LSPClient {
|
export namespace LSPClient {
|
||||||
const log = Log.create({ service: "lsp.client" })
|
const log = Log.create({ service: "lsp.client" })
|
||||||
@@ -52,7 +53,9 @@ export namespace LSPClient {
|
|||||||
log.info("textDocument/publishDiagnostics", {
|
log.info("textDocument/publishDiagnostics", {
|
||||||
path,
|
path,
|
||||||
})
|
})
|
||||||
|
const exists = diagnostics.has(path)
|
||||||
diagnostics.set(path, params.diagnostics)
|
diagnostics.set(path, params.diagnostics)
|
||||||
|
if (!exists && serverID === "typescript") return
|
||||||
Bus.publish(Event.Diagnostics, { path, serverID })
|
Bus.publish(Event.Diagnostics, { path, serverID })
|
||||||
})
|
})
|
||||||
connection.onRequest("workspace/configuration", async () => {
|
connection.onRequest("workspace/configuration", async () => {
|
||||||
@@ -61,7 +64,7 @@ export namespace LSPClient {
|
|||||||
connection.listen()
|
connection.listen()
|
||||||
|
|
||||||
log.info("sending initialize", { id: serverID })
|
log.info("sending initialize", { id: serverID })
|
||||||
await Promise.race([
|
await withTimeout(
|
||||||
connection.sendRequest("initialize", {
|
connection.sendRequest("initialize", {
|
||||||
processId: server.process.pid,
|
processId: server.process.pid,
|
||||||
workspaceFolders: [
|
workspaceFolders: [
|
||||||
@@ -88,12 +91,10 @@ export namespace LSPClient {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
new Promise((_, reject) => {
|
5_000,
|
||||||
setTimeout(() => {
|
).catch(() => {
|
||||||
reject(new InitializeError({ serverID }))
|
throw new InitializeError({ serverID })
|
||||||
}, 5_000)
|
})
|
||||||
}),
|
|
||||||
])
|
|
||||||
await connection.sendNotification("initialized", {})
|
await connection.sendNotification("initialized", {})
|
||||||
log.info("initialized")
|
log.info("initialized")
|
||||||
|
|
||||||
@@ -116,36 +117,28 @@ export namespace LSPClient {
|
|||||||
const file = Bun.file(input.path)
|
const file = Bun.file(input.path)
|
||||||
const text = await file.text()
|
const text = await file.text()
|
||||||
const version = files[input.path]
|
const version = files[input.path]
|
||||||
if (version === undefined) {
|
if (version !== undefined) {
|
||||||
log.info("textDocument/didOpen", input)
|
|
||||||
diagnostics.delete(input.path)
|
diagnostics.delete(input.path)
|
||||||
const extension = path.extname(input.path)
|
await connection.sendNotification("textDocument/didClose", {
|
||||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
|
||||||
await connection.sendNotification("textDocument/didOpen", {
|
|
||||||
textDocument: {
|
textDocument: {
|
||||||
uri: `file://` + input.path,
|
uri: `file://` + input.path,
|
||||||
languageId,
|
|
||||||
version: 0,
|
|
||||||
text,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
files[input.path] = 0
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
log.info("textDocument/didOpen", input)
|
||||||
log.info("textDocument/didChange", input)
|
|
||||||
diagnostics.delete(input.path)
|
diagnostics.delete(input.path)
|
||||||
await connection.sendNotification("textDocument/didChange", {
|
const extension = path.extname(input.path)
|
||||||
|
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||||
|
await connection.sendNotification("textDocument/didOpen", {
|
||||||
textDocument: {
|
textDocument: {
|
||||||
uri: `file://` + input.path,
|
uri: `file://` + input.path,
|
||||||
version: ++files[input.path],
|
languageId,
|
||||||
|
version: 0,
|
||||||
|
text,
|
||||||
},
|
},
|
||||||
contentChanges: [
|
|
||||||
{
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
files[input.path] = 0
|
||||||
|
return
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
get diagnostics() {
|
get diagnostics() {
|
||||||
@@ -157,35 +150,32 @@ export namespace LSPClient {
|
|||||||
: path.resolve(app.path.cwd, input.path)
|
: path.resolve(app.path.cwd, input.path)
|
||||||
log.info("waiting for diagnostics", input)
|
log.info("waiting for diagnostics", input)
|
||||||
let unsub: () => void
|
let unsub: () => void
|
||||||
let timeout: NodeJS.Timeout
|
return await withTimeout(
|
||||||
return await Promise.race([
|
new Promise<void>((resolve) => {
|
||||||
new Promise<void>(async (resolve) => {
|
|
||||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||||
if (
|
if (
|
||||||
event.properties.path === input.path &&
|
event.properties.path === input.path &&
|
||||||
event.properties.serverID === result.serverID
|
event.properties.serverID === result.serverID
|
||||||
) {
|
) {
|
||||||
log.info("got diagnostics", input)
|
log.info("got diagnostics", input)
|
||||||
clearTimeout(timeout)
|
|
||||||
unsub?.()
|
unsub?.()
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
new Promise<void>((resolve) => {
|
3000,
|
||||||
timeout = setTimeout(() => {
|
)
|
||||||
log.info("timed out refreshing diagnostics", input)
|
.catch(() => {})
|
||||||
unsub?.()
|
.finally(() => {
|
||||||
resolve()
|
unsub?.()
|
||||||
}, 5000)
|
})
|
||||||
}),
|
|
||||||
])
|
|
||||||
},
|
},
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
log.info("shutting down")
|
log.info("shutting down", { serverID })
|
||||||
connection.end()
|
connection.end()
|
||||||
connection.dispose()
|
connection.dispose()
|
||||||
server.process.kill("SIGKILL")
|
server.process.kill("SIGTERM")
|
||||||
|
log.info("shutdown", { serverID })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,19 +3,60 @@ import { Log } from "../util/log"
|
|||||||
import { LSPClient } from "./client"
|
import { LSPClient } from "./client"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { LSPServer } from "./server"
|
import { LSPServer } from "./server"
|
||||||
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
export namespace LSP {
|
export namespace LSP {
|
||||||
const log = Log.create({ service: "lsp" })
|
const log = Log.create({ service: "lsp" })
|
||||||
|
|
||||||
|
export const Symbol = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
kind: z.number(),
|
||||||
|
location: z.object({
|
||||||
|
uri: z.string(),
|
||||||
|
range: z.object({
|
||||||
|
start: z.object({
|
||||||
|
line: z.number(),
|
||||||
|
character: z.number(),
|
||||||
|
}),
|
||||||
|
end: z.object({
|
||||||
|
line: z.number(),
|
||||||
|
character: z.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.openapi({
|
||||||
|
ref: "LSP.Symbol",
|
||||||
|
})
|
||||||
|
export type Symbol = z.infer<typeof Symbol>
|
||||||
|
|
||||||
const state = App.state(
|
const state = App.state(
|
||||||
"lsp",
|
"lsp",
|
||||||
async () => {
|
async (app) => {
|
||||||
log.info("initializing")
|
log.info("initializing")
|
||||||
const clients = new Map<string, LSPClient.Info>()
|
const clients = new Map<string, LSPClient.Info>()
|
||||||
const skip = new Set<string>()
|
for (const server of Object.values(LSPServer)) {
|
||||||
|
for (const extension of server.extensions) {
|
||||||
|
const [file] = await Ripgrep.files({
|
||||||
|
cwd: app.path.cwd,
|
||||||
|
glob: "*" + extension,
|
||||||
|
})
|
||||||
|
if (!file) continue
|
||||||
|
const handle = await server.spawn(App.info())
|
||||||
|
if (!handle) break
|
||||||
|
const client = await LSPClient.create(server.id, handle).catch(
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
if (!client) break
|
||||||
|
clients.set(server.id, client)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("initialized")
|
||||||
return {
|
return {
|
||||||
clients,
|
clients,
|
||||||
skip,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async (state) => {
|
async (state) => {
|
||||||
@@ -25,35 +66,23 @@ export namespace LSP {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export async function init() {
|
||||||
|
return state()
|
||||||
|
}
|
||||||
|
|
||||||
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
|
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
|
||||||
const extension = path.parse(input).ext
|
const extension = path.parse(input).ext
|
||||||
const s = await state()
|
const matches = Object.values(LSPServer)
|
||||||
const matches = LSPServer.All.filter((x) =>
|
.filter((x) => x.extensions.includes(extension))
|
||||||
x.extensions.includes(extension),
|
.map((x) => x.id)
|
||||||
)
|
await run(async (client) => {
|
||||||
for (const match of matches) {
|
if (!matches.includes(client.serverID)) return
|
||||||
if (s.skip.has(match.id)) continue
|
const wait = waitForDiagnostics
|
||||||
const existing = s.clients.get(match.id)
|
? client.waitForDiagnostics({ path: input })
|
||||||
if (existing) continue
|
: Promise.resolve()
|
||||||
const handle = await match.spawn(App.info())
|
await client.notify.open({ path: input })
|
||||||
if (!handle) {
|
return wait
|
||||||
s.skip.add(match.id)
|
})
|
||||||
continue
|
|
||||||
}
|
|
||||||
const client = await LSPClient.create(match.id, handle).catch(() => {})
|
|
||||||
if (!client) {
|
|
||||||
s.skip.add(match.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s.clients.set(match.id, client)
|
|
||||||
}
|
|
||||||
if (waitForDiagnostics) {
|
|
||||||
await run(async (client) => {
|
|
||||||
const wait = client.waitForDiagnostics({ path: input })
|
|
||||||
await client.notify.open({ path: input })
|
|
||||||
return wait
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function diagnostics() {
|
export async function diagnostics() {
|
||||||
@@ -86,6 +115,14 @@ export namespace LSP {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function workspaceSymbol(query: string) {
|
||||||
|
return run((client) =>
|
||||||
|
client.connection.sendRequest("workspace/symbol", {
|
||||||
|
query,
|
||||||
|
}),
|
||||||
|
).then((result) => result.flat() as LSP.Symbol[])
|
||||||
|
}
|
||||||
|
|
||||||
async function run<T>(
|
async function run<T>(
|
||||||
input: (client: LSPClient.Info) => Promise<T>,
|
input: (client: LSPClient.Info) => Promise<T>,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
|||||||
".cshtml": "razor",
|
".cshtml": "razor",
|
||||||
".razor": "razor",
|
".razor": "razor",
|
||||||
".rb": "ruby",
|
".rb": "ruby",
|
||||||
|
".rake": "ruby",
|
||||||
|
".gemspec": "ruby",
|
||||||
|
".ru": "ruby",
|
||||||
|
".erb": "erb",
|
||||||
|
".html.erb": "erb",
|
||||||
|
".js.erb": "erb",
|
||||||
|
".css.erb": "erb",
|
||||||
|
".json.erb": "erb",
|
||||||
".rs": "rust",
|
".rs": "rust",
|
||||||
".scss": "scss",
|
".scss": "scss",
|
||||||
".sass": "sass",
|
".sass": "sass",
|
||||||
|
|||||||
@@ -19,78 +19,128 @@ export namespace LSPServer {
|
|||||||
spawn(app: App.Info): Promise<Handle | undefined>
|
spawn(app: App.Info): Promise<Handle | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const All: Info[] = [
|
export const Typescript: Info = {
|
||||||
{
|
id: "typescript",
|
||||||
id: "typescript",
|
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
||||||
extensions: [
|
async spawn(app) {
|
||||||
".ts",
|
const tsserver = await Bun.resolve(
|
||||||
".tsx",
|
"typescript/lib/tsserver.js",
|
||||||
".js",
|
app.path.cwd,
|
||||||
".jsx",
|
).catch(() => {})
|
||||||
".mjs",
|
if (!tsserver) return
|
||||||
".cjs",
|
const proc = spawn(
|
||||||
".mts",
|
BunProc.which(),
|
||||||
".cts",
|
["x", "typescript-language-server", "--stdio"],
|
||||||
],
|
{
|
||||||
async spawn(app) {
|
env: {
|
||||||
const tsserver = await Bun.resolve(
|
...process.env,
|
||||||
"typescript/lib/tsserver.js",
|
BUN_BE_BUN: "1",
|
||||||
app.path.cwd,
|
|
||||||
).catch(() => {})
|
|
||||||
if (!tsserver) return
|
|
||||||
const proc = spawn(
|
|
||||||
BunProc.which(),
|
|
||||||
["x", "typescript-language-server", "--stdio"],
|
|
||||||
{
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
BUN_BE_BUN: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
},
|
||||||
return {
|
)
|
||||||
process: proc,
|
return {
|
||||||
initialization: {
|
process: proc,
|
||||||
tsserver: {
|
initialization: {
|
||||||
path: tsserver,
|
tsserver: {
|
||||||
},
|
path: tsserver,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
}
|
||||||
id: "golang",
|
|
||||||
extensions: [".go"],
|
export const Gopls: Info = {
|
||||||
async spawn() {
|
id: "golang",
|
||||||
let bin = Bun.which("gopls", {
|
extensions: [".go"],
|
||||||
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
async spawn() {
|
||||||
|
let bin = Bun.which("gopls", {
|
||||||
|
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||||
|
})
|
||||||
|
if (!bin) {
|
||||||
|
log.info("installing gopls")
|
||||||
|
const proc = Bun.spawn({
|
||||||
|
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
|
||||||
|
env: { ...process.env, GOBIN: Global.Path.bin },
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
stdin: "pipe",
|
||||||
})
|
})
|
||||||
if (!bin) {
|
const exit = await proc.exited
|
||||||
log.info("installing gopls")
|
if (exit !== 0) {
|
||||||
const proc = Bun.spawn({
|
log.error("Failed to install gopls")
|
||||||
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
|
return
|
||||||
env: { ...process.env, GOBIN: Global.Path.bin },
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
stdin: "pipe",
|
|
||||||
})
|
|
||||||
const exit = await proc.exited
|
|
||||||
if (exit !== 0) {
|
|
||||||
log.error("Failed to install gopls")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bin = path.join(
|
|
||||||
Global.Path.bin,
|
|
||||||
"gopls" + (process.platform === "win32" ? ".exe" : ""),
|
|
||||||
)
|
|
||||||
log.info(`installed gopls`, {
|
|
||||||
bin,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return {
|
bin = path.join(
|
||||||
process: spawn(bin!),
|
Global.Path.bin,
|
||||||
}
|
"gopls" + (process.platform === "win32" ? ".exe" : ""),
|
||||||
},
|
)
|
||||||
|
log.info(`installed gopls`, {
|
||||||
|
bin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
process: spawn(bin!),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
]
|
}
|
||||||
|
|
||||||
|
export const RubyLsp: Info = {
|
||||||
|
id: "ruby-lsp",
|
||||||
|
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||||
|
async spawn() {
|
||||||
|
let bin = Bun.which("ruby-lsp", {
|
||||||
|
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||||
|
})
|
||||||
|
if (!bin) {
|
||||||
|
const ruby = Bun.which("ruby")
|
||||||
|
const gem = Bun.which("gem")
|
||||||
|
if (!ruby || !gem) {
|
||||||
|
log.info("Ruby not found, please install Ruby first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.info("installing ruby-lsp")
|
||||||
|
const proc = Bun.spawn({
|
||||||
|
cmd: ["gem", "install", "ruby-lsp", "--bindir", Global.Path.bin],
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
stdin: "pipe",
|
||||||
|
})
|
||||||
|
const exit = await proc.exited
|
||||||
|
if (exit !== 0) {
|
||||||
|
log.error("Failed to install ruby-lsp")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bin = path.join(
|
||||||
|
Global.Path.bin,
|
||||||
|
"ruby-lsp" + (process.platform === "win32" ? ".exe" : ""),
|
||||||
|
)
|
||||||
|
log.info(`installed ruby-lsp`, {
|
||||||
|
bin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
process: spawn(bin!, ["--stdio"]),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Pyright: Info = {
|
||||||
|
id: "pyright",
|
||||||
|
extensions: [".py", ".pyi"],
|
||||||
|
async spawn() {
|
||||||
|
const proc = spawn(
|
||||||
|
BunProc.which(),
|
||||||
|
["x", "pyright-langserver", "--stdio"],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BUN_BE_BUN: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
process: proc,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,22 @@ import { experimental_createMCPClient, type Tool } from "ai"
|
|||||||
import { Experimental_StdioMCPTransport } from "ai/mcp-stdio"
|
import { Experimental_StdioMCPTransport } from "ai/mcp-stdio"
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { NamedError } from "../util/error"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { Session } from "../session"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
|
||||||
export namespace MCP {
|
export namespace MCP {
|
||||||
|
const log = Log.create({ service: "mcp" })
|
||||||
|
|
||||||
|
export const Failed = NamedError.create(
|
||||||
|
"MCPFailed",
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const state = App.state(
|
const state = App.state(
|
||||||
"mcp",
|
"mcp",
|
||||||
async () => {
|
async () => {
|
||||||
@@ -12,27 +26,60 @@ export namespace MCP {
|
|||||||
[name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
|
[name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
|
||||||
} = {}
|
} = {}
|
||||||
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
|
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
|
||||||
|
if (mcp.enabled === false) {
|
||||||
|
log.info("mcp server disabled", { key })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.info("found", { key, type: mcp.type })
|
||||||
if (mcp.type === "remote") {
|
if (mcp.type === "remote") {
|
||||||
clients[key] = await experimental_createMCPClient({
|
const client = await experimental_createMCPClient({
|
||||||
name: key,
|
name: key,
|
||||||
transport: {
|
transport: {
|
||||||
type: "sse",
|
type: "sse",
|
||||||
url: mcp.url,
|
url: mcp.url,
|
||||||
},
|
},
|
||||||
})
|
}).catch(() => {})
|
||||||
|
if (!client) {
|
||||||
|
Bus.publish(Session.Event.Error, {
|
||||||
|
error: {
|
||||||
|
name: "UnknownError",
|
||||||
|
data: {
|
||||||
|
message: `MCP server ${key} failed to start`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clients[key] = client
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mcp.type === "local") {
|
if (mcp.type === "local") {
|
||||||
const [cmd, ...args] = mcp.command
|
const [cmd, ...args] = mcp.command
|
||||||
clients[key] = await experimental_createMCPClient({
|
const client = await experimental_createMCPClient({
|
||||||
name: key,
|
name: key,
|
||||||
transport: new Experimental_StdioMCPTransport({
|
transport: new Experimental_StdioMCPTransport({
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
command: cmd,
|
command: cmd,
|
||||||
args,
|
args,
|
||||||
env: mcp.environment,
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||||
|
...mcp.environment,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
})
|
}).catch(() => {})
|
||||||
|
if (!client) {
|
||||||
|
Bus.publish(Session.Event.Error, {
|
||||||
|
error: {
|
||||||
|
name: "UnknownError",
|
||||||
|
data: {
|
||||||
|
message: `MCP server ${key} failed to start`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clients[key] = client
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export async function data() {
|
||||||
|
const json = await fetch("https://models.dev/api.json").then((x) => x.text())
|
||||||
|
return json
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Global } from "../global"
|
|||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import { data } from "./models-macro" with { type: "macro" }
|
||||||
|
|
||||||
export namespace ModelsDev {
|
export namespace ModelsDev {
|
||||||
const log = Log.create({ service: "models.dev" })
|
const log = Log.create({ service: "models.dev" })
|
||||||
@@ -9,7 +10,9 @@ export namespace ModelsDev {
|
|||||||
|
|
||||||
export const Model = z
|
export const Model = z
|
||||||
.object({
|
.object({
|
||||||
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
release_date: z.string(),
|
||||||
attachment: z.boolean(),
|
attachment: z.boolean(),
|
||||||
reasoning: z.boolean(),
|
reasoning: z.boolean(),
|
||||||
temperature: z.boolean(),
|
temperature: z.boolean(),
|
||||||
@@ -24,11 +27,10 @@ export namespace ModelsDev {
|
|||||||
context: z.number(),
|
context: z.number(),
|
||||||
output: z.number(),
|
output: z.number(),
|
||||||
}),
|
}),
|
||||||
id: z.string(),
|
|
||||||
options: z.record(z.any()),
|
options: z.record(z.any()),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Model.Info",
|
ref: "Model",
|
||||||
})
|
})
|
||||||
export type Model = z.infer<typeof Model>
|
export type Model = z.infer<typeof Model>
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ export namespace ModelsDev {
|
|||||||
models: z.record(Model),
|
models: z.record(Model),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Provider.Info",
|
ref: "Provider",
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Provider = z.infer<typeof Provider>
|
export type Provider = z.infer<typeof Provider>
|
||||||
@@ -54,16 +56,15 @@ export namespace ModelsDev {
|
|||||||
refresh()
|
refresh()
|
||||||
return result as Record<string, Provider>
|
return result as Record<string, Provider>
|
||||||
}
|
}
|
||||||
await refresh()
|
refresh()
|
||||||
return get()
|
const json = await data()
|
||||||
|
return JSON.parse(json) as Record<string, Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const file = Bun.file(filepath)
|
const file = Bun.file(filepath)
|
||||||
log.info("refreshing")
|
log.info("refreshing")
|
||||||
const result = await fetch("https://models.dev/api.json")
|
const result = await fetch("https://models.dev/api.json").catch(() => {})
|
||||||
if (!result.ok)
|
if (result && result.ok) await Bun.write(file, result)
|
||||||
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
|
|
||||||
await Bun.write(file, result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,36 +11,36 @@ import { WebFetchTool } from "../tool/webfetch"
|
|||||||
import { GlobTool } from "../tool/glob"
|
import { GlobTool } from "../tool/glob"
|
||||||
import { GrepTool } from "../tool/grep"
|
import { GrepTool } from "../tool/grep"
|
||||||
import { ListTool } from "../tool/ls"
|
import { ListTool } from "../tool/ls"
|
||||||
import { LspDiagnosticTool } from "../tool/lsp-diagnostics"
|
|
||||||
import { LspHoverTool } from "../tool/lsp-hover"
|
|
||||||
import { PatchTool } from "../tool/patch"
|
import { PatchTool } from "../tool/patch"
|
||||||
import { ReadTool } from "../tool/read"
|
import { ReadTool } from "../tool/read"
|
||||||
import type { Tool } from "../tool/tool"
|
import type { Tool } from "../tool/tool"
|
||||||
import { WriteTool } from "../tool/write"
|
import { WriteTool } from "../tool/write"
|
||||||
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
|
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
|
||||||
import { AuthAnthropic } from "../auth/anthropic"
|
import { AuthAnthropic } from "../auth/anthropic"
|
||||||
|
import { AuthCopilot } from "../auth/copilot"
|
||||||
import { ModelsDev } from "./models"
|
import { ModelsDev } from "./models"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { TaskTool } from "../tool/task"
|
// import { TaskTool } from "../tool/task"
|
||||||
|
|
||||||
export namespace Provider {
|
export namespace Provider {
|
||||||
const log = Log.create({ service: "provider" })
|
const log = Log.create({ service: "provider" })
|
||||||
|
|
||||||
type CustomLoader = (provider: ModelsDev.Provider) => Promise<
|
type CustomLoader = (
|
||||||
| {
|
provider: ModelsDev.Provider,
|
||||||
getModel?: (sdk: any, modelID: string) => Promise<any>
|
api?: string,
|
||||||
options: Record<string, any>
|
) => Promise<{
|
||||||
}
|
autoload: boolean
|
||||||
| false
|
getModel?: (sdk: any, modelID: string) => Promise<any>
|
||||||
>
|
options?: Record<string, any>
|
||||||
|
}>
|
||||||
|
|
||||||
type Source = "env" | "config" | "custom" | "api"
|
type Source = "env" | "config" | "custom" | "api"
|
||||||
|
|
||||||
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
||||||
async anthropic(provider) {
|
async anthropic(provider) {
|
||||||
const access = await AuthAnthropic.access()
|
const access = await AuthAnthropic.access()
|
||||||
if (!access) return false
|
if (!access) return { autoload: false }
|
||||||
for (const model of Object.values(provider.models)) {
|
for (const model of Object.values(provider.models)) {
|
||||||
model.cost = {
|
model.cost = {
|
||||||
input: 0,
|
input: 0,
|
||||||
@@ -48,6 +48,7 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
autoload: true,
|
||||||
options: {
|
options: {
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
async fetch(input: any, init: any) {
|
async fetch(input: any, init: any) {
|
||||||
@@ -66,8 +67,70 @@ export namespace Provider {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"github-copilot": async (provider) => {
|
||||||
|
const copilot = await AuthCopilot()
|
||||||
|
if (!copilot) return { autoload: false }
|
||||||
|
let info = await Auth.get("github-copilot")
|
||||||
|
if (!info || info.type !== "oauth") return { autoload: false }
|
||||||
|
|
||||||
|
if (provider && provider.models) {
|
||||||
|
for (const model of Object.values(provider.models)) {
|
||||||
|
model.cost = {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
autoload: true,
|
||||||
|
options: {
|
||||||
|
apiKey: "",
|
||||||
|
async fetch(input: any, init: any) {
|
||||||
|
const info = await Auth.get("github-copilot")
|
||||||
|
if (!info || info.type !== "oauth") return
|
||||||
|
if (!info.access || info.expires < Date.now()) {
|
||||||
|
const tokens = await copilot.access(info.refresh)
|
||||||
|
if (!tokens)
|
||||||
|
throw new Error("GitHub Copilot authentication expired")
|
||||||
|
await Auth.set("github-copilot", {
|
||||||
|
type: "oauth",
|
||||||
|
...tokens,
|
||||||
|
})
|
||||||
|
info.access = tokens.access
|
||||||
|
}
|
||||||
|
let isAgentCall = false
|
||||||
|
try {
|
||||||
|
const body =
|
||||||
|
typeof init.body === "string"
|
||||||
|
? JSON.parse(init.body)
|
||||||
|
: init.body
|
||||||
|
if (body?.messages) {
|
||||||
|
isAgentCall = body.messages.some(
|
||||||
|
(msg: any) =>
|
||||||
|
msg.role && ["tool", "assistant"].includes(msg.role),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const headers = {
|
||||||
|
...init.headers,
|
||||||
|
...copilot.HEADERS,
|
||||||
|
Authorization: `Bearer ${info.access}`,
|
||||||
|
"Openai-Intent": "conversation-edits",
|
||||||
|
"X-Initiator": isAgentCall ? "agent" : "user",
|
||||||
|
}
|
||||||
|
delete headers["x-api-key"]
|
||||||
|
return fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
openai: async () => {
|
openai: async () => {
|
||||||
return {
|
return {
|
||||||
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string) {
|
async getModel(sdk: any, modelID: string) {
|
||||||
return sdk.responses(modelID)
|
return sdk.responses(modelID)
|
||||||
},
|
},
|
||||||
@@ -75,7 +138,8 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"amazon-bedrock": async () => {
|
"amazon-bedrock": async () => {
|
||||||
if (!process.env["AWS_PROFILE"]) return false
|
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"])
|
||||||
|
return { autoload: false }
|
||||||
|
|
||||||
const region = process.env["AWS_REGION"] ?? "us-east-1"
|
const region = process.env["AWS_REGION"] ?? "us-east-1"
|
||||||
|
|
||||||
@@ -83,19 +147,75 @@ export namespace Provider {
|
|||||||
await BunProc.install("@aws-sdk/credential-providers")
|
await BunProc.install("@aws-sdk/credential-providers")
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
autoload: true,
|
||||||
options: {
|
options: {
|
||||||
region,
|
region,
|
||||||
credentialProvider: fromNodeProviderChain(),
|
credentialProvider: fromNodeProviderChain(),
|
||||||
},
|
},
|
||||||
async getModel(sdk: any, modelID: string) {
|
async getModel(sdk: any, modelID: string) {
|
||||||
if (modelID.includes("claude")) {
|
let regionPrefix = region.split("-")[0]
|
||||||
const prefix = region.split("-")[0]
|
|
||||||
modelID = `${prefix}.${modelID}`
|
switch (regionPrefix) {
|
||||||
|
case "us": {
|
||||||
|
const modelRequiresPrefix = ["claude", "deepseek"].some((m) =>
|
||||||
|
modelID.includes(m),
|
||||||
|
)
|
||||||
|
if (modelRequiresPrefix) {
|
||||||
|
modelID = `${regionPrefix}.${modelID}`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "eu": {
|
||||||
|
const regionRequiresPrefix = [
|
||||||
|
"eu-west-1",
|
||||||
|
"eu-west-3",
|
||||||
|
"eu-north-1",
|
||||||
|
"eu-central-1",
|
||||||
|
"eu-south-1",
|
||||||
|
"eu-south-2",
|
||||||
|
].some((r) => region.includes(r))
|
||||||
|
const modelRequiresPrefix = [
|
||||||
|
"claude",
|
||||||
|
"nova-lite",
|
||||||
|
"nova-micro",
|
||||||
|
"llama3",
|
||||||
|
"pixtral",
|
||||||
|
].some((m) => modelID.includes(m))
|
||||||
|
if (regionRequiresPrefix && modelRequiresPrefix) {
|
||||||
|
modelID = `${regionPrefix}.${modelID}`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "ap": {
|
||||||
|
const modelRequiresPrefix = [
|
||||||
|
"claude",
|
||||||
|
"nova-lite",
|
||||||
|
"nova-micro",
|
||||||
|
"nova-pro",
|
||||||
|
].some((m) => modelID.includes(m))
|
||||||
|
if (modelRequiresPrefix) {
|
||||||
|
regionPrefix = "apac"
|
||||||
|
modelID = `${regionPrefix}.${modelID}`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sdk.languageModel(modelID)
|
return sdk.languageModel(modelID)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openrouter: async () => {
|
||||||
|
return {
|
||||||
|
autoload: false,
|
||||||
|
options: {
|
||||||
|
headers: {
|
||||||
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
|
"X-Title": "opencode",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = App.state("provider", async () => {
|
const state = App.state("provider", async () => {
|
||||||
@@ -133,6 +253,7 @@ export namespace Provider {
|
|||||||
source,
|
source,
|
||||||
info,
|
info,
|
||||||
options,
|
options,
|
||||||
|
getModel,
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -150,6 +271,7 @@ export namespace Provider {
|
|||||||
npm: provider.npm ?? existing?.npm,
|
npm: provider.npm ?? existing?.npm,
|
||||||
name: provider.name ?? existing?.name ?? providerID,
|
name: provider.name ?? existing?.name ?? providerID,
|
||||||
env: provider.env ?? existing?.env ?? [],
|
env: provider.env ?? existing?.env ?? [],
|
||||||
|
api: provider.api ?? existing?.api,
|
||||||
models: existing?.models ?? {},
|
models: existing?.models ?? {},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +280,7 @@ export namespace Provider {
|
|||||||
const parsedModel: ModelsDev.Model = {
|
const parsedModel: ModelsDev.Model = {
|
||||||
id: modelID,
|
id: modelID,
|
||||||
name: model.name ?? existing?.name ?? modelID,
|
name: model.name ?? existing?.name ?? modelID,
|
||||||
|
release_date: model.release_date ?? existing?.release_date,
|
||||||
attachment: model.attachment ?? existing?.attachment ?? false,
|
attachment: model.attachment ?? existing?.attachment ?? false,
|
||||||
reasoning: model.reasoning ?? existing?.reasoning ?? false,
|
reasoning: model.reasoning ?? existing?.reasoning ?? false,
|
||||||
temperature: model.temperature ?? existing?.temperature ?? false,
|
temperature: model.temperature ?? existing?.temperature ?? false,
|
||||||
@@ -191,9 +314,14 @@ export namespace Provider {
|
|||||||
// load env
|
// load env
|
||||||
for (const [providerID, provider] of Object.entries(database)) {
|
for (const [providerID, provider] of Object.entries(database)) {
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
if (provider.env.some((item) => process.env[item])) {
|
const apiKey = provider.env.map((item) => process.env[item]).at(0)
|
||||||
mergeProvider(providerID, {}, "env")
|
if (!apiKey) continue
|
||||||
}
|
mergeProvider(
|
||||||
|
providerID,
|
||||||
|
// only include apiKey if there's only one potential option
|
||||||
|
provider.env.length === 1 ? { apiKey } : {},
|
||||||
|
"env",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// load apikeys
|
// load apikeys
|
||||||
@@ -208,8 +336,14 @@ export namespace Provider {
|
|||||||
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
const result = await fn(database[providerID])
|
const result = await fn(database[providerID])
|
||||||
if (result)
|
if (result && (result.autoload || providers[providerID])) {
|
||||||
mergeProvider(providerID, result.options, "custom", result.getModel)
|
mergeProvider(
|
||||||
|
providerID,
|
||||||
|
result.options ?? {},
|
||||||
|
"custom",
|
||||||
|
result.getModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// load config
|
// load config
|
||||||
@@ -344,16 +478,15 @@ export namespace Provider {
|
|||||||
GlobTool,
|
GlobTool,
|
||||||
GrepTool,
|
GrepTool,
|
||||||
ListTool,
|
ListTool,
|
||||||
LspDiagnosticTool,
|
// LspDiagnosticTool,
|
||||||
LspHoverTool,
|
// LspHoverTool,
|
||||||
PatchTool,
|
PatchTool,
|
||||||
ReadTool,
|
ReadTool,
|
||||||
EditTool,
|
|
||||||
// MultiEditTool,
|
// MultiEditTool,
|
||||||
WriteTool,
|
WriteTool,
|
||||||
TodoWriteTool,
|
TodoWriteTool,
|
||||||
TaskTool,
|
|
||||||
TodoReadTool,
|
TodoReadTool,
|
||||||
|
// TaskTool,
|
||||||
]
|
]
|
||||||
|
|
||||||
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
|
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
|
||||||
|
|||||||
@@ -1,24 +1,38 @@
|
|||||||
import type { CoreMessage } from "ai"
|
import type { LanguageModelV1Prompt } from "ai"
|
||||||
|
import { unique } from "remeda"
|
||||||
|
|
||||||
export namespace ProviderTransform {
|
export namespace ProviderTransform {
|
||||||
export function message(
|
export function message(
|
||||||
msg: CoreMessage,
|
msgs: LanguageModelV1Prompt,
|
||||||
index: number,
|
|
||||||
providerID: string,
|
providerID: string,
|
||||||
modelID: string,
|
modelID: string,
|
||||||
) {
|
) {
|
||||||
if (
|
if (providerID === "anthropic" || modelID.includes("anthropic")) {
|
||||||
(providerID === "anthropic" || modelID.includes("anthropic")) &&
|
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
|
||||||
index < 4
|
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
|
||||||
) {
|
|
||||||
msg.providerOptions = {
|
for (const msg of unique([...system, ...final])) {
|
||||||
...msg.providerOptions,
|
msg.providerMetadata = {
|
||||||
anthropic: {
|
...msg.providerMetadata,
|
||||||
cacheControl: { type: "ephemeral" },
|
anthropic: {
|
||||||
},
|
cacheControl: { type: "ephemeral" },
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (providerID === "amazon-bedrock" || modelID.includes("anthropic")) {
|
||||||
|
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
|
||||||
|
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
|
||||||
|
|
||||||
return msg
|
for (const msg of unique([...system, ...final])) {
|
||||||
|
msg.providerMetadata = {
|
||||||
|
...msg.providerMetadata,
|
||||||
|
bedrock: {
|
||||||
|
cachePoint: { type: "ephemeral" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import { z } from "zod"
|
|||||||
import { Message } from "../session/message"
|
import { Message } from "../session/message"
|
||||||
import { Provider } from "../provider/provider"
|
import { Provider } from "../provider/provider"
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
import { Global } from "../global"
|
|
||||||
import { mapValues } from "remeda"
|
import { mapValues } from "remeda"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
import { ModelsDev } from "../provider/models"
|
import { ModelsDev } from "../provider/models"
|
||||||
import { Ripgrep } from "../external/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { Installation } from "../installation"
|
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
|
import { File } from "../file"
|
||||||
|
import { LSP } from "../lsp"
|
||||||
|
|
||||||
const ERRORS = {
|
const ERRORS = {
|
||||||
400: {
|
400: {
|
||||||
@@ -70,12 +70,12 @@ export namespace Server {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.get(
|
.get(
|
||||||
"/openapi",
|
"/doc",
|
||||||
openAPISpecs(app, {
|
openAPISpecs(app, {
|
||||||
documentation: {
|
documentation: {
|
||||||
info: {
|
info: {
|
||||||
title: "opencode",
|
title: "opencode",
|
||||||
version: "1.0.0",
|
version: "0.0.3",
|
||||||
description: "opencode api",
|
description: "opencode api",
|
||||||
},
|
},
|
||||||
openapi: "3.0.0",
|
openapi: "3.0.0",
|
||||||
@@ -122,8 +122,8 @@ export namespace Server {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.get(
|
||||||
"/app_info",
|
"/app",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Get app info",
|
description: "Get app info",
|
||||||
responses: {
|
responses: {
|
||||||
@@ -142,26 +142,7 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
"/config_get",
|
"/app/init",
|
||||||
describeRoute({
|
|
||||||
description: "Get config info",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Get config info",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(Config.Info),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
return c.json(await Config.get())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/app_initialize",
|
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Initialize the app",
|
description: "Initialize the app",
|
||||||
responses: {
|
responses: {
|
||||||
@@ -180,172 +161,27 @@ export namespace Server {
|
|||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.get(
|
||||||
"/session_initialize",
|
"/config",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Analyze the app and create an AGENTS.md file",
|
description: "Get config info",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "200",
|
description: "Get config info",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(z.boolean()),
|
schema: resolver(Config.Info),
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
zValidator(
|
|
||||||
"json",
|
|
||||||
z.object({
|
|
||||||
sessionID: z.string(),
|
|
||||||
providerID: z.string(),
|
|
||||||
modelID: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const body = c.req.valid("json")
|
|
||||||
await Session.initialize(body)
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/path_get",
|
|
||||||
describeRoute({
|
|
||||||
description: "Get paths",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "200",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(
|
|
||||||
z.object({
|
|
||||||
root: z.string(),
|
|
||||||
data: z.string(),
|
|
||||||
cwd: z.string(),
|
|
||||||
config: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const app = App.info()
|
return c.json(await Config.get())
|
||||||
return c.json({
|
|
||||||
root: app.path.root,
|
|
||||||
data: app.path.data,
|
|
||||||
cwd: app.path.cwd,
|
|
||||||
config: Global.Path.data,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.get(
|
||||||
"/session_create",
|
"/session",
|
||||||
describeRoute({
|
|
||||||
description: "Create a new session",
|
|
||||||
responses: {
|
|
||||||
...ERRORS,
|
|
||||||
200: {
|
|
||||||
description: "Successfully created session",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(Session.Info),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
const session = await Session.create()
|
|
||||||
return c.json(session)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/session_share",
|
|
||||||
describeRoute({
|
|
||||||
description: "Share the session",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successfully shared session",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(Session.Info),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
zValidator(
|
|
||||||
"json",
|
|
||||||
z.object({
|
|
||||||
sessionID: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const body = c.req.valid("json")
|
|
||||||
await Session.share(body.sessionID)
|
|
||||||
const session = await Session.get(body.sessionID)
|
|
||||||
return c.json(session)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/session_unshare",
|
|
||||||
describeRoute({
|
|
||||||
description: "Unshare the session",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successfully unshared session",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(Session.Info),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
zValidator(
|
|
||||||
"json",
|
|
||||||
z.object({
|
|
||||||
sessionID: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const body = c.req.valid("json")
|
|
||||||
await Session.unshare(body.sessionID)
|
|
||||||
const session = await Session.get(body.sessionID)
|
|
||||||
return c.json(session)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/session_messages",
|
|
||||||
describeRoute({
|
|
||||||
description: "Get messages for a session",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successfully created session",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(Message.Info.array()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
zValidator(
|
|
||||||
"json",
|
|
||||||
z.object({
|
|
||||||
sessionID: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const messages = await Session.messages(c.req.valid("json").sessionID)
|
|
||||||
return c.json(messages)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/session_list",
|
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "List all sessions",
|
description: "List all sessions",
|
||||||
responses: {
|
responses: {
|
||||||
@@ -365,7 +201,89 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
"/session_abort",
|
"/session",
|
||||||
|
describeRoute({
|
||||||
|
description: "Create a new session",
|
||||||
|
responses: {
|
||||||
|
...ERRORS,
|
||||||
|
200: {
|
||||||
|
description: "Successfully created session",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(Session.Info),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const session = await Session.create()
|
||||||
|
return c.json(session)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"/session/:id",
|
||||||
|
describeRoute({
|
||||||
|
description: "Delete a session and all its data",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully deleted session",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
await Session.remove(c.req.valid("param").id)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/session/:id/init",
|
||||||
|
describeRoute({
|
||||||
|
description: "Analyze the app and create an AGENTS.md file",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "200",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
id: z.string().openapi({ description: "Session ID" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
zValidator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
providerID: z.string(),
|
||||||
|
modelID: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const sessionID = c.req.valid("param").id
|
||||||
|
const body = c.req.valid("json")
|
||||||
|
await Session.initialize({ ...body, sessionID })
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/session/:id/abort",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Abort a session",
|
description: "Abort a session",
|
||||||
responses: {
|
responses: {
|
||||||
@@ -380,23 +298,78 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
zValidator(
|
zValidator(
|
||||||
"json",
|
"param",
|
||||||
z.object({
|
z.object({
|
||||||
sessionID: z.string(),
|
id: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json")
|
return c.json(Session.abort(c.req.valid("param").id))
|
||||||
return c.json(Session.abort(body.sessionID))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
"/session_summarize",
|
"/session/:id/share",
|
||||||
|
describeRoute({
|
||||||
|
description: "Share a session",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully shared session",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(Session.Info),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const id = c.req.valid("param").id
|
||||||
|
await Session.share(id)
|
||||||
|
const session = await Session.get(id)
|
||||||
|
return c.json(session)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"/session/:id/share",
|
||||||
|
describeRoute({
|
||||||
|
description: "Unshare the session",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully unshared session",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(Session.Info),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const id = c.req.valid("param").id
|
||||||
|
await Session.unshare(id)
|
||||||
|
const session = await Session.get(id)
|
||||||
|
return c.json(session)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/session/:id/summarize",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Summarize the session",
|
description: "Summarize the session",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "Summarize the session",
|
description: "Summarized session",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(z.boolean()),
|
schema: resolver(z.boolean()),
|
||||||
@@ -405,27 +378,59 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
zValidator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
id: z.string().openapi({ description: "Session ID" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
zValidator(
|
zValidator(
|
||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
sessionID: z.string(),
|
|
||||||
providerID: z.string(),
|
providerID: z.string(),
|
||||||
modelID: z.string(),
|
modelID: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
const id = c.req.valid("param").id
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
await Session.summarize(body)
|
await Session.summarize({ ...body, sessionID: id })
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.get(
|
||||||
"/session_chat",
|
"/session/:id/message",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Chat with a model",
|
description: "List messages for a session",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "Chat with a model",
|
description: "List of messages",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(Message.Info.array()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
id: z.string().openapi({ description: "Session ID" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const messages = await Session.messages(c.req.valid("param").id)
|
||||||
|
return c.json(messages)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/session/:id/message",
|
||||||
|
describeRoute({
|
||||||
|
description: "Create and send a new message to a session",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Created message",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Message.Info),
|
schema: resolver(Message.Info),
|
||||||
@@ -434,23 +439,29 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
zValidator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
id: z.string().openapi({ description: "Session ID" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
zValidator(
|
zValidator(
|
||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
sessionID: z.string(),
|
|
||||||
providerID: z.string(),
|
providerID: z.string(),
|
||||||
modelID: z.string(),
|
modelID: z.string(),
|
||||||
parts: Message.Part.array(),
|
parts: Message.MessagePart.array(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
const sessionID = c.req.valid("param").id
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
const msg = await Session.chat(body)
|
const msg = await Session.chat({ ...body, sessionID })
|
||||||
return c.json(msg)
|
return c.json(msg)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.get(
|
||||||
"/provider_list",
|
"/config/providers",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "List all providers",
|
description: "List all providers",
|
||||||
responses: {
|
responses: {
|
||||||
@@ -482,13 +493,45 @@ export namespace Server {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.get(
|
||||||
"/file_search",
|
"/find",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Search for files",
|
description: "Find text in files",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "Search for files",
|
description: "Matches",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(Ripgrep.Match.shape.data.array()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
pattern: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const app = App.info()
|
||||||
|
const pattern = c.req.valid("query").pattern
|
||||||
|
const result = await Ripgrep.search({
|
||||||
|
cwd: app.path.cwd,
|
||||||
|
pattern,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
return c.json(result)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/find/file",
|
||||||
|
describeRoute({
|
||||||
|
description: "Find files",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "File paths",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(z.string().array()),
|
schema: resolver(z.string().array()),
|
||||||
@@ -498,39 +541,112 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
zValidator(
|
zValidator(
|
||||||
"json",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
query: z.string(),
|
query: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json")
|
const query = c.req.valid("query").query
|
||||||
const app = App.info()
|
const app = App.info()
|
||||||
const result = await Ripgrep.files({
|
const result = await Ripgrep.files({
|
||||||
cwd: app.path.cwd,
|
cwd: app.path.cwd,
|
||||||
query: body.query,
|
query,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
return c.json(result)
|
return c.json(result)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.get(
|
||||||
"installation_info",
|
"/find/symbol",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Get installation info",
|
description: "Find workspace symbols",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "Get installation info",
|
description: "Symbols",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Installation.Info),
|
schema: resolver(z.unknown().array()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
query: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const query = c.req.valid("query").query
|
||||||
|
const result = await LSP.workspaceSymbol(query)
|
||||||
|
return c.json(result)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/file",
|
||||||
|
describeRoute({
|
||||||
|
description: "Read a file",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "File content",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
z.object({
|
||||||
|
type: z.enum(["raw", "patch"]),
|
||||||
|
content: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
path: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const path = c.req.valid("query").path
|
||||||
|
const content = await File.read(path)
|
||||||
|
log.info("read file", {
|
||||||
|
path,
|
||||||
|
content: content.content,
|
||||||
|
})
|
||||||
|
return c.json(content)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/file/status",
|
||||||
|
describeRoute({
|
||||||
|
description: "Get file status",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "File status",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
file: z.string(),
|
||||||
|
added: z.number().int(),
|
||||||
|
removed: z.number().int(),
|
||||||
|
status: z.enum(["added", "deleted", "modified"]),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(Installation.info())
|
const content = await File.status()
|
||||||
|
return c.json(content)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -552,10 +668,10 @@ export namespace Server {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listen() {
|
export function listen(opts: { port: number; hostname: string }) {
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 0,
|
port: opts.port,
|
||||||
hostname: "0.0.0.0",
|
hostname: opts.hostname,
|
||||||
idleTimeout: 0,
|
idleTimeout: 0,
|
||||||
fetch: app().fetch,
|
fetch: app().fetch,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type CoreMessage,
|
type CoreMessage,
|
||||||
type UIMessage,
|
type UIMessage,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
|
wrapLanguageModel,
|
||||||
} from "ai"
|
} from "ai"
|
||||||
import { z, ZodSchema } from "zod"
|
import { z, ZodSchema } from "zod"
|
||||||
import { Decimal } from "decimal.js"
|
import { Decimal } from "decimal.js"
|
||||||
@@ -33,6 +34,7 @@ import type { ModelsDev } from "../provider/models"
|
|||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { ProviderTransform } from "../provider/transform"
|
import { ProviderTransform } from "../provider/transform"
|
||||||
|
import { Snapshot } from "../snapshot"
|
||||||
|
|
||||||
export namespace Session {
|
export namespace Session {
|
||||||
const log = Log.create({ service: "session" })
|
const log = Log.create({ service: "session" })
|
||||||
@@ -52,16 +54,27 @@ export namespace Session {
|
|||||||
created: z.number(),
|
created: z.number(),
|
||||||
updated: z.number(),
|
updated: z.number(),
|
||||||
}),
|
}),
|
||||||
|
revert: z
|
||||||
|
.object({
|
||||||
|
messageID: z.string(),
|
||||||
|
part: z.number(),
|
||||||
|
snapshot: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "session.info",
|
ref: "Session",
|
||||||
})
|
})
|
||||||
export type Info = z.output<typeof Info>
|
export type Info = z.output<typeof Info>
|
||||||
|
|
||||||
export const ShareInfo = z.object({
|
export const ShareInfo = z
|
||||||
secret: z.string(),
|
.object({
|
||||||
url: z.string(),
|
secret: z.string(),
|
||||||
})
|
url: z.string(),
|
||||||
|
})
|
||||||
|
.openapi({
|
||||||
|
ref: "SessionShare",
|
||||||
|
})
|
||||||
export type ShareInfo = z.output<typeof ShareInfo>
|
export type ShareInfo = z.output<typeof ShareInfo>
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
@@ -71,6 +84,18 @@ export namespace Session {
|
|||||||
info: Info,
|
info: Info,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
Deleted: Bus.event(
|
||||||
|
"session.deleted",
|
||||||
|
z.object({
|
||||||
|
info: Info,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Idle: Bus.event(
|
||||||
|
"session.idle",
|
||||||
|
z.object({
|
||||||
|
sessionID: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
Error: Bus.event(
|
Error: Bus.event(
|
||||||
"session.error",
|
"session.error",
|
||||||
z.object({
|
z.object({
|
||||||
@@ -205,6 +230,17 @@ export namespace Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function children(parentID: string) {
|
||||||
|
const result = [] as Session.Info[]
|
||||||
|
for await (const item of Storage.list("session/info")) {
|
||||||
|
const sessionID = path.basename(item, ".json")
|
||||||
|
const session = await get(sessionID)
|
||||||
|
if (session.parentID !== parentID) continue
|
||||||
|
result.push(session)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export function abort(sessionID: string) {
|
export function abort(sessionID: string) {
|
||||||
const controller = state().pending.get(sessionID)
|
const controller = state().pending.get(sessionID)
|
||||||
if (!controller) return false
|
if (!controller) return false
|
||||||
@@ -213,6 +249,28 @@ export namespace Session {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function remove(sessionID: string, emitEvent = true) {
|
||||||
|
try {
|
||||||
|
abort(sessionID)
|
||||||
|
const session = await get(sessionID)
|
||||||
|
for (const child of await children(sessionID)) {
|
||||||
|
await remove(child.id, false)
|
||||||
|
}
|
||||||
|
await unshare(sessionID).catch(() => {})
|
||||||
|
await Storage.remove(`session/info/${sessionID}`).catch(() => {})
|
||||||
|
await Storage.removeDir(`session/message/${sessionID}/`).catch(() => {})
|
||||||
|
state().sessions.delete(sessionID)
|
||||||
|
state().messages.delete(sessionID)
|
||||||
|
if (emitEvent) {
|
||||||
|
Bus.publish(Event.Deleted, {
|
||||||
|
info: session,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateMessage(msg: Message.Info) {
|
async function updateMessage(msg: Message.Info) {
|
||||||
await Storage.writeJSON(
|
await Storage.writeJSON(
|
||||||
"session/message/" + msg.metadata.sessionID + "/" + msg.id,
|
"session/message/" + msg.metadata.sessionID + "/" + msg.id,
|
||||||
@@ -227,7 +285,7 @@ export namespace Session {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
providerID: string
|
providerID: string
|
||||||
modelID: string
|
modelID: string
|
||||||
parts: Message.Part[]
|
parts: Message.MessagePart[]
|
||||||
system?: string[]
|
system?: string[]
|
||||||
tools?: Tool.Info[]
|
tools?: Tool.Info[]
|
||||||
}) {
|
}) {
|
||||||
@@ -235,6 +293,37 @@ export namespace Session {
|
|||||||
l.info("chatting")
|
l.info("chatting")
|
||||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||||
let msgs = await messages(input.sessionID)
|
let msgs = await messages(input.sessionID)
|
||||||
|
const session = await get(input.sessionID)
|
||||||
|
|
||||||
|
if (session.revert) {
|
||||||
|
const trimmed = []
|
||||||
|
for (const msg of msgs) {
|
||||||
|
if (
|
||||||
|
msg.id > session.revert.messageID ||
|
||||||
|
(msg.id === session.revert.messageID && session.revert.part === 0)
|
||||||
|
) {
|
||||||
|
await Storage.remove(
|
||||||
|
"session/message/" + input.sessionID + "/" + msg.id,
|
||||||
|
)
|
||||||
|
await Bus.publish(Message.Event.Removed, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: msg.id,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.id === session.revert.messageID) {
|
||||||
|
if (session.revert.part === 0) break
|
||||||
|
msg.parts = msg.parts.slice(0, session.revert.part)
|
||||||
|
}
|
||||||
|
trimmed.push(msg)
|
||||||
|
}
|
||||||
|
msgs = trimmed
|
||||||
|
await update(input.sessionID, (draft) => {
|
||||||
|
draft.revert = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const previous = msgs.at(-1)
|
const previous = msgs.at(-1)
|
||||||
|
|
||||||
// auto summarize if too long
|
// auto summarize if too long
|
||||||
@@ -247,7 +336,10 @@ export namespace Session {
|
|||||||
if (
|
if (
|
||||||
model.info.limit.context &&
|
model.info.limit.context &&
|
||||||
tokens >
|
tokens >
|
||||||
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
|
Math.max(
|
||||||
|
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9,
|
||||||
|
0,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
await summarize({
|
await summarize({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -266,7 +358,6 @@ export namespace Session {
|
|||||||
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
|
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
|
||||||
|
|
||||||
const app = App.info()
|
const app = App.info()
|
||||||
const session = await get(input.sessionID)
|
|
||||||
if (msgs.length === 0 && !session.parentID) {
|
if (msgs.length === 0 && !session.parentID) {
|
||||||
generateText({
|
generateText({
|
||||||
maxTokens: input.providerID === "google" ? 1024 : 20,
|
maxTokens: input.providerID === "google" ? 1024 : 20,
|
||||||
@@ -285,9 +376,7 @@ export namespace Session {
|
|||||||
parts: toParts(input.parts),
|
parts: toParts(input.parts),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
].map((msg, i) =>
|
],
|
||||||
ProviderTransform.message(msg, i, input.providerID, input.modelID),
|
|
||||||
),
|
|
||||||
model: model.language,
|
model: model.language,
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -298,6 +387,7 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
const snapshot = await Snapshot.create(input.sessionID)
|
||||||
const msg: Message.Info = {
|
const msg: Message.Info = {
|
||||||
role: "user",
|
role: "user",
|
||||||
id: Identifier.ascending("message"),
|
id: Identifier.ascending("message"),
|
||||||
@@ -308,6 +398,7 @@ export namespace Session {
|
|||||||
},
|
},
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
tool: {},
|
tool: {},
|
||||||
|
snapshot,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
await updateMessage(msg)
|
await updateMessage(msg)
|
||||||
@@ -322,6 +413,7 @@ export namespace Session {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [],
|
parts: [],
|
||||||
metadata: {
|
metadata: {
|
||||||
|
snapshot,
|
||||||
assistant: {
|
assistant: {
|
||||||
system,
|
system,
|
||||||
path: {
|
path: {
|
||||||
@@ -373,6 +465,7 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
next.metadata!.tool![opts.toolCallId] = {
|
next.metadata!.tool![opts.toolCallId] = {
|
||||||
...result.metadata,
|
...result.metadata,
|
||||||
|
snapshot: await Snapshot.create(input.sessionID),
|
||||||
time: {
|
time: {
|
||||||
start,
|
start,
|
||||||
end: Date.now(),
|
end: Date.now(),
|
||||||
@@ -385,6 +478,7 @@ export namespace Session {
|
|||||||
error: true,
|
error: true,
|
||||||
message: e.toString(),
|
message: e.toString(),
|
||||||
title: e.toString(),
|
title: e.toString(),
|
||||||
|
snapshot: await Snapshot.create(input.sessionID),
|
||||||
time: {
|
time: {
|
||||||
start,
|
start,
|
||||||
end: Date.now(),
|
end: Date.now(),
|
||||||
@@ -406,6 +500,7 @@ export namespace Session {
|
|||||||
const result = await execute(args, opts)
|
const result = await execute(args, opts)
|
||||||
next.metadata!.tool![opts.toolCallId] = {
|
next.metadata!.tool![opts.toolCallId] = {
|
||||||
...result.metadata,
|
...result.metadata,
|
||||||
|
snapshot: await Snapshot.create(input.sessionID),
|
||||||
time: {
|
time: {
|
||||||
start,
|
start,
|
||||||
end: Date.now(),
|
end: Date.now(),
|
||||||
@@ -420,6 +515,7 @@ export namespace Session {
|
|||||||
next.metadata!.tool![opts.toolCallId] = {
|
next.metadata!.tool![opts.toolCallId] = {
|
||||||
error: true,
|
error: true,
|
||||||
message: e.toString(),
|
message: e.toString(),
|
||||||
|
snapshot: await Snapshot.create(input.sessionID),
|
||||||
title: "mcp",
|
title: "mcp",
|
||||||
time: {
|
time: {
|
||||||
start,
|
start,
|
||||||
@@ -434,24 +530,6 @@ export namespace Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let text: Message.TextPart | undefined
|
let text: Message.TextPart | undefined
|
||||||
await Bun.write(
|
|
||||||
"/tmp/message.json",
|
|
||||||
JSON.stringify(
|
|
||||||
[
|
|
||||||
...system.map(
|
|
||||||
(x): CoreMessage => ({
|
|
||||||
role: "system",
|
|
||||||
content: x,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
...convertToCoreMessages(
|
|
||||||
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
onStepFinish: async (step) => {
|
onStepFinish: async (step) => {
|
||||||
log.info("step finish", { finishReason: step.finishReason })
|
log.info("step finish", { finishReason: step.finishReason })
|
||||||
@@ -469,15 +547,6 @@ export namespace Session {
|
|||||||
}
|
}
|
||||||
text = undefined
|
text = undefined
|
||||||
},
|
},
|
||||||
async onFinish(input) {
|
|
||||||
log.info("message finish", {
|
|
||||||
reason: input.finishReason,
|
|
||||||
})
|
|
||||||
const assistant = next.metadata!.assistant!
|
|
||||||
const usage = getUsage(model.info, input.usage, input.providerMetadata)
|
|
||||||
assistant.cost = usage.cost
|
|
||||||
await updateMessage(next)
|
|
||||||
},
|
|
||||||
onError(err) {
|
onError(err) {
|
||||||
log.error("callback error", err)
|
log.error("callback error", err)
|
||||||
switch (true) {
|
switch (true) {
|
||||||
@@ -514,6 +583,7 @@ export namespace Session {
|
|||||||
// return step
|
// return step
|
||||||
// },
|
// },
|
||||||
toolCallStreaming: true,
|
toolCallStreaming: true,
|
||||||
|
maxTokens: Math.max(0, model.info.limit.output) || undefined,
|
||||||
abortSignal: abort.signal,
|
abortSignal: abort.signal,
|
||||||
maxSteps: 1000,
|
maxSteps: 1000,
|
||||||
providerOptions: model.info.options,
|
providerOptions: model.info.options,
|
||||||
@@ -527,12 +597,26 @@ export namespace Session {
|
|||||||
...convertToCoreMessages(
|
...convertToCoreMessages(
|
||||||
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
||||||
),
|
),
|
||||||
].map((msg, i) =>
|
],
|
||||||
ProviderTransform.message(msg, i, input.providerID, input.modelID),
|
|
||||||
),
|
|
||||||
temperature: model.info.temperature ? 0 : undefined,
|
temperature: model.info.temperature ? 0 : undefined,
|
||||||
tools: model.info.tool_call === false ? undefined : tools,
|
tools: model.info.tool_call === false ? undefined : tools,
|
||||||
model: model.language,
|
model: wrapLanguageModel({
|
||||||
|
model: model.language,
|
||||||
|
middleware: [
|
||||||
|
{
|
||||||
|
async transformParams(args) {
|
||||||
|
if (args.type === "stream") {
|
||||||
|
args.params.prompt = ProviderTransform.message(
|
||||||
|
args.params.prompt,
|
||||||
|
input.providerID,
|
||||||
|
input.modelID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return args.params
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
for await (const value of result.fullStream) {
|
for await (const value of result.fullStream) {
|
||||||
@@ -623,6 +707,21 @@ export namespace Session {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case "finish":
|
||||||
|
log.info("message finish", {
|
||||||
|
reason: value.finishReason,
|
||||||
|
})
|
||||||
|
const assistant = next.metadata!.assistant!
|
||||||
|
const usage = getUsage(
|
||||||
|
model.info,
|
||||||
|
value.usage,
|
||||||
|
value.providerMetadata,
|
||||||
|
)
|
||||||
|
assistant.cost += usage.cost
|
||||||
|
await updateMessage(next)
|
||||||
|
if (value.finishReason === "length")
|
||||||
|
throw new Message.OutputLengthError({})
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
l.info("unhandled", {
|
l.info("unhandled", {
|
||||||
type: value.type,
|
type: value.type,
|
||||||
@@ -636,6 +735,9 @@ export namespace Session {
|
|||||||
error: e,
|
error: e,
|
||||||
})
|
})
|
||||||
switch (true) {
|
switch (true) {
|
||||||
|
case Message.OutputLengthError.isInstance(e):
|
||||||
|
next.metadata.error = e
|
||||||
|
break
|
||||||
case LoadAPIKeyError.isInstance(e):
|
case LoadAPIKeyError.isInstance(e):
|
||||||
next.metadata.error = new Provider.AuthError(
|
next.metadata.error = new Provider.AuthError(
|
||||||
{
|
{
|
||||||
@@ -678,6 +780,51 @@ export namespace Session {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function revert(input: {
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
part: number
|
||||||
|
}) {
|
||||||
|
const message = await getMessage(input.sessionID, input.messageID)
|
||||||
|
if (!message) return
|
||||||
|
const part = message.parts[input.part]
|
||||||
|
if (!part) return
|
||||||
|
const session = await get(input.sessionID)
|
||||||
|
const snapshot =
|
||||||
|
session.revert?.snapshot ?? (await Snapshot.create(input.sessionID))
|
||||||
|
const old = (() => {
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
const lastTool = message.parts.findLast(
|
||||||
|
(part, index) =>
|
||||||
|
part.type === "tool-invocation" && index < input.part,
|
||||||
|
)
|
||||||
|
if (lastTool && lastTool.type === "tool-invocation")
|
||||||
|
return message.metadata.tool[lastTool.toolInvocation.toolCallId]
|
||||||
|
.snapshot
|
||||||
|
}
|
||||||
|
return message.metadata.snapshot
|
||||||
|
})()
|
||||||
|
if (old) await Snapshot.restore(input.sessionID, old)
|
||||||
|
await update(input.sessionID, (draft) => {
|
||||||
|
draft.revert = {
|
||||||
|
messageID: input.messageID,
|
||||||
|
part: input.part,
|
||||||
|
snapshot,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unrevert(sessionID: string) {
|
||||||
|
const session = await get(sessionID)
|
||||||
|
if (!session) return
|
||||||
|
if (!session.revert) return
|
||||||
|
if (session.revert.snapshot)
|
||||||
|
await Snapshot.restore(sessionID, session.revert.snapshot)
|
||||||
|
update(sessionID, (draft) => {
|
||||||
|
draft.revert = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function summarize(input: {
|
export async function summarize(input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
providerID: string
|
providerID: string
|
||||||
@@ -723,7 +870,9 @@ export namespace Session {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
await updateMessage(next)
|
await updateMessage(next)
|
||||||
const result = await generateText({
|
|
||||||
|
let text: Message.TextPart | undefined
|
||||||
|
const result = streamText({
|
||||||
abortSignal: abort.signal,
|
abortSignal: abort.signal,
|
||||||
model: model.language,
|
model: model.language,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -744,16 +893,46 @@ export namespace Session {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
onStepFinish: async (step) => {
|
||||||
|
const assistant = next.metadata!.assistant!
|
||||||
|
const usage = getUsage(model.info, step.usage, step.providerMetadata)
|
||||||
|
assistant.cost += usage.cost
|
||||||
|
assistant.tokens = usage.tokens
|
||||||
|
await updateMessage(next)
|
||||||
|
if (text) {
|
||||||
|
Bus.publish(Message.Event.PartUpdated, {
|
||||||
|
part: text,
|
||||||
|
messageID: next.id,
|
||||||
|
sessionID: next.metadata.sessionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
text = undefined
|
||||||
|
},
|
||||||
|
async onFinish(input) {
|
||||||
|
const assistant = next.metadata!.assistant!
|
||||||
|
const usage = getUsage(model.info, input.usage, input.providerMetadata)
|
||||||
|
assistant.cost += usage.cost
|
||||||
|
assistant.tokens = usage.tokens
|
||||||
|
next.metadata!.time.completed = Date.now()
|
||||||
|
await updateMessage(next)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
next.parts.push({
|
|
||||||
type: "text",
|
for await (const value of result.fullStream) {
|
||||||
text: result.text,
|
switch (value.type) {
|
||||||
})
|
case "text-delta":
|
||||||
const assistant = next.metadata!.assistant!
|
if (!text) {
|
||||||
const usage = getUsage(model.info, result.usage, result.providerMetadata)
|
text = {
|
||||||
assistant.cost = usage.cost
|
type: "text",
|
||||||
assistant.tokens = usage.tokens
|
text: value.textDelta,
|
||||||
await updateMessage(next)
|
}
|
||||||
|
next.parts.push(text)
|
||||||
|
} else text.text += value.textDelta
|
||||||
|
|
||||||
|
await updateMessage(next)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function lock(sessionID: string) {
|
function lock(sessionID: string) {
|
||||||
@@ -766,6 +945,9 @@ export namespace Session {
|
|||||||
[Symbol.dispose]() {
|
[Symbol.dispose]() {
|
||||||
log.info("unlocking", { sessionID })
|
log.info("unlocking", { sessionID })
|
||||||
state().pending.delete(sessionID)
|
state().pending.delete(sessionID)
|
||||||
|
Bus.publish(Event.Idle, {
|
||||||
|
sessionID,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -781,8 +963,12 @@ export namespace Session {
|
|||||||
reasoning: 0,
|
reasoning: 0,
|
||||||
cache: {
|
cache: {
|
||||||
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||||
|
// @ts-expect-error
|
||||||
|
metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
|
||||||
0) as number,
|
0) as number,
|
||||||
read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ??
|
read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ??
|
||||||
|
// @ts-expect-error
|
||||||
|
metadata?.["bedrock"]?.["usage"]?.["cacheReadInputTokens"] ??
|
||||||
0) as number,
|
0) as number,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -854,7 +1040,7 @@ function toUIMessage(msg: Message.Info): UIMessage {
|
|||||||
throw new Error("not implemented")
|
throw new Error("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
function toParts(parts: Message.Part[]): UIMessage["parts"] {
|
function toParts(parts: Message.MessagePart[]): UIMessage["parts"] {
|
||||||
const result: UIMessage["parts"] = []
|
const result: UIMessage["parts"] = []
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { Provider } from "../provider/provider"
|
|||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
|
|
||||||
export namespace Message {
|
export namespace Message {
|
||||||
|
export const OutputLengthError = NamedError.create(
|
||||||
|
"MessageOutputLengthError",
|
||||||
|
z.object({}),
|
||||||
|
)
|
||||||
|
|
||||||
export const ToolCall = z
|
export const ToolCall = z
|
||||||
.object({
|
.object({
|
||||||
state: z.literal("call"),
|
state: z.literal("call"),
|
||||||
@@ -13,7 +18,7 @@ export namespace Message {
|
|||||||
args: z.custom<Required<unknown>>(),
|
args: z.custom<Required<unknown>>(),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.ToolInvocation.ToolCall",
|
ref: "ToolCall",
|
||||||
})
|
})
|
||||||
export type ToolCall = z.infer<typeof ToolCall>
|
export type ToolCall = z.infer<typeof ToolCall>
|
||||||
|
|
||||||
@@ -26,7 +31,7 @@ export namespace Message {
|
|||||||
args: z.custom<Required<unknown>>(),
|
args: z.custom<Required<unknown>>(),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.ToolInvocation.ToolPartialCall",
|
ref: "ToolPartialCall",
|
||||||
})
|
})
|
||||||
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
|
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
|
||||||
|
|
||||||
@@ -40,14 +45,14 @@ export namespace Message {
|
|||||||
result: z.string(),
|
result: z.string(),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.ToolInvocation.ToolResult",
|
ref: "ToolResult",
|
||||||
})
|
})
|
||||||
export type ToolResult = z.infer<typeof ToolResult>
|
export type ToolResult = z.infer<typeof ToolResult>
|
||||||
|
|
||||||
export const ToolInvocation = z
|
export const ToolInvocation = z
|
||||||
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
|
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.ToolInvocation",
|
ref: "ToolInvocation",
|
||||||
})
|
})
|
||||||
export type ToolInvocation = z.infer<typeof ToolInvocation>
|
export type ToolInvocation = z.infer<typeof ToolInvocation>
|
||||||
|
|
||||||
@@ -57,7 +62,7 @@ export namespace Message {
|
|||||||
text: z.string(),
|
text: z.string(),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.Part.Text",
|
ref: "TextPart",
|
||||||
})
|
})
|
||||||
export type TextPart = z.infer<typeof TextPart>
|
export type TextPart = z.infer<typeof TextPart>
|
||||||
|
|
||||||
@@ -68,7 +73,7 @@ export namespace Message {
|
|||||||
providerMetadata: z.record(z.any()).optional(),
|
providerMetadata: z.record(z.any()).optional(),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.Part.Reasoning",
|
ref: "ReasoningPart",
|
||||||
})
|
})
|
||||||
export type ReasoningPart = z.infer<typeof ReasoningPart>
|
export type ReasoningPart = z.infer<typeof ReasoningPart>
|
||||||
|
|
||||||
@@ -78,7 +83,7 @@ export namespace Message {
|
|||||||
toolInvocation: ToolInvocation,
|
toolInvocation: ToolInvocation,
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.Part.ToolInvocation",
|
ref: "ToolInvocationPart",
|
||||||
})
|
})
|
||||||
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
|
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
|
||||||
|
|
||||||
@@ -91,7 +96,7 @@ export namespace Message {
|
|||||||
providerMetadata: z.record(z.any()).optional(),
|
providerMetadata: z.record(z.any()).optional(),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.Part.SourceUrl",
|
ref: "SourceUrlPart",
|
||||||
})
|
})
|
||||||
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
|
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
|
||||||
|
|
||||||
@@ -103,7 +108,7 @@ export namespace Message {
|
|||||||
url: z.string(),
|
url: z.string(),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.Part.File",
|
ref: "FilePart",
|
||||||
})
|
})
|
||||||
export type FilePart = z.infer<typeof FilePart>
|
export type FilePart = z.infer<typeof FilePart>
|
||||||
|
|
||||||
@@ -112,11 +117,11 @@ export namespace Message {
|
|||||||
type: z.literal("step-start"),
|
type: z.literal("step-start"),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.Part.StepStart",
|
ref: "StepStartPart",
|
||||||
})
|
})
|
||||||
export type StepStartPart = z.infer<typeof StepStartPart>
|
export type StepStartPart = z.infer<typeof StepStartPart>
|
||||||
|
|
||||||
export const Part = z
|
export const MessagePart = z
|
||||||
.discriminatedUnion("type", [
|
.discriminatedUnion("type", [
|
||||||
TextPart,
|
TextPart,
|
||||||
ReasoningPart,
|
ReasoningPart,
|
||||||
@@ -126,65 +131,70 @@ export namespace Message {
|
|||||||
StepStartPart,
|
StepStartPart,
|
||||||
])
|
])
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.Part",
|
ref: "MessagePart",
|
||||||
})
|
})
|
||||||
export type Part = z.infer<typeof Part>
|
export type MessagePart = z.infer<typeof MessagePart>
|
||||||
|
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
role: z.enum(["user", "assistant"]),
|
role: z.enum(["user", "assistant"]),
|
||||||
parts: z.array(Part),
|
parts: z.array(MessagePart),
|
||||||
metadata: z.object({
|
metadata: z
|
||||||
time: z.object({
|
.object({
|
||||||
created: z.number(),
|
time: z.object({
|
||||||
completed: z.number().optional(),
|
created: z.number(),
|
||||||
}),
|
completed: z.number().optional(),
|
||||||
error: z
|
}),
|
||||||
.discriminatedUnion("name", [
|
error: z
|
||||||
Provider.AuthError.Schema,
|
.discriminatedUnion("name", [
|
||||||
NamedError.Unknown.Schema,
|
Provider.AuthError.Schema,
|
||||||
])
|
NamedError.Unknown.Schema,
|
||||||
.optional(),
|
OutputLengthError.Schema,
|
||||||
sessionID: z.string(),
|
])
|
||||||
tool: z.record(
|
.optional(),
|
||||||
z.string(),
|
sessionID: z.string(),
|
||||||
z
|
tool: z.record(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
title: z.string(),
|
||||||
|
snapshot: z.string().optional(),
|
||||||
|
time: z.object({
|
||||||
|
start: z.number(),
|
||||||
|
end: z.number(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.catchall(z.any()),
|
||||||
|
),
|
||||||
|
assistant: z
|
||||||
.object({
|
.object({
|
||||||
title: z.string(),
|
system: z.string().array(),
|
||||||
time: z.object({
|
modelID: z.string(),
|
||||||
start: z.number(),
|
providerID: z.string(),
|
||||||
end: z.number(),
|
path: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
root: z.string(),
|
||||||
|
}),
|
||||||
|
cost: z.number(),
|
||||||
|
summary: z.boolean().optional(),
|
||||||
|
tokens: z.object({
|
||||||
|
input: z.number(),
|
||||||
|
output: z.number(),
|
||||||
|
reasoning: z.number(),
|
||||||
|
cache: z.object({
|
||||||
|
read: z.number(),
|
||||||
|
write: z.number(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.catchall(z.any()),
|
.optional(),
|
||||||
),
|
snapshot: z.string().optional(),
|
||||||
assistant: z
|
})
|
||||||
.object({
|
.openapi({ ref: "MessageMetadata" }),
|
||||||
system: z.string().array(),
|
|
||||||
modelID: z.string(),
|
|
||||||
providerID: z.string(),
|
|
||||||
path: z.object({
|
|
||||||
cwd: z.string(),
|
|
||||||
root: z.string(),
|
|
||||||
}),
|
|
||||||
cost: z.number(),
|
|
||||||
summary: z.boolean().optional(),
|
|
||||||
tokens: z.object({
|
|
||||||
input: z.number(),
|
|
||||||
output: z.number(),
|
|
||||||
reasoning: z.number(),
|
|
||||||
cache: z.object({
|
|
||||||
read: z.number(),
|
|
||||||
write: z.number(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.Info",
|
ref: "Message",
|
||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
@@ -195,9 +205,20 @@ export namespace Message {
|
|||||||
info: Info,
|
info: Info,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
Removed: Bus.event(
|
||||||
|
"message.removed",
|
||||||
|
z.object({
|
||||||
|
sessionID: z.string(),
|
||||||
|
messageID: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
PartUpdated: Bus.event(
|
PartUpdated: Bus.event(
|
||||||
"message.part.updated",
|
"message.part.updated",
|
||||||
z.object({ part: Part, sessionID: z.string(), messageID: z.string() }),
|
z.object({
|
||||||
|
part: MessagePart,
|
||||||
|
sessionID: z.string(),
|
||||||
|
messageID: z.string(),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
You will generate a short title based on the first message a user begins a conversation with
|
Generate a short title based on the first message a user begins a conversation with. CRITICAL: Your response must be EXACTLY one line with NO line breaks, newlines, or multiple sentences.
|
||||||
- ensure it is not more than 50 characters long
|
|
||||||
- the title should be a summary of the user's message
|
Requirements:
|
||||||
- it should be one line long
|
- Maximum 50 characters
|
||||||
- do not use quotes or colons
|
- Single line only - NO newlines or line breaks
|
||||||
- the entire text you return will be used as the title
|
- Summary of the user's message
|
||||||
- never return anything that is more than one sentence (one line) long
|
- No quotes, colons, or special formatting
|
||||||
|
- Do not include explanatory text like "summary:" or similar
|
||||||
|
- Your entire response becomes the title
|
||||||
|
|
||||||
|
IMPORTANT: Return only the title text on a single line. Do not add any explanations, formatting, or additional text.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
import { Ripgrep } from "../external/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -27,55 +27,6 @@ export namespace SystemPrompt {
|
|||||||
|
|
||||||
export async function environment() {
|
export async function environment() {
|
||||||
const app = App.info()
|
const app = App.info()
|
||||||
|
|
||||||
;async () => {
|
|
||||||
const files = await Ripgrep.files({
|
|
||||||
cwd: app.path.cwd,
|
|
||||||
})
|
|
||||||
type Node = {
|
|
||||||
children: Record<string, Node>
|
|
||||||
}
|
|
||||||
const root: Node = {
|
|
||||||
children: {},
|
|
||||||
}
|
|
||||||
for (const file of files) {
|
|
||||||
const parts = file.split("/")
|
|
||||||
let node = root
|
|
||||||
for (const part of parts) {
|
|
||||||
const existing = node.children[part]
|
|
||||||
if (existing) {
|
|
||||||
node = existing
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
node.children[part] = {
|
|
||||||
children: {},
|
|
||||||
}
|
|
||||||
node = node.children[part]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(path: string[], node: Node): string {
|
|
||||||
// if (path.length === 3) return "\t".repeat(path.length) + "..."
|
|
||||||
const lines: string[] = []
|
|
||||||
const entries = Object.entries(node.children).sort(([a], [b]) =>
|
|
||||||
a.localeCompare(b),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const [name, child] of entries) {
|
|
||||||
const currentPath = [...path, name]
|
|
||||||
const indent = "\t".repeat(path.length)
|
|
||||||
const hasChildren = Object.keys(child.children).length > 0
|
|
||||||
lines.push(`${indent}${name}` + (hasChildren ? "/" : ""))
|
|
||||||
|
|
||||||
if (hasChildren) lines.push(render(currentPath, child))
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
const result = render([], root)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
`Here is some useful information about the environment you are running in:`,
|
`Here is some useful information about the environment you are running in:`,
|
||||||
@@ -85,9 +36,16 @@ export namespace SystemPrompt {
|
|||||||
` Platform: ${process.platform}`,
|
` Platform: ${process.platform}`,
|
||||||
` Today's date: ${new Date().toDateString()}`,
|
` Today's date: ${new Date().toDateString()}`,
|
||||||
`</env>`,
|
`</env>`,
|
||||||
// `<project>`,
|
`<project>`,
|
||||||
// ` ${app.git ? await tree() : ""}`,
|
` ${
|
||||||
// `</project>`,
|
app.git
|
||||||
|
? await Ripgrep.tree({
|
||||||
|
cwd: app.path.cwd,
|
||||||
|
limit: 200,
|
||||||
|
})
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
`</project>`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { App } from "../app/app"
|
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
@@ -11,12 +10,6 @@ export namespace Share {
|
|||||||
let queue: Promise<void> = Promise.resolve()
|
let queue: Promise<void> = Promise.resolve()
|
||||||
const pending = new Map<string, any>()
|
const pending = new Map<string, any>()
|
||||||
|
|
||||||
const state = App.state("share", async () => {
|
|
||||||
Bus.subscribe(Storage.Event.Write, async (payload) => {
|
|
||||||
await sync(payload.properties.key, payload.properties.content)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function sync(key: string, content: any) {
|
export async function sync(key: string, content: any) {
|
||||||
const [root, ...splits] = key.split("/")
|
const [root, ...splits] = key.split("/")
|
||||||
if (root !== "session") return
|
if (root !== "session") return
|
||||||
@@ -52,8 +45,10 @@ export namespace Share {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function init() {
|
export function init() {
|
||||||
await state()
|
Bus.subscribe(Storage.Event.Write, async (payload) => {
|
||||||
|
await sync(payload.properties.key, payload.properties.content)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const URL =
|
export const URL =
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { App } from "../app/app"
|
||||||
|
import {
|
||||||
|
add,
|
||||||
|
commit,
|
||||||
|
init,
|
||||||
|
checkout,
|
||||||
|
statusMatrix,
|
||||||
|
remove,
|
||||||
|
} from "isomorphic-git"
|
||||||
|
import path from "path"
|
||||||
|
import fs from "fs"
|
||||||
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
|
export namespace Snapshot {
|
||||||
|
const log = Log.create({ service: "snapshot" })
|
||||||
|
|
||||||
|
export async function create(sessionID: string) {
|
||||||
|
const app = App.info()
|
||||||
|
const git = gitdir(sessionID)
|
||||||
|
const files = await Ripgrep.files({
|
||||||
|
cwd: app.path.cwd,
|
||||||
|
limit: app.git ? undefined : 1000,
|
||||||
|
})
|
||||||
|
// not a git repo and too big to snapshot
|
||||||
|
if (!app.git && files.length === 1000) return
|
||||||
|
await init({
|
||||||
|
dir: app.path.cwd,
|
||||||
|
gitdir: git,
|
||||||
|
fs,
|
||||||
|
})
|
||||||
|
const status = await statusMatrix({
|
||||||
|
fs,
|
||||||
|
gitdir: git,
|
||||||
|
dir: app.path.cwd,
|
||||||
|
})
|
||||||
|
await add({
|
||||||
|
fs,
|
||||||
|
gitdir: git,
|
||||||
|
parallel: true,
|
||||||
|
dir: app.path.cwd,
|
||||||
|
filepath: files,
|
||||||
|
})
|
||||||
|
for (const [file, _head, workdir, stage] of status) {
|
||||||
|
if (workdir === 0 && stage === 1) {
|
||||||
|
log.info("remove", { file })
|
||||||
|
await remove({
|
||||||
|
fs,
|
||||||
|
gitdir: git,
|
||||||
|
dir: app.path.cwd,
|
||||||
|
filepath: file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await commit({
|
||||||
|
fs,
|
||||||
|
gitdir: git,
|
||||||
|
dir: app.path.cwd,
|
||||||
|
message: "snapshot",
|
||||||
|
author: {
|
||||||
|
name: "opencode",
|
||||||
|
email: "mail@opencode.ai",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
log.info("commit", { result })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restore(sessionID: string, commit: string) {
|
||||||
|
log.info("restore", { commit })
|
||||||
|
const app = App.info()
|
||||||
|
await checkout({
|
||||||
|
fs,
|
||||||
|
gitdir: gitdir(sessionID),
|
||||||
|
dir: app.path.cwd,
|
||||||
|
ref: commit,
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitdir(sessionID: string) {
|
||||||
|
const app = App.info()
|
||||||
|
return path.join(app.path.data, "snapshot", sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,11 @@ export namespace Storage {
|
|||||||
await fs.unlink(target).catch(() => {})
|
await fs.unlink(target).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeDir(key: string) {
|
||||||
|
const target = path.join(state().dir, key)
|
||||||
|
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
export async function readJSON<T>(key: string) {
|
export async function readJSON<T>(key: string) {
|
||||||
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
|
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import DESCRIPTION from "./bash.txt"
|
import DESCRIPTION from "./bash.txt"
|
||||||
|
import { App } from "../app/app"
|
||||||
|
|
||||||
const MAX_OUTPUT_LENGTH = 30000
|
const MAX_OUTPUT_LENGTH = 30000
|
||||||
const BANNED_COMMANDS = [
|
const BANNED_COMMANDS = [
|
||||||
@@ -49,6 +50,7 @@ export const BashTool = Tool.define({
|
|||||||
|
|
||||||
const process = Bun.spawn({
|
const process = Bun.spawn({
|
||||||
cmd: ["bash", "-c", params.command],
|
cmd: ["bash", "-c", params.command],
|
||||||
|
cwd: App.info().path.cwd,
|
||||||
maxBuffer: MAX_OUTPUT_LENGTH,
|
maxBuffer: MAX_OUTPUT_LENGTH,
|
||||||
signal: ctx.abort,
|
signal: ctx.abort,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
@@ -63,10 +65,18 @@ export const BashTool = Tool.define({
|
|||||||
metadata: {
|
metadata: {
|
||||||
stderr,
|
stderr,
|
||||||
stdout,
|
stdout,
|
||||||
|
exit: process.exitCode,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
title: params.command,
|
title: params.command,
|
||||||
},
|
},
|
||||||
output: stdout.replaceAll(/\x1b\[[0-9;]*m/g, ""),
|
output: [
|
||||||
|
`<stdout>`,
|
||||||
|
stdout ?? "",
|
||||||
|
`</stdout>`,
|
||||||
|
`<stderr>`,
|
||||||
|
stderr ?? "",
|
||||||
|
`</stderr>`,
|
||||||
|
].join("\n"),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
|
// the approaches in this edit tool are sourced from
|
||||||
|
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
|
||||||
|
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
|
||||||
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { FileTimes } from "./util/file-times"
|
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { createTwoFilesPatch } from "diff"
|
import { createTwoFilesPatch } from "diff"
|
||||||
import { Permission } from "../permission"
|
import { Permission } from "../permission"
|
||||||
import DESCRIPTION from "./edit.txt"
|
import DESCRIPTION from "./edit.txt"
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
|
import { File } from "../file"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { FileTime } from "../file/time"
|
||||||
|
|
||||||
export const EditTool = Tool.define({
|
export const EditTool = Tool.define({
|
||||||
id: "edit",
|
id: "edit",
|
||||||
@@ -22,13 +28,17 @@ export const EditTool = Tool.define({
|
|||||||
replaceAll: z
|
replaceAll: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Replace all occurences of old_string (default false)"),
|
.describe("Replace all occurrences of old_string (default false)"),
|
||||||
}),
|
}),
|
||||||
async execute(params, ctx) {
|
async execute(params, ctx) {
|
||||||
if (!params.filePath) {
|
if (!params.filePath) {
|
||||||
throw new Error("filePath is required")
|
throw new Error("filePath is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.oldString === params.newString) {
|
||||||
|
throw new Error("oldString and newString must be different")
|
||||||
|
}
|
||||||
|
|
||||||
const app = App.info()
|
const app = App.info()
|
||||||
const filepath = path.isAbsolute(params.filePath)
|
const filepath = path.isAbsolute(params.filePath)
|
||||||
? params.filePath
|
? params.filePath
|
||||||
@@ -51,47 +61,38 @@ export const EditTool = Tool.define({
|
|||||||
if (params.oldString === "") {
|
if (params.oldString === "") {
|
||||||
contentNew = params.newString
|
contentNew = params.newString
|
||||||
await Bun.write(filepath, params.newString)
|
await Bun.write(filepath, params.newString)
|
||||||
|
await Bus.publish(File.Event.Edited, {
|
||||||
|
file: filepath,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = Bun.file(filepath)
|
const file = Bun.file(filepath)
|
||||||
if (!(await file.exists())) throw new Error(`File ${filepath} not found`)
|
const stats = await file.stat().catch(() => {})
|
||||||
const stats = await file.stat()
|
if (!stats) throw new Error(`File ${filepath} not found`)
|
||||||
if (stats.isDirectory())
|
if (stats.isDirectory())
|
||||||
throw new Error(`Path is a directory, not a file: ${filepath}`)
|
throw new Error(`Path is a directory, not a file: ${filepath}`)
|
||||||
await FileTimes.assert(ctx.sessionID, filepath)
|
await FileTime.assert(ctx.sessionID, filepath)
|
||||||
contentOld = await file.text()
|
contentOld = await file.text()
|
||||||
const index = contentOld.indexOf(params.oldString)
|
|
||||||
if (index === -1)
|
|
||||||
throw new Error(
|
|
||||||
`oldString not found in file. Make sure it matches exactly, including whitespace and line breaks`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (params.replaceAll) {
|
|
||||||
contentNew = contentOld.replaceAll(params.oldString, params.newString)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params.replaceAll) {
|
|
||||||
const lastIndex = contentOld.lastIndexOf(params.oldString)
|
|
||||||
if (index !== lastIndex)
|
|
||||||
throw new Error(
|
|
||||||
`oldString appears multiple times in the file. Please provide more context to ensure a unique match`,
|
|
||||||
)
|
|
||||||
|
|
||||||
contentNew =
|
|
||||||
contentOld.substring(0, index) +
|
|
||||||
params.newString +
|
|
||||||
contentOld.substring(index + params.oldString.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
contentNew = replace(
|
||||||
|
contentOld,
|
||||||
|
params.oldString,
|
||||||
|
params.newString,
|
||||||
|
params.replaceAll,
|
||||||
|
)
|
||||||
await file.write(contentNew)
|
await file.write(contentNew)
|
||||||
|
await Bus.publish(File.Event.Edited, {
|
||||||
|
file: filepath,
|
||||||
|
})
|
||||||
|
contentNew = await file.text()
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const diff = trimDiff(
|
const diff = trimDiff(
|
||||||
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
|
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
|
||||||
)
|
)
|
||||||
|
|
||||||
FileTimes.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
let output = ""
|
let output = ""
|
||||||
await LSP.touchFile(filepath, true)
|
await LSP.touchFile(filepath, true)
|
||||||
@@ -116,6 +117,326 @@ export const EditTool = Tool.define({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export type Replacer = (
|
||||||
|
content: string,
|
||||||
|
find: string,
|
||||||
|
) => Generator<string, void, unknown>
|
||||||
|
|
||||||
|
export const SimpleReplacer: Replacer = function* (_content, find) {
|
||||||
|
yield find
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
||||||
|
const originalLines = content.split("\n")
|
||||||
|
const searchLines = find.split("\n")
|
||||||
|
|
||||||
|
if (searchLines[searchLines.length - 1] === "") {
|
||||||
|
searchLines.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
||||||
|
let matches = true
|
||||||
|
|
||||||
|
for (let j = 0; j < searchLines.length; j++) {
|
||||||
|
const originalTrimmed = originalLines[i + j].trim()
|
||||||
|
const searchTrimmed = searchLines[j].trim()
|
||||||
|
|
||||||
|
if (originalTrimmed !== searchTrimmed) {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches) {
|
||||||
|
let matchStartIndex = 0
|
||||||
|
for (let k = 0; k < i; k++) {
|
||||||
|
matchStartIndex += originalLines[k].length + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchEndIndex = matchStartIndex
|
||||||
|
for (let k = 0; k < searchLines.length; k++) {
|
||||||
|
matchEndIndex += originalLines[i + k].length + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
yield content.substring(matchStartIndex, matchEndIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||||
|
const originalLines = content.split("\n")
|
||||||
|
const searchLines = find.split("\n")
|
||||||
|
|
||||||
|
if (searchLines.length < 3) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchLines[searchLines.length - 1] === "") {
|
||||||
|
searchLines.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstLineSearch = searchLines[0].trim()
|
||||||
|
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||||
|
|
||||||
|
// Find blocks where first line matches the search first line
|
||||||
|
for (let i = 0; i < originalLines.length; i++) {
|
||||||
|
if (originalLines[i].trim() !== firstLineSearch) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for the matching last line after this first line
|
||||||
|
for (let j = i + 2; j < originalLines.length; j++) {
|
||||||
|
if (originalLines[j].trim() === lastLineSearch) {
|
||||||
|
// Found a potential block from i to j
|
||||||
|
let matchStartIndex = 0
|
||||||
|
for (let k = 0; k < i; k++) {
|
||||||
|
matchStartIndex += originalLines[k].length + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchEndIndex = matchStartIndex
|
||||||
|
for (let k = 0; k <= j - i; k++) {
|
||||||
|
matchEndIndex += originalLines[i + k].length
|
||||||
|
if (k < j - i) {
|
||||||
|
matchEndIndex += 1 // Add newline character except for the last line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield content.substring(matchStartIndex, matchEndIndex)
|
||||||
|
break // Only match the first occurrence of the last line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WhitespaceNormalizedReplacer: Replacer = function* (
|
||||||
|
content,
|
||||||
|
find,
|
||||||
|
) {
|
||||||
|
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
|
||||||
|
const normalizedFind = normalizeWhitespace(find)
|
||||||
|
|
||||||
|
// Handle single line matches
|
||||||
|
const lines = content.split("\n")
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
if (normalizeWhitespace(line) === normalizedFind) {
|
||||||
|
yield line
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for substring matches within lines
|
||||||
|
const normalizedLine = normalizeWhitespace(line)
|
||||||
|
if (normalizedLine.includes(normalizedFind)) {
|
||||||
|
// Find the actual substring in the original line that matches
|
||||||
|
const words = find.trim().split(/\s+/)
|
||||||
|
if (words.length > 0) {
|
||||||
|
const pattern = words
|
||||||
|
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||||
|
.join("\\s+")
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(pattern)
|
||||||
|
const match = line.match(regex)
|
||||||
|
if (match) {
|
||||||
|
yield match[0]
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid regex pattern, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle multi-line matches
|
||||||
|
const findLines = find.split("\n")
|
||||||
|
if (findLines.length > 1) {
|
||||||
|
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||||
|
const block = lines.slice(i, i + findLines.length)
|
||||||
|
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
|
||||||
|
yield block.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||||
|
const removeIndentation = (text: string) => {
|
||||||
|
const lines = text.split("\n")
|
||||||
|
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
|
||||||
|
if (nonEmptyLines.length === 0) return text
|
||||||
|
|
||||||
|
const minIndent = Math.min(
|
||||||
|
...nonEmptyLines.map((line) => {
|
||||||
|
const match = line.match(/^(\s*)/)
|
||||||
|
return match ? match[1].length : 0
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFind = removeIndentation(find)
|
||||||
|
const contentLines = content.split("\n")
|
||||||
|
const findLines = find.split("\n")
|
||||||
|
|
||||||
|
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
||||||
|
const block = contentLines.slice(i, i + findLines.length).join("\n")
|
||||||
|
if (removeIndentation(block) === normalizedFind) {
|
||||||
|
yield block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
||||||
|
const unescapeString = (str: string): string => {
|
||||||
|
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
|
||||||
|
switch (capturedChar) {
|
||||||
|
case "n":
|
||||||
|
return "\n"
|
||||||
|
case "t":
|
||||||
|
return "\t"
|
||||||
|
case "r":
|
||||||
|
return "\r"
|
||||||
|
case "'":
|
||||||
|
return "'"
|
||||||
|
case '"':
|
||||||
|
return '"'
|
||||||
|
case "`":
|
||||||
|
return "`"
|
||||||
|
case "\\":
|
||||||
|
return "\\"
|
||||||
|
case "\n":
|
||||||
|
return "\n"
|
||||||
|
case "$":
|
||||||
|
return "$"
|
||||||
|
default:
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unescapedFind = unescapeString(find)
|
||||||
|
|
||||||
|
// Try direct match with unescaped find string
|
||||||
|
if (content.includes(unescapedFind)) {
|
||||||
|
yield unescapedFind
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try finding escaped versions in content that match unescaped find
|
||||||
|
const lines = content.split("\n")
|
||||||
|
const findLines = unescapedFind.split("\n")
|
||||||
|
|
||||||
|
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||||
|
const block = lines.slice(i, i + findLines.length).join("\n")
|
||||||
|
const unescapedBlock = unescapeString(block)
|
||||||
|
|
||||||
|
if (unescapedBlock === unescapedFind) {
|
||||||
|
yield block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
|
||||||
|
// This replacer yields all exact matches, allowing the replace function
|
||||||
|
// to handle multiple occurrences based on replaceAll parameter
|
||||||
|
let startIndex = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const index = content.indexOf(find, startIndex)
|
||||||
|
if (index === -1) break
|
||||||
|
|
||||||
|
yield find
|
||||||
|
startIndex = index + find.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
||||||
|
const trimmedFind = find.trim()
|
||||||
|
|
||||||
|
if (trimmedFind === find) {
|
||||||
|
// Already trimmed, no point in trying
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find the trimmed version
|
||||||
|
if (content.includes(trimmedFind)) {
|
||||||
|
yield trimmedFind
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try finding blocks where trimmed content matches
|
||||||
|
const lines = content.split("\n")
|
||||||
|
const findLines = find.split("\n")
|
||||||
|
|
||||||
|
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||||
|
const block = lines.slice(i, i + findLines.length).join("\n")
|
||||||
|
|
||||||
|
if (block.trim() === trimmedFind) {
|
||||||
|
yield block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextAwareReplacer: Replacer = function* (content, find) {
|
||||||
|
const findLines = find.split("\n")
|
||||||
|
if (findLines.length < 3) {
|
||||||
|
// Need at least 3 lines to have meaningful context
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing empty line if present
|
||||||
|
if (findLines[findLines.length - 1] === "") {
|
||||||
|
findLines.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLines = content.split("\n")
|
||||||
|
|
||||||
|
// Extract first and last lines as context anchors
|
||||||
|
const firstLine = findLines[0].trim()
|
||||||
|
const lastLine = findLines[findLines.length - 1].trim()
|
||||||
|
|
||||||
|
// Find blocks that start and end with the context anchors
|
||||||
|
for (let i = 0; i < contentLines.length; i++) {
|
||||||
|
if (contentLines[i].trim() !== firstLine) continue
|
||||||
|
|
||||||
|
// Look for the matching last line
|
||||||
|
for (let j = i + 2; j < contentLines.length; j++) {
|
||||||
|
if (contentLines[j].trim() === lastLine) {
|
||||||
|
// Found a potential context block
|
||||||
|
const blockLines = contentLines.slice(i, j + 1)
|
||||||
|
const block = blockLines.join("\n")
|
||||||
|
|
||||||
|
// Check if the middle content has reasonable similarity
|
||||||
|
// (simple heuristic: at least 50% of non-empty lines should match when trimmed)
|
||||||
|
if (blockLines.length === findLines.length) {
|
||||||
|
let matchingLines = 0
|
||||||
|
let totalNonEmptyLines = 0
|
||||||
|
|
||||||
|
for (let k = 1; k < blockLines.length - 1; k++) {
|
||||||
|
const blockLine = blockLines[k].trim()
|
||||||
|
const findLine = findLines[k].trim()
|
||||||
|
|
||||||
|
if (blockLine.length > 0 || findLine.length > 0) {
|
||||||
|
totalNonEmptyLines++
|
||||||
|
if (blockLine === findLine) {
|
||||||
|
matchingLines++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
totalNonEmptyLines === 0 ||
|
||||||
|
matchingLines / totalNonEmptyLines >= 0.5
|
||||||
|
) {
|
||||||
|
yield block
|
||||||
|
break // Only match the first occurrence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function trimDiff(diff: string): string {
|
function trimDiff(diff: string): string {
|
||||||
const lines = diff.split("\n")
|
const lines = diff.split("\n")
|
||||||
const contentLines = lines.filter(
|
const contentLines = lines.filter(
|
||||||
@@ -151,3 +472,42 @@ function trimDiff(diff: string): string {
|
|||||||
|
|
||||||
return trimmedLines.join("\n")
|
return trimmedLines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function replace(
|
||||||
|
content: string,
|
||||||
|
oldString: string,
|
||||||
|
newString: string,
|
||||||
|
replaceAll = false,
|
||||||
|
): string {
|
||||||
|
if (oldString === newString) {
|
||||||
|
throw new Error("oldString and newString must be different")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const replacer of [
|
||||||
|
SimpleReplacer,
|
||||||
|
LineTrimmedReplacer,
|
||||||
|
BlockAnchorReplacer,
|
||||||
|
WhitespaceNormalizedReplacer,
|
||||||
|
IndentationFlexibleReplacer,
|
||||||
|
EscapeNormalizedReplacer,
|
||||||
|
TrimmedBoundaryReplacer,
|
||||||
|
ContextAwareReplacer,
|
||||||
|
MultiOccurrenceReplacer,
|
||||||
|
]) {
|
||||||
|
for (const search of replacer(content, oldString)) {
|
||||||
|
const index = content.indexOf(search)
|
||||||
|
if (index === -1) continue
|
||||||
|
if (replaceAll) {
|
||||||
|
return content.replaceAll(search, newString)
|
||||||
|
}
|
||||||
|
const lastIndex = content.lastIndexOf(search)
|
||||||
|
if (index !== lastIndex) continue
|
||||||
|
return (
|
||||||
|
content.substring(0, index) +
|
||||||
|
newString +
|
||||||
|
content.substring(index + search.length)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("oldString not found in content or was found multiple times")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from "path"
|
|||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
import DESCRIPTION from "./glob.txt"
|
import DESCRIPTION from "./glob.txt"
|
||||||
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
|
|
||||||
export const GlobTool = Tool.define({
|
export const GlobTool = Tool.define({
|
||||||
id: "glob",
|
id: "glob",
|
||||||
@@ -24,10 +25,12 @@ export const GlobTool = Tool.define({
|
|||||||
: path.resolve(app.path.cwd, search)
|
: path.resolve(app.path.cwd, search)
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
const glob = new Bun.Glob(params.pattern)
|
|
||||||
const files = []
|
const files = []
|
||||||
let truncated = false
|
let truncated = false
|
||||||
for await (const file of glob.scan({ cwd: search, dot: true })) {
|
for (const file of await Ripgrep.files({
|
||||||
|
cwd: search,
|
||||||
|
glob: params.pattern,
|
||||||
|
})) {
|
||||||
if (files.length >= limit) {
|
if (files.length >= limit) {
|
||||||
truncated = true
|
truncated = true
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
import { Ripgrep } from "../external/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
|
|
||||||
import DESCRIPTION from "./grep.txt"
|
import DESCRIPTION from "./grep.txt"
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ To make multiple file edits, provide the following:
|
|||||||
2. edits: An array of edit operations to perform, where each edit contains:
|
2. edits: An array of edit operations to perform, where each edit contains:
|
||||||
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
|
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
|
||||||
- new_string: The edited text to replace the old_string
|
- new_string: The edited text to replace the old_string
|
||||||
- replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false.
|
- replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
|
||||||
|
|
||||||
IMPORTANT:
|
IMPORTANT:
|
||||||
- All edits are applied in sequence, in the order they are provided
|
- All edits are applied in sequence, in the order they are provided
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from "zod"
|
|||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import * as fs from "fs/promises"
|
import * as fs from "fs/promises"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { FileTimes } from "./util/file-times"
|
import { FileTime } from "../file/time"
|
||||||
import DESCRIPTION from "./patch.txt"
|
import DESCRIPTION from "./patch.txt"
|
||||||
|
|
||||||
const PatchParams = z.object({
|
const PatchParams = z.object({
|
||||||
@@ -244,7 +244,7 @@ export const PatchTool = Tool.define({
|
|||||||
absPath = path.resolve(process.cwd(), absPath)
|
absPath = path.resolve(process.cwd(), absPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
await FileTimes.assert(ctx.sessionID, absPath)
|
await FileTime.assert(ctx.sessionID, absPath)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(absPath)
|
const stats = await fs.stat(absPath)
|
||||||
@@ -351,7 +351,7 @@ export const PatchTool = Tool.define({
|
|||||||
totalAdditions += additions
|
totalAdditions += additions
|
||||||
totalRemovals += removals
|
totalRemovals += removals
|
||||||
|
|
||||||
FileTimes.read(ctx.sessionID, absPath)
|
FileTime.read(ctx.sessionID, absPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
|
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as fs from "fs"
|
|||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { FileTimes } from "./util/file-times"
|
import { FileTime } from "../file/time"
|
||||||
import DESCRIPTION from "./read.txt"
|
import DESCRIPTION from "./read.txt"
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
|
|
||||||
@@ -89,8 +89,8 @@ export const ReadTool = Tool.define({
|
|||||||
output += "\n</file>"
|
output += "\n</file>"
|
||||||
|
|
||||||
// just warms the lsp client
|
// just warms the lsp client
|
||||||
await LSP.touchFile(filePath, true)
|
await LSP.touchFile(filePath, false)
|
||||||
FileTimes.read(ctx.sessionID, filePath)
|
FileTime.read(ctx.sessionID, filePath)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
output,
|
output,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Usage:
|
|||||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||||
- Any lines longer than 2000 characters will be truncated
|
- Any lines longer than 2000 characters will be truncated
|
||||||
- Results are returned using cat -n format, with line numbers starting at 1
|
- Results are returned using cat -n format, with line numbers starting at 1
|
||||||
- This tool allows OpenCode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as OpenCode is a multimodal LLM.
|
- This tool allows opencode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as opencode is a multimodal LLM.
|
||||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
||||||
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
|
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
|
||||||
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { FileTimes } from "./util/file-times"
|
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { Permission } from "../permission"
|
import { Permission } from "../permission"
|
||||||
import DESCRIPTION from "./write.txt"
|
import DESCRIPTION from "./write.txt"
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { File } from "../file"
|
||||||
|
import { FileTime } from "../file/time"
|
||||||
|
|
||||||
export const WriteTool = Tool.define({
|
export const WriteTool = Tool.define({
|
||||||
id: "write",
|
id: "write",
|
||||||
@@ -26,7 +28,7 @@ export const WriteTool = Tool.define({
|
|||||||
|
|
||||||
const file = Bun.file(filepath)
|
const file = Bun.file(filepath)
|
||||||
const exists = await file.exists()
|
const exists = await file.exists()
|
||||||
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
|
if (exists) await FileTime.assert(ctx.sessionID, filepath)
|
||||||
|
|
||||||
await Permission.ask({
|
await Permission.ask({
|
||||||
id: "write",
|
id: "write",
|
||||||
@@ -42,7 +44,10 @@ export const WriteTool = Tool.define({
|
|||||||
})
|
})
|
||||||
|
|
||||||
await Bun.write(filepath, params.content)
|
await Bun.write(filepath, params.content)
|
||||||
FileTimes.read(ctx.sessionID, filepath)
|
await Bus.publish(File.Event.Edited, {
|
||||||
|
file: filepath,
|
||||||
|
})
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
let output = ""
|
let output = ""
|
||||||
await LSP.touchFile(filepath, true)
|
await LSP.touchFile(filepath, true)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ export function lazy<T>(fn: () => T) {
|
|||||||
|
|
||||||
return (): T => {
|
return (): T => {
|
||||||
if (loaded) return value as T
|
if (loaded) return value as T
|
||||||
|
loaded = true
|
||||||
value = fn()
|
value = fn()
|
||||||
return value as T
|
return value as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export namespace Log {
|
|||||||
await fs.mkdir(dir, { recursive: true })
|
await fs.mkdir(dir, { recursive: true })
|
||||||
cleanup(dir)
|
cleanup(dir)
|
||||||
if (options.print) return
|
if (options.print) return
|
||||||
logpath = path.join(dir, new Date().toISOString().split(".")[0] + ".log")
|
logpath = path.join(
|
||||||
|
dir,
|
||||||
|
new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
||||||
|
)
|
||||||
const logfile = Bun.file(logpath)
|
const logfile = Bun.file(logpath)
|
||||||
await fs.truncate(logpath).catch(() => {})
|
await fs.truncate(logpath).catch(() => {})
|
||||||
const writer = logfile.writer()
|
const writer = logfile.writer()
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
|
let timeout: NodeJS.Timeout
|
||||||
|
return Promise.race([
|
||||||
|
promise.then((result) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
return result
|
||||||
|
}),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
reject(new Error(`Operation timed out after ${ms}ms`))
|
||||||
|
}, ms)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -6,4 +6,4 @@
|
|||||||
/// <reference path="../../sst-env.d.ts" />
|
/// <reference path="../../sst-env.d.ts" />
|
||||||
|
|
||||||
import "sst"
|
import "sst"
|
||||||
export {}
|
export {}
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { replace } from "../../src/tool/edit"
|
||||||
|
|
||||||
|
interface TestCase {
|
||||||
|
content: string
|
||||||
|
find: string
|
||||||
|
replace: string
|
||||||
|
all?: boolean
|
||||||
|
fail?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCases: TestCase[] = [
|
||||||
|
// SimpleReplacer cases
|
||||||
|
{
|
||||||
|
content: ["function hello() {", ' console.log("world");', "}"].join("\n"),
|
||||||
|
find: 'console.log("world");',
|
||||||
|
replace: 'console.log("universe");',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
"if (condition) {",
|
||||||
|
" doSomething();",
|
||||||
|
" doSomethingElse();",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
find: [" doSomething();", " doSomethingElse();"].join("\n"),
|
||||||
|
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// LineTrimmedReplacer cases
|
||||||
|
{
|
||||||
|
content: ["function test() {", ' console.log("hello");', "}"].join("\n"),
|
||||||
|
find: 'console.log("hello");',
|
||||||
|
replace: 'console.log("goodbye");',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: ["const x = 5; ", "const y = 10;"].join("\n"),
|
||||||
|
find: "const x = 5;",
|
||||||
|
replace: "const x = 15;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: [" if (true) {", " return false;", " }"].join("\n"),
|
||||||
|
find: ["if (true) {", "return false;", "}"].join("\n"),
|
||||||
|
replace: ["if (false) {", "return true;", "}"].join("\n"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// BlockAnchorReplacer cases
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
"function calculate(a, b) {",
|
||||||
|
" const temp = a + b;",
|
||||||
|
" const result = temp * 2;",
|
||||||
|
" return result;",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
find: [
|
||||||
|
"function calculate(a, b) {",
|
||||||
|
" // different middle content",
|
||||||
|
" return result;",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
"class MyClass {",
|
||||||
|
" constructor() {",
|
||||||
|
" this.value = 0;",
|
||||||
|
" }",
|
||||||
|
" ",
|
||||||
|
" getValue() {",
|
||||||
|
" return this.value;",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
|
||||||
|
replace: [
|
||||||
|
"class MyClass {",
|
||||||
|
" constructor() {",
|
||||||
|
" this.value = 42;",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// WhitespaceNormalizedReplacer cases
|
||||||
|
{
|
||||||
|
content: ["function test() {", '\tconsole.log("hello");', "}"].join("\n"),
|
||||||
|
find: ' console.log("hello");',
|
||||||
|
replace: ' console.log("world");',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "const x = 5;",
|
||||||
|
find: "const x = 5;",
|
||||||
|
replace: "const x = 10;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "if\t( condition\t) {",
|
||||||
|
find: "if ( condition ) {",
|
||||||
|
replace: "if (newCondition) {",
|
||||||
|
},
|
||||||
|
|
||||||
|
// IndentationFlexibleReplacer cases
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
" function nested() {",
|
||||||
|
' console.log("deeply nested");',
|
||||||
|
" return true;",
|
||||||
|
" }",
|
||||||
|
].join("\n"),
|
||||||
|
find: [
|
||||||
|
"function nested() {",
|
||||||
|
' console.log("deeply nested");',
|
||||||
|
" return true;",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
replace: [
|
||||||
|
"function nested() {",
|
||||||
|
' console.log("updated");',
|
||||||
|
" return false;",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
" if (true) {",
|
||||||
|
' console.log("level 1");',
|
||||||
|
' console.log("level 2");',
|
||||||
|
" }",
|
||||||
|
].join("\n"),
|
||||||
|
find: [
|
||||||
|
"if (true) {",
|
||||||
|
'console.log("level 1");',
|
||||||
|
' console.log("level 2");',
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// replaceAll option cases
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
'console.log("test");',
|
||||||
|
'console.log("test");',
|
||||||
|
'console.log("test");',
|
||||||
|
].join("\n"),
|
||||||
|
find: 'console.log("test");',
|
||||||
|
replace: 'console.log("updated");',
|
||||||
|
all: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: ['console.log("test");', 'console.log("test");'].join("\n"),
|
||||||
|
find: 'console.log("test");',
|
||||||
|
replace: 'console.log("updated");',
|
||||||
|
all: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Error cases
|
||||||
|
{
|
||||||
|
content: 'console.log("hello");',
|
||||||
|
find: "nonexistent string",
|
||||||
|
replace: "updated",
|
||||||
|
fail: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: ["test", "test", "different content", "test"].join("\n"),
|
||||||
|
find: "test",
|
||||||
|
replace: "updated",
|
||||||
|
all: false,
|
||||||
|
fail: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
{
|
||||||
|
content: "",
|
||||||
|
find: "",
|
||||||
|
replace: "new content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "const regex = /[.*+?^${}()|[\\\\]\\\\\\\\]/g;",
|
||||||
|
find: "/[.*+?^${}()|[\\\\]\\\\\\\\]/g",
|
||||||
|
replace: "/\\\\w+/g",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'const message = "Hello 世界! 🌍";',
|
||||||
|
find: "Hello 世界! 🌍",
|
||||||
|
replace: "Hello World! 🌎",
|
||||||
|
},
|
||||||
|
|
||||||
|
// EscapeNormalizedReplacer cases
|
||||||
|
{
|
||||||
|
content: 'console.log("Hello\nWorld");',
|
||||||
|
find: 'console.log("Hello\\nWorld");',
|
||||||
|
replace: 'console.log("Hello\nUniverse");',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "const str = 'It's working';",
|
||||||
|
find: "const str = 'It\\'s working';",
|
||||||
|
replace: "const str = 'It's fixed';",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "const template = `Hello ${name}`;",
|
||||||
|
find: "const template = `Hello \\${name}`;",
|
||||||
|
replace: "const template = `Hi ${name}`;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "const path = 'C:\\Users\\test';",
|
||||||
|
find: "const path = 'C:\\\\Users\\\\test';",
|
||||||
|
replace: "const path = 'C:\\Users\\admin';",
|
||||||
|
},
|
||||||
|
|
||||||
|
// MultiOccurrenceReplacer cases (with replaceAll)
|
||||||
|
{
|
||||||
|
content: ["debug('start');", "debug('middle');", "debug('end');"].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
find: "debug",
|
||||||
|
replace: "log",
|
||||||
|
all: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "const x = 1; const y = 1; const z = 1;",
|
||||||
|
find: "1",
|
||||||
|
replace: "2",
|
||||||
|
all: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// TrimmedBoundaryReplacer cases
|
||||||
|
{
|
||||||
|
content: [" function test() {", " return true;", " }"].join("\n"),
|
||||||
|
find: ["function test() {", " return true;", "}"].join("\n"),
|
||||||
|
replace: ["function test() {", " return false;", "}"].join("\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "\n const value = 42; \n",
|
||||||
|
find: "const value = 42;",
|
||||||
|
replace: "const value = 24;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: ["", " if (condition) {", " doSomething();", " }", ""].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
find: ["if (condition) {", " doSomething();", "}"].join("\n"),
|
||||||
|
replace: ["if (condition) {", " doNothing();", "}"].join("\n"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ContextAwareReplacer cases
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
"function calculate(a, b) {",
|
||||||
|
" const temp = a + b;",
|
||||||
|
" const result = temp * 2;",
|
||||||
|
" return result;",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
find: [
|
||||||
|
"function calculate(a, b) {",
|
||||||
|
" // some different content here",
|
||||||
|
" // more different content",
|
||||||
|
" return result;",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
"class TestClass {",
|
||||||
|
" constructor() {",
|
||||||
|
" this.value = 0;",
|
||||||
|
" }",
|
||||||
|
" ",
|
||||||
|
" method() {",
|
||||||
|
" return this.value;",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
find: [
|
||||||
|
"class TestClass {",
|
||||||
|
" // different implementation",
|
||||||
|
" // with multiple lines",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Combined edge cases for new replacers
|
||||||
|
{
|
||||||
|
content: '\tconsole.log("test");\t',
|
||||||
|
find: 'console.log("test");',
|
||||||
|
replace: 'console.log("updated");',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: [" ", "function test() {", " return 'value';", "}", " "].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
find: ["function test() {", "return 'value';", "}"].join("\n"),
|
||||||
|
replace: ["function test() {", "return 'new value';", "}"].join("\n"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test for same oldString and newString (should fail)
|
||||||
|
{
|
||||||
|
content: 'console.log("test");',
|
||||||
|
find: 'console.log("test");',
|
||||||
|
replace: 'console.log("test");',
|
||||||
|
fail: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Additional tests for fixes made
|
||||||
|
|
||||||
|
// WhitespaceNormalizedReplacer - test regex special characters that could cause errors
|
||||||
|
{
|
||||||
|
content: 'const pattern = "test[123]";',
|
||||||
|
find: "test[123]",
|
||||||
|
replace: "test[456]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'const regex = "^start.*end$";',
|
||||||
|
find: "^start.*end$",
|
||||||
|
replace: "^begin.*finish$",
|
||||||
|
},
|
||||||
|
|
||||||
|
// EscapeNormalizedReplacer - test single backslash vs double backslash
|
||||||
|
{
|
||||||
|
content: 'const path = "C:\\Users";',
|
||||||
|
find: 'const path = "C:\\Users";',
|
||||||
|
replace: 'const path = "D:\\Users";',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'console.log("Line1\\nLine2");',
|
||||||
|
find: 'console.log("Line1\\nLine2");',
|
||||||
|
replace: 'console.log("First\\nSecond");',
|
||||||
|
},
|
||||||
|
|
||||||
|
// BlockAnchorReplacer - test edge case with exact newline boundaries
|
||||||
|
{
|
||||||
|
content: ["function test() {", " return true;", "}"].join("\n"),
|
||||||
|
find: ["function test() {", " // middle", "}"].join("\n"),
|
||||||
|
replace: ["function test() {", " return false;", "}"].join("\n"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ContextAwareReplacer - test with trailing newline in find string
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
"class Test {",
|
||||||
|
" method1() {",
|
||||||
|
" return 1;",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
find: [
|
||||||
|
"class Test {",
|
||||||
|
" // different content",
|
||||||
|
"}",
|
||||||
|
"", // trailing empty line
|
||||||
|
].join("\n"),
|
||||||
|
replace: ["class Test {", " method2() { return 2; }", "}"].join("\n"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test validation for empty strings with same oldString and newString
|
||||||
|
{
|
||||||
|
content: "",
|
||||||
|
find: "",
|
||||||
|
replace: "",
|
||||||
|
fail: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test multiple occurrences with replaceAll=false (should fail)
|
||||||
|
{
|
||||||
|
content: ["const a = 1;", "const b = 1;", "const c = 1;"].join("\n"),
|
||||||
|
find: "= 1",
|
||||||
|
replace: "= 2",
|
||||||
|
all: false,
|
||||||
|
fail: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test whitespace normalization with multiple spaces and tabs mixed
|
||||||
|
{
|
||||||
|
content: "if\t \t( \tcondition\t )\t{",
|
||||||
|
find: "if ( condition ) {",
|
||||||
|
replace: "if (newCondition) {",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test escape sequences in template literals
|
||||||
|
{
|
||||||
|
content: "const msg = `Hello\\tWorld`;",
|
||||||
|
find: "const msg = `Hello\\tWorld`;",
|
||||||
|
replace: "const msg = `Hi\\tWorld`;",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe("EditTool Replacers", () => {
|
||||||
|
test.each(testCases)("case %#", (testCase) => {
|
||||||
|
if (testCase.fail) {
|
||||||
|
expect(() => {
|
||||||
|
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
|
||||||
|
}).toThrow()
|
||||||
|
} else {
|
||||||
|
const result = replace(
|
||||||
|
testCase.content,
|
||||||
|
testCase.find,
|
||||||
|
testCase.replace,
|
||||||
|
testCase.all,
|
||||||
|
)
|
||||||
|
expect(result).toContain(testCase.replace)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,7 +14,7 @@ describe("tool.glob", () => {
|
|||||||
await App.provide({ cwd: process.cwd() }, async () => {
|
await App.provide({ cwd: process.cwd() }, async () => {
|
||||||
let result = await GlobTool.execute(
|
let result = await GlobTool.execute(
|
||||||
{
|
{
|
||||||
pattern: "./node_modules/**/*",
|
pattern: "../../node_modules/**/*",
|
||||||
path: undefined,
|
path: undefined,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
@@ -33,7 +33,7 @@ describe("tool.glob", () => {
|
|||||||
)
|
)
|
||||||
expect(result.metadata).toMatchObject({
|
expect(result.metadata).toMatchObject({
|
||||||
truncated: false,
|
truncated: false,
|
||||||
count: 2,
|
count: 3,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
opencode-test
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# TUI Agent Guidelines
|
||||||
|
|
||||||
|
## Build/Test Commands
|
||||||
|
|
||||||
|
- **Build**: `go build ./cmd/opencode` (builds main binary)
|
||||||
|
- **Test**: `go test ./...` (runs all tests)
|
||||||
|
- **Single test**: `go test ./internal/theme -run TestLoadThemesFromJSON` (specific test)
|
||||||
|
- **Release build**: Uses `.goreleaser.yml` configuration
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- **Language**: Go 1.24+ with standard formatting (`gofmt`)
|
||||||
|
- **Imports**: Group standard, third-party, local packages with blank lines
|
||||||
|
- **Naming**: Go conventions - PascalCase exports, camelCase private, ALL_CAPS constants
|
||||||
|
- **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping
|
||||||
|
- **Structs**: Define clear interfaces, embed when appropriate
|
||||||
|
- **Testing**: Use table-driven tests, `t.TempDir()` for file operations
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **TUI Framework**: Bubble Tea v2 with Lipgloss v2 for styling
|
||||||
|
- **Client**: Generated OpenAPI client communicates with TypeScript server
|
||||||
|
- **Components**: Reusable UI components in `internal/components/`
|
||||||
|
- **Themes**: JSON-based theming system with override hierarchy
|
||||||
|
- **State**: Centralized app state with message passing
|
||||||
@@ -9,9 +9,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
|
"github.com/sst/opencode-sdk-go"
|
||||||
|
"github.com/sst/opencode-sdk-go/option"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/tui"
|
"github.com/sst/opencode/internal/tui"
|
||||||
"github.com/sst/opencode/pkg/client"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
@@ -25,8 +26,12 @@ func main() {
|
|||||||
url := os.Getenv("OPENCODE_SERVER")
|
url := os.Getenv("OPENCODE_SERVER")
|
||||||
|
|
||||||
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
|
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
|
||||||
var appInfo client.AppInfo
|
var appInfo opencode.App
|
||||||
json.Unmarshal([]byte(appInfoStr), &appInfo)
|
err := json.Unmarshal([]byte(appInfoStr), &appInfo)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to unmarshal app info", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
|
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
|
||||||
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
|
||||||
@@ -45,7 +50,12 @@ func main() {
|
|||||||
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
httpClient, err := client.NewClientWithResponses(url)
|
slog.Debug("TUI launched", "app", appInfo)
|
||||||
|
|
||||||
|
httpClient := opencode.NewClient(
|
||||||
|
option.WithBaseURL(url),
|
||||||
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to create client", "error", err)
|
slog.Error("Failed to create client", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -67,21 +77,15 @@ func main() {
|
|||||||
tea.WithMouseCellMotion(),
|
tea.WithMouseCellMotion(),
|
||||||
)
|
)
|
||||||
|
|
||||||
eventClient, err := client.NewClient(url)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to create event client", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
evts, err := eventClient.Event(ctx)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to subscribe to events", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for item := range evts {
|
stream := httpClient.Event.ListStreaming(ctx)
|
||||||
program.Send(item)
|
for stream.Next() {
|
||||||
|
evt := stream.Current().AsUnion()
|
||||||
|
program.Send(evt)
|
||||||
|
}
|
||||||
|
if err := stream.Err(); err != nil {
|
||||||
|
slog.Error("Error streaming events", "error", err)
|
||||||
|
program.Send(err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -14,8 +14,9 @@ require (
|
|||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||||
github.com/muesli/reflow v0.3.0
|
github.com/muesli/reflow v0.3.0
|
||||||
github.com/muesli/termenv v0.16.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/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
||||||
|
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
|
||||||
|
github.com/tidwall/gjson v1.14.4
|
||||||
rsc.io/qr v0.2.0
|
rsc.io/qr v0.2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +24,6 @@ require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
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/atombender/go-jsonschema v0.20.0 // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // 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/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
@@ -35,7 +35,6 @@ require (
|
|||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
github.com/go-openapi/swag v0.23.0 // indirect
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/goccy/go-yaml v1.17.1 // 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/invopop/yaml v0.3.1 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
@@ -48,6 +47,9 @@ require (
|
|||||||
github.com/sosodev/duration v1.3.1 // indirect
|
github.com/sosodev/duration v1.3.1 // indirect
|
||||||
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
|
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
|
||||||
github.com/spf13/cobra v1.9.1 // indirect
|
github.com/spf13/cobra v1.9.1 // 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
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||||
golang.org/x/mod v0.24.0 // indirect
|
golang.org/x/mod v0.24.0 // indirect
|
||||||
golang.org/x/tools v0.31.0 // indirect
|
golang.org/x/tools v0.31.0 // indirect
|
||||||
@@ -68,10 +70,10 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // 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/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // 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/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
|||||||
+12
-11
@@ -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/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 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
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 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
|
||||||
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
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 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
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 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
|
||||||
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
|
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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
@@ -23,7 +20,6 @@ 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/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 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
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/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
|
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/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
||||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
|
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
|
||||||
@@ -96,8 +92,6 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
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/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/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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
@@ -110,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/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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -148,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/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 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
|
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.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.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.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
@@ -190,14 +181,24 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
|||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
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 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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/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/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 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.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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
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 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||||
|
|||||||
+152
-149
@@ -6,37 +6,39 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
|
"github.com/sst/opencode-sdk-go"
|
||||||
"github.com/sst/opencode/internal/commands"
|
"github.com/sst/opencode/internal/commands"
|
||||||
|
"github.com/sst/opencode/internal/components/toast"
|
||||||
"github.com/sst/opencode/internal/config"
|
"github.com/sst/opencode/internal/config"
|
||||||
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/internal/util"
|
"github.com/sst/opencode/internal/util"
|
||||||
"github.com/sst/opencode/pkg/client"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var RootPath string
|
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
Info client.AppInfo
|
Info opencode.App
|
||||||
Version string
|
Version string
|
||||||
StatePath string
|
StatePath string
|
||||||
Config *client.ConfigInfo
|
Config *opencode.Config
|
||||||
Client *client.ClientWithResponses
|
Client *opencode.Client
|
||||||
State *config.State
|
State *config.State
|
||||||
Provider *client.ProviderInfo
|
Provider *opencode.Provider
|
||||||
Model *client.ModelInfo
|
Model *opencode.Model
|
||||||
Session *client.SessionInfo
|
Session *opencode.Session
|
||||||
Messages []client.MessageInfo
|
Messages []opencode.Message
|
||||||
Commands commands.CommandRegistry
|
Commands commands.CommandRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionSelectedMsg = *client.SessionInfo
|
type SessionSelectedMsg = *opencode.Session
|
||||||
|
type SessionLoadedMsg struct{}
|
||||||
type ModelSelectedMsg struct {
|
type ModelSelectedMsg struct {
|
||||||
Provider client.ProviderInfo
|
Provider opencode.Provider
|
||||||
Model client.ModelInfo
|
Model opencode.Model
|
||||||
}
|
}
|
||||||
type SessionClearedMsg struct{}
|
type SessionClearedMsg struct{}
|
||||||
type CompactSessionMsg struct{}
|
type CompactSessionMsg struct{}
|
||||||
@@ -44,32 +46,32 @@ type SendMsg struct {
|
|||||||
Text string
|
Text string
|
||||||
Attachments []Attachment
|
Attachments []Attachment
|
||||||
}
|
}
|
||||||
type CompletionDialogTriggerdMsg struct {
|
type CompletionDialogTriggeredMsg struct {
|
||||||
InitialValue string
|
InitialValue string
|
||||||
}
|
}
|
||||||
|
type OptimisticMessageAddedMsg struct {
|
||||||
|
Message opencode.Message
|
||||||
|
}
|
||||||
|
type FileRenderedMsg struct {
|
||||||
|
FilePath string
|
||||||
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
version string,
|
version string,
|
||||||
appInfo client.AppInfo,
|
appInfo opencode.App,
|
||||||
httpClient *client.ClientWithResponses,
|
httpClient *opencode.Client,
|
||||||
) (*App, error) {
|
) (*App, error) {
|
||||||
RootPath = appInfo.Path.Root
|
util.RootPath = appInfo.Path.Root
|
||||||
|
util.CwdPath = appInfo.Path.Cwd
|
||||||
|
|
||||||
configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
|
configInfo, err := httpClient.Config.Get(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if configResponse.StatusCode() != 200 || configResponse.JSON200 == nil {
|
|
||||||
return nil, fmt.Errorf("failed to get config: %d", configResponse.StatusCode())
|
if configInfo.Keybinds.Leader == "" {
|
||||||
}
|
configInfo.Keybinds.Leader = "ctrl+x"
|
||||||
configInfo := configResponse.JSON200
|
|
||||||
if configInfo.Keybinds == nil {
|
|
||||||
leader := "ctrl+x"
|
|
||||||
keybinds := client.ConfigKeybinds{
|
|
||||||
Leader: &leader,
|
|
||||||
}
|
|
||||||
configInfo.Keybinds = &keybinds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appStatePath := filepath.Join(appInfo.Path.State, "tui")
|
appStatePath := filepath.Join(appInfo.Path.State, "tui")
|
||||||
@@ -79,16 +81,16 @@ func New(
|
|||||||
config.SaveState(appStatePath, appState)
|
config.SaveState(appStatePath, appState)
|
||||||
}
|
}
|
||||||
|
|
||||||
if configInfo.Theme != nil {
|
if configInfo.Theme != "" {
|
||||||
appState.Theme = *configInfo.Theme
|
appState.Theme = configInfo.Theme
|
||||||
}
|
}
|
||||||
if configInfo.Model != nil {
|
|
||||||
splits := strings.Split(*configInfo.Model, "/")
|
if configInfo.Model != "" {
|
||||||
|
splits := strings.Split(configInfo.Model, "/")
|
||||||
appState.Provider = splits[0]
|
appState.Provider = splits[0]
|
||||||
appState.Model = strings.Join(splits[1:], "/")
|
appState.Model = strings.Join(splits[1:], "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load themes from all directories
|
|
||||||
if err := theme.LoadThemesFromDirectories(
|
if err := theme.LoadThemesFromDirectories(
|
||||||
appInfo.Path.Config,
|
appInfo.Path.Config,
|
||||||
appInfo.Path.Root,
|
appInfo.Path.Root,
|
||||||
@@ -98,6 +100,12 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if appState.Theme != "" {
|
if appState.Theme != "" {
|
||||||
|
if appState.Theme == "system" && styles.Terminal != nil {
|
||||||
|
theme.UpdateSystemTheme(
|
||||||
|
styles.Terminal.Background,
|
||||||
|
styles.Terminal.BackgroundIsDark,
|
||||||
|
)
|
||||||
|
}
|
||||||
theme.SetTheme(appState.Theme)
|
theme.SetTheme(appState.Theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,29 +118,42 @@ func New(
|
|||||||
Config: configInfo,
|
Config: configInfo,
|
||||||
State: appState,
|
State: appState,
|
||||||
Client: httpClient,
|
Client: httpClient,
|
||||||
Session: &client.SessionInfo{},
|
Session: &opencode.Session{},
|
||||||
Messages: []client.MessageInfo{},
|
Messages: []opencode.Message{},
|
||||||
Commands: commands.LoadFromConfig(configInfo),
|
Commands: commands.LoadFromConfig(configInfo),
|
||||||
}
|
}
|
||||||
|
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
kb := command.Keybindings[0]
|
||||||
|
key := kb.Key
|
||||||
|
if kb.RequiresLeader {
|
||||||
|
key = a.Config.Keybinds.Leader + " " + kb.Key
|
||||||
|
}
|
||||||
|
return base(key) + muted(" "+command.Description)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) InitializeProvider() tea.Cmd {
|
func (a *App) InitializeProvider() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
providersResponse, err := a.Client.PostProviderListWithResponse(context.Background())
|
providersResponse, err := a.Client.Config.Providers(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to list providers", "error", err)
|
slog.Error("Failed to list providers", "error", err)
|
||||||
// TODO: notify user
|
// TODO: notify user
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
providers := []client.ProviderInfo{}
|
providers := providersResponse.Providers
|
||||||
var defaultProvider *client.ProviderInfo
|
var defaultProvider *opencode.Provider
|
||||||
var defaultModel *client.ModelInfo
|
var defaultModel *opencode.Model
|
||||||
|
|
||||||
var anthropic *client.ProviderInfo
|
var anthropic *opencode.Provider
|
||||||
for _, provider := range providersResponse.JSON200.Providers {
|
for _, provider := range providers {
|
||||||
if provider.Id == "anthropic" {
|
if provider.ID == "anthropic" {
|
||||||
anthropic = &provider
|
anthropic = &provider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +164,7 @@ func (a *App) InitializeProvider() tea.Cmd {
|
|||||||
defaultModel = getDefaultModel(providersResponse, *anthropic)
|
defaultModel = getDefaultModel(providersResponse, *anthropic)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, provider := range providersResponse.JSON200.Providers {
|
for _, provider := range providers {
|
||||||
if defaultProvider == nil || defaultModel == nil {
|
if defaultProvider == nil || defaultModel == nil {
|
||||||
defaultProvider = &provider
|
defaultProvider = &provider
|
||||||
defaultModel = getDefaultModel(providersResponse, provider)
|
defaultModel = getDefaultModel(providersResponse, provider)
|
||||||
@@ -155,14 +176,14 @@ func (a *App) InitializeProvider() tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentProvider *client.ProviderInfo
|
var currentProvider *opencode.Provider
|
||||||
var currentModel *client.ModelInfo
|
var currentModel *opencode.Model
|
||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
if provider.Id == a.State.Provider {
|
if provider.ID == a.State.Provider {
|
||||||
currentProvider = &provider
|
currentProvider = &provider
|
||||||
|
|
||||||
for _, model := range provider.Models {
|
for _, model := range provider.Models {
|
||||||
if model.Id == a.State.Model {
|
if model.ID == a.State.Model {
|
||||||
currentModel = &model
|
currentModel = &model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,7 +194,6 @@ func (a *App) InitializeProvider() tea.Cmd {
|
|||||||
currentModel = defaultModel
|
currentModel = defaultModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: handle no provider or model setup, yet
|
|
||||||
return ModelSelectedMsg{
|
return ModelSelectedMsg{
|
||||||
Provider: *currentProvider,
|
Provider: *currentProvider,
|
||||||
Model: *currentModel,
|
Model: *currentModel,
|
||||||
@@ -181,8 +201,8 @@ func (a *App) InitializeProvider() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo {
|
func getDefaultModel(response *opencode.ConfigProvidersResponse, provider opencode.Provider) *opencode.Model {
|
||||||
if match, ok := response.JSON200.Default[provider.Id]; ok {
|
if match, ok := response.Default[provider.ID]; ok {
|
||||||
model := provider.Models[match]
|
model := provider.Models[match]
|
||||||
return &model
|
return &model
|
||||||
} else {
|
} else {
|
||||||
@@ -206,7 +226,7 @@ func (a *App) IsBusy() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastMessage := a.Messages[len(a.Messages)-1]
|
lastMessage := a.Messages[len(a.Messages)-1]
|
||||||
return lastMessage.Metadata.Time.Completed == nil
|
return lastMessage.Metadata.Time.Completed == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SaveState() {
|
func (a *App) SaveState() {
|
||||||
@@ -229,104 +249,96 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
|||||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
|
_, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
|
||||||
SessionID: a.Session.Id,
|
ProviderID: opencode.F(a.Provider.ID),
|
||||||
ProviderID: a.Provider.Id,
|
ModelID: opencode.F(a.Model.ID),
|
||||||
ModelID: a.Model.Id,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to initialize project", "error", err)
|
slog.Error("Failed to initialize project", "error", err)
|
||||||
// status.Error(err.Error())
|
// status.Error(err.Error())
|
||||||
}
|
}
|
||||||
if response != nil && response.StatusCode != 200 {
|
|
||||||
slog.Error("Failed to initialize project", "error", response.StatusCode)
|
|
||||||
// status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return tea.Batch(cmds...)
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
||||||
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
|
go func() {
|
||||||
SessionID: a.Session.Id,
|
_, err := a.Client.Session.Summarize(ctx, a.Session.ID, opencode.SessionSummarizeParams{
|
||||||
ProviderID: a.Provider.Id,
|
ProviderID: opencode.F(a.Provider.ID),
|
||||||
ModelID: a.Model.Id,
|
ModelID: opencode.F(a.Model.ID),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to compact session", "error", err)
|
slog.Error("Failed to compact session", "error", err)
|
||||||
}
|
}
|
||||||
if response != nil && response.StatusCode() != 200 {
|
}()
|
||||||
slog.Error("Failed to compact session", "error", response.StatusCode)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) MarkProjectInitialized(ctx context.Context) error {
|
func (a *App) MarkProjectInitialized(ctx context.Context) error {
|
||||||
response, err := a.Client.PostAppInitialize(ctx)
|
_, err := a.Client.App.Init(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to mark project as initialized", "error", err)
|
slog.Error("Failed to mark project as initialized", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if response != nil && response.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("failed to initialize project: %d", response.StatusCode)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CreateSession(ctx context.Context) (*client.SessionInfo, error) {
|
func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
|
||||||
resp, err := a.Client.PostSessionCreateWithResponse(ctx)
|
session, err := a.Client.Session.New(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if resp != nil && resp.StatusCode() != 200 {
|
|
||||||
return nil, fmt.Errorf("failed to create session: %d", resp.StatusCode())
|
|
||||||
}
|
|
||||||
session := resp.JSON200
|
|
||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
|
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
if a.Session.Id == "" {
|
if a.Session.ID == "" {
|
||||||
session, err := a.CreateSession(ctx)
|
session, err := a.CreateSession(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// status.Error(err.Error())
|
return toast.NewErrorToast(err.Error())
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
a.Session = session
|
a.Session = session
|
||||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Handle attachments when API supports them
|
optimisticMessage := opencode.Message{
|
||||||
if len(attachments) > 0 {
|
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
|
||||||
// For now, ignore attachments
|
Role: opencode.MessageRoleUser,
|
||||||
// return "", fmt.Errorf("attachments not supported yet")
|
Parts: []opencode.MessagePart{{
|
||||||
|
Type: opencode.MessagePartTypeText,
|
||||||
|
Text: text,
|
||||||
|
}},
|
||||||
|
Metadata: opencode.MessageMetadata{
|
||||||
|
SessionID: a.Session.ID,
|
||||||
|
Time: opencode.MessageMetadataTime{
|
||||||
|
Created: float64(time.Now().Unix()),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
part := client.MessagePart{}
|
a.Messages = append(a.Messages, optimisticMessage)
|
||||||
part.FromMessagePartText(client.MessagePartText{
|
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
|
||||||
Type: "text",
|
|
||||||
Text: text,
|
|
||||||
})
|
|
||||||
parts := []client.MessagePart{part}
|
|
||||||
|
|
||||||
go func() {
|
cmds = append(cmds, func() tea.Msg {
|
||||||
response, err := a.Client.PostSessionChat(ctx, client.PostSessionChatJSONRequestBody{
|
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
|
||||||
SessionID: a.Session.Id,
|
Parts: opencode.F([]opencode.MessagePartUnionParam{
|
||||||
Parts: parts,
|
opencode.TextPartParam{
|
||||||
ProviderID: a.Provider.Id,
|
Type: opencode.F(opencode.TextPartTypeText),
|
||||||
ModelID: a.Model.Id,
|
Text: opencode.F(text),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ProviderID: opencode.F(a.Provider.ID),
|
||||||
|
ModelID: opencode.F(a.Model.ID),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to send message", "error", err)
|
errormsg := fmt.Sprintf("failed to send message: %v", err)
|
||||||
// status.Error(err.Error())
|
slog.Error(errormsg)
|
||||||
|
return toast.NewErrorToast(errormsg)()
|
||||||
}
|
}
|
||||||
if response != nil && response.StatusCode != 200 {
|
return nil
|
||||||
slog.Error("Failed to send message", "error", fmt.Sprintf("failed to send message: %d", response.StatusCode))
|
})
|
||||||
// status.Error(fmt.Sprintf("failed to send message: %d", response.StatusCode))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// The actual response will come through SSE
|
// The actual response will come through SSE
|
||||||
// For now, just return success
|
// For now, just return success
|
||||||
@@ -334,70 +346,61 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
||||||
response, err := a.Client.PostSessionAbort(ctx, client.PostSessionAbortJSONRequestBody{
|
_, err := a.Client.Session.Abort(ctx, sessionID)
|
||||||
SessionID: sessionID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to cancel session", "error", err)
|
slog.Error("Failed to cancel session", "error", err)
|
||||||
// status.Error(err.Error())
|
// status.Error(err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if response != nil && response.StatusCode != 200 {
|
return nil
|
||||||
slog.Error("Failed to cancel session", "error", fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
|
}
|
||||||
// status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
|
|
||||||
return fmt.Errorf("failed to cancel session: %d", response.StatusCode)
|
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
|
||||||
|
sort.Slice(sessions, func(i, j int) bool {
|
||||||
|
return sessions[i].Time.Created-sessions[j].Time.Created > 0
|
||||||
|
})
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
|
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]opencode.Message, error) {
|
||||||
resp, err := a.Client.PostSessionListWithResponse(ctx)
|
response, err := a.Client.Session.Messages(ctx, sessionId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if resp.StatusCode() != 200 {
|
if response == nil {
|
||||||
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
|
return []opencode.Message{}, nil
|
||||||
}
|
}
|
||||||
if resp.JSON200 == nil {
|
messages := *response
|
||||||
return []client.SessionInfo{}, nil
|
|
||||||
}
|
|
||||||
sessions := *resp.JSON200
|
|
||||||
|
|
||||||
sort.Slice(sessions, func(i, j int) bool {
|
|
||||||
return sessions[i].Time.Created-sessions[j].Time.Created > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return sessions, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) {
|
|
||||||
resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if resp.StatusCode() != 200 {
|
|
||||||
return nil, fmt.Errorf("failed to list messages: %d", resp.StatusCode())
|
|
||||||
}
|
|
||||||
if resp.JSON200 == nil {
|
|
||||||
return []client.MessageInfo{}, nil
|
|
||||||
}
|
|
||||||
messages := *resp.JSON200
|
|
||||||
return messages, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error) {
|
func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
|
||||||
resp, err := a.Client.PostProviderListWithResponse(ctx)
|
response, err := a.Client.Config.Providers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if resp.StatusCode() != 200 {
|
if response == nil {
|
||||||
return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
|
return []opencode.Provider{}, nil
|
||||||
}
|
|
||||||
if resp.JSON200 == nil {
|
|
||||||
return []client.ProviderInfo{}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
providers := *resp.JSON200
|
providers := *response
|
||||||
return providers.Providers, nil
|
return providers.Providers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/sst/opencode/pkg/client"
|
"github.com/sst/opencode-sdk-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExecuteCommandMsg Command
|
type ExecuteCommandMsg Command
|
||||||
@@ -80,13 +80,15 @@ const (
|
|||||||
ToolDetailsCommand CommandName = "tool_details"
|
ToolDetailsCommand CommandName = "tool_details"
|
||||||
ModelListCommand CommandName = "model_list"
|
ModelListCommand CommandName = "model_list"
|
||||||
ThemeListCommand CommandName = "theme_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"
|
ProjectInitCommand CommandName = "project_init"
|
||||||
InputClearCommand CommandName = "input_clear"
|
InputClearCommand CommandName = "input_clear"
|
||||||
InputPasteCommand CommandName = "input_paste"
|
InputPasteCommand CommandName = "input_paste"
|
||||||
InputSubmitCommand CommandName = "input_submit"
|
InputSubmitCommand CommandName = "input_submit"
|
||||||
InputNewlineCommand CommandName = "input_newline"
|
InputNewlineCommand CommandName = "input_newline"
|
||||||
HistoryPreviousCommand CommandName = "history_previous"
|
|
||||||
HistoryNextCommand CommandName = "history_next"
|
|
||||||
MessagesPageUpCommand CommandName = "messages_page_up"
|
MessagesPageUpCommand CommandName = "messages_page_up"
|
||||||
MessagesPageDownCommand CommandName = "messages_page_down"
|
MessagesPageDownCommand CommandName = "messages_page_down"
|
||||||
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
|
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
|
||||||
@@ -95,6 +97,9 @@ const (
|
|||||||
MessagesNextCommand CommandName = "messages_next"
|
MessagesNextCommand CommandName = "messages_next"
|
||||||
MessagesFirstCommand CommandName = "messages_first"
|
MessagesFirstCommand CommandName = "messages_first"
|
||||||
MessagesLastCommand CommandName = "messages_last"
|
MessagesLastCommand CommandName = "messages_last"
|
||||||
|
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
|
||||||
|
MessagesCopyCommand CommandName = "messages_copy"
|
||||||
|
MessagesRevertCommand CommandName = "messages_revert"
|
||||||
AppExitCommand CommandName = "app_exit"
|
AppExitCommand CommandName = "app_exit"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,7 +128,7 @@ func parseBindings(bindings ...string) []Keybinding {
|
|||||||
return parsedBindings
|
return parsedBindings
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
|
func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||||
defaults := []Command{
|
defaults := []Command{
|
||||||
{
|
{
|
||||||
Name: AppHelpCommand,
|
Name: AppHelpCommand,
|
||||||
@@ -184,6 +189,27 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
|
|||||||
Keybindings: parseBindings("<leader>t"),
|
Keybindings: parseBindings("<leader>t"),
|
||||||
Trigger: "themes",
|
Trigger: "themes",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: FileListCommand,
|
||||||
|
Description: "list files",
|
||||||
|
Keybindings: parseBindings("<leader>f"),
|
||||||
|
Trigger: "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,
|
Name: ProjectInitCommand,
|
||||||
Description: "create/update AGENTS.md",
|
Description: "create/update AGENTS.md",
|
||||||
@@ -210,16 +236,6 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
|
|||||||
Description: "insert newline",
|
Description: "insert newline",
|
||||||
Keybindings: parseBindings("shift+enter", "ctrl+j"),
|
Keybindings: parseBindings("shift+enter", "ctrl+j"),
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// Name: HistoryPreviousCommand,
|
|
||||||
// Description: "previous prompt",
|
|
||||||
// Keybindings: parseBindings("up"),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Name: HistoryNextCommand,
|
|
||||||
// Description: "next prompt",
|
|
||||||
// Keybindings: parseBindings("down"),
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
Name: MessagesPageUpCommand,
|
Name: MessagesPageUpCommand,
|
||||||
Description: "page up",
|
Description: "page up",
|
||||||
@@ -243,12 +259,12 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
|
|||||||
{
|
{
|
||||||
Name: MessagesPreviousCommand,
|
Name: MessagesPreviousCommand,
|
||||||
Description: "previous message",
|
Description: "previous message",
|
||||||
Keybindings: parseBindings("ctrl+alt+k"),
|
Keybindings: parseBindings("ctrl+up"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: MessagesNextCommand,
|
Name: MessagesNextCommand,
|
||||||
Description: "next message",
|
Description: "next message",
|
||||||
Keybindings: parseBindings("ctrl+alt+j"),
|
Keybindings: parseBindings("ctrl+down"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: MessagesFirstCommand,
|
Name: MessagesFirstCommand,
|
||||||
@@ -260,6 +276,21 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
|
|||||||
Description: "last message",
|
Description: "last message",
|
||||||
Keybindings: parseBindings("ctrl+alt+g"),
|
Keybindings: parseBindings("ctrl+alt+g"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: MessagesLayoutToggleCommand,
|
||||||
|
Description: "toggle layout",
|
||||||
|
Keybindings: parseBindings("<leader>p"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: MessagesCopyCommand,
|
||||||
|
Description: "copy message",
|
||||||
|
Keybindings: parseBindings("<leader>y"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: MessagesRevertCommand,
|
||||||
|
Description: "revert message",
|
||||||
|
Keybindings: parseBindings("<leader>u"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: AppExitCommand,
|
Name: AppExitCommand,
|
||||||
Description: "exit the app",
|
Description: "exit the app",
|
||||||
@@ -269,10 +300,10 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
|
|||||||
}
|
}
|
||||||
registry := make(CommandRegistry)
|
registry := make(CommandRegistry)
|
||||||
keybinds := map[string]string{}
|
keybinds := map[string]string{}
|
||||||
marshalled, _ := json.Marshal(*config.Keybinds)
|
marshalled, _ := json.Marshal(config.Keybinds)
|
||||||
json.Unmarshal(marshalled, &keybinds)
|
json.Unmarshal(marshalled, &keybinds)
|
||||||
for _, command := range defaults {
|
for _, command := range defaults {
|
||||||
if keybind, ok := keybinds[string(command.Name)]; ok {
|
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
|
||||||
command.Keybindings = parseBindings(keybind)
|
command.Keybindings = parseBindings(keybind)
|
||||||
}
|
}
|
||||||
registry[command.Name] = command
|
registry[command.Name] = command
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/commands"
|
"github.com/sst/opencode/internal/commands"
|
||||||
"github.com/sst/opencode/internal/components/dialog"
|
"github.com/sst/opencode/internal/components/dialog"
|
||||||
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,20 +25,13 @@ func (c *CommandCompletionProvider) GetId() string {
|
|||||||
return "commands"
|
return "commands"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
|
|
||||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
|
||||||
Title: "Commands",
|
|
||||||
Value: "commands",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
||||||
return "no matching commands"
|
return "no matching commands"
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
|
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
|
||||||
spacer := strings.Repeat(" ", space)
|
spacer := strings.Repeat(" ", space)
|
||||||
title := " /" + cmd.Trigger + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
|
title := " /" + cmd.Trigger + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
|
||||||
value := string(cmd.Name)
|
value := string(cmd.Name)
|
||||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
return dialog.NewCompletionItem(dialog.CompletionItem{
|
||||||
Title: title,
|
Title: title,
|
||||||
|
|||||||
@@ -2,67 +2,108 @@ package completions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sst/opencode-sdk-go"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/components/dialog"
|
"github.com/sst/opencode/internal/components/dialog"
|
||||||
"github.com/sst/opencode/pkg/client"
|
"github.com/sst/opencode/internal/styles"
|
||||||
|
"github.com/sst/opencode/internal/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
type filesAndFoldersContextGroup struct {
|
type filesAndFoldersContextGroup struct {
|
||||||
app *app.App
|
app *app.App
|
||||||
prefix string
|
prefix string
|
||||||
|
gitFiles []dialog.CompletionItemI
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) GetId() string {
|
func (cg *filesAndFoldersContextGroup) GetId() string {
|
||||||
return cg.prefix
|
return cg.prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
|
|
||||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
|
||||||
Title: "Files & Folders",
|
|
||||||
Value: "files",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
|
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
|
||||||
return "no matching files"
|
return "no matching files"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
|
func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
|
||||||
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
|
t := theme.CurrentTheme()
|
||||||
Query: query,
|
items := make([]dialog.CompletionItemI, 0)
|
||||||
})
|
base := styles.NewStyle().Background(t.BackgroundElement())
|
||||||
if err != nil {
|
green := base.Foreground(t.Success()).Render
|
||||||
return []string{}, err
|
red := base.Foreground(t.Error()).Render
|
||||||
}
|
|
||||||
if response.JSON200 == nil {
|
status, _ := cg.app.Client.File.Status(context.Background())
|
||||||
return []string{}, nil
|
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 {
|
||||||
|
title := file.File
|
||||||
|
if file.Added > 0 {
|
||||||
|
title += green(" +" + strconv.Itoa(int(file.Added)))
|
||||||
|
}
|
||||||
|
if file.Removed > 0 {
|
||||||
|
title += red(" -" + strconv.Itoa(int(file.Removed)))
|
||||||
|
}
|
||||||
|
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
||||||
|
Title: title,
|
||||||
|
Value: file.File,
|
||||||
|
})
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return *response.JSON200, nil
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
||||||
matches, err := cg.getFiles(query)
|
items := make([]dialog.CompletionItemI, 0)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
query = strings.TrimSpace(query)
|
||||||
|
if query == "" {
|
||||||
|
items = append(items, cg.gitFiles...)
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]dialog.CompletionItemI, 0, len(matches))
|
files, err := cg.app.Client.Find.Files(
|
||||||
for _, file := range matches {
|
context.Background(),
|
||||||
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
opencode.FindFilesParams{Query: opencode.F(query)},
|
||||||
Title: file,
|
)
|
||||||
Value: file,
|
if err != nil {
|
||||||
})
|
slog.Error("Failed to get completion items", "error", err)
|
||||||
items = append(items, item)
|
}
|
||||||
|
|
||||||
|
for _, file := range *files {
|
||||||
|
exists := false
|
||||||
|
for _, existing := range cg.gitFiles {
|
||||||
|
if existing.GetValue() == file {
|
||||||
|
if query != "" {
|
||||||
|
items = append(items, existing)
|
||||||
|
}
|
||||||
|
exists = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
||||||
|
Title: file,
|
||||||
|
Value: file,
|
||||||
|
})
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
|
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
|
||||||
return &filesAndFoldersContextGroup{
|
cg := &filesAndFoldersContextGroup{
|
||||||
app: app,
|
app: app,
|
||||||
prefix: "file",
|
prefix: "file",
|
||||||
}
|
}
|
||||||
|
cg.gitFiles = cg.getGitFiles()
|
||||||
|
return cg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"github.com/sst/opencode/internal/components/dialog"
|
"github.com/sst/opencode/internal/components/dialog"
|
||||||
"github.com/sst/opencode/internal/components/textarea"
|
"github.com/sst/opencode/internal/components/textarea"
|
||||||
"github.com/sst/opencode/internal/image"
|
"github.com/sst/opencode/internal/image"
|
||||||
"github.com/sst/opencode/internal/layout"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/internal/util"
|
"github.com/sst/opencode/internal/util"
|
||||||
@@ -21,32 +20,30 @@ import (
|
|||||||
|
|
||||||
type EditorComponent interface {
|
type EditorComponent interface {
|
||||||
tea.Model
|
tea.Model
|
||||||
tea.ViewModel
|
View(width int) string
|
||||||
layout.Sizeable
|
Content(width int) string
|
||||||
Content() string
|
|
||||||
Lines() int
|
Lines() int
|
||||||
Value() string
|
Value() string
|
||||||
|
Focused() bool
|
||||||
|
Focus() (tea.Model, tea.Cmd)
|
||||||
|
Blur()
|
||||||
Submit() (tea.Model, tea.Cmd)
|
Submit() (tea.Model, tea.Cmd)
|
||||||
Clear() (tea.Model, tea.Cmd)
|
Clear() (tea.Model, tea.Cmd)
|
||||||
Paste() (tea.Model, tea.Cmd)
|
Paste() (tea.Model, tea.Cmd)
|
||||||
Newline() (tea.Model, tea.Cmd)
|
Newline() (tea.Model, tea.Cmd)
|
||||||
Previous() (tea.Model, tea.Cmd)
|
SetInterruptKeyInDebounce(inDebounce bool)
|
||||||
Next() (tea.Model, tea.Cmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type editorComponent struct {
|
type editorComponent struct {
|
||||||
app *app.App
|
app *app.App
|
||||||
width, height int
|
textarea textarea.Model
|
||||||
textarea textarea.Model
|
attachments []app.Attachment
|
||||||
attachments []app.Attachment
|
spinner spinner.Model
|
||||||
history []string
|
interruptKeyInDebounce bool
|
||||||
historyIndex int
|
|
||||||
currentMessage string
|
|
||||||
spinner spinner.Model
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Init() tea.Cmd {
|
func (m *editorComponent) Init() tea.Cmd {
|
||||||
return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus)
|
return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -67,7 +64,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case dialog.ThemeSelectedMsg:
|
case dialog.ThemeSelectedMsg:
|
||||||
m.textarea = createTextArea(&m.textarea)
|
m.textarea = createTextArea(&m.textarea)
|
||||||
m.spinner = createSpinner()
|
m.spinner = createSpinner()
|
||||||
return m, tea.Batch(m.spinner.Tick, textarea.Blink)
|
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
|
||||||
case dialog.CompletionSelectedMsg:
|
case dialog.CompletionSelectedMsg:
|
||||||
if msg.IsCommand {
|
if msg.IsCommand {
|
||||||
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
||||||
@@ -78,8 +75,15 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
} else {
|
} else {
|
||||||
existingValue := m.textarea.Value()
|
existingValue := m.textarea.Value()
|
||||||
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
|
|
||||||
m.textarea.SetValue(modifiedValue + " ")
|
// Replace the current token (after last space)
|
||||||
|
lastSpaceIndex := strings.LastIndex(existingValue, " ")
|
||||||
|
if lastSpaceIndex == -1 {
|
||||||
|
m.textarea.SetValue(msg.CompletionValue + " ")
|
||||||
|
} else {
|
||||||
|
modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
|
||||||
|
m.textarea.SetValue(modifiedValue + " ")
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,31 +97,41 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Content() string {
|
func (m *editorComponent) Content(width int) string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
base := styles.BaseStyle().Background(t.Background()).Render
|
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||||
muted := styles.Muted().Background(t.Background()).Render
|
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||||
promptStyle := lipgloss.NewStyle().
|
promptStyle := styles.NewStyle().Foreground(t.Primary()).
|
||||||
Padding(0, 0, 0, 1).
|
Padding(0, 0, 0, 1).
|
||||||
Bold(true).
|
Bold(true)
|
||||||
Foreground(t.Primary())
|
|
||||||
prompt := promptStyle.Render(">")
|
prompt := promptStyle.Render(">")
|
||||||
|
|
||||||
|
m.textarea.SetWidth(width - 6)
|
||||||
textarea := lipgloss.JoinHorizontal(
|
textarea := lipgloss.JoinHorizontal(
|
||||||
lipgloss.Top,
|
lipgloss.Top,
|
||||||
prompt,
|
prompt,
|
||||||
m.textarea.View(),
|
m.textarea.View(),
|
||||||
)
|
)
|
||||||
textarea = styles.BaseStyle().
|
textarea = styles.NewStyle().
|
||||||
Width(m.width).
|
Background(t.BackgroundElement()).
|
||||||
|
Width(width).
|
||||||
PaddingTop(1).
|
PaddingTop(1).
|
||||||
PaddingBottom(1).
|
PaddingBottom(1).
|
||||||
Background(t.BackgroundElement()).
|
BorderStyle(lipgloss.ThickBorder()).
|
||||||
|
BorderForeground(t.Border()).
|
||||||
|
BorderBackground(t.Background()).
|
||||||
|
BorderLeft(true).
|
||||||
|
BorderRight(true).
|
||||||
Render(textarea)
|
Render(textarea)
|
||||||
|
|
||||||
hint := base("enter") + muted(" send ")
|
hint := base(m.getSubmitKeyText()) + muted(" send ")
|
||||||
if m.app.IsBusy() {
|
if m.app.IsBusy() {
|
||||||
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
|
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 := ""
|
model := ""
|
||||||
@@ -125,33 +139,40 @@ func (m *editorComponent) Content() string {
|
|||||||
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
|
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
|
space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
|
||||||
spacer := lipgloss.NewStyle().Background(t.Background()).Width(space).Render("")
|
spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
|
||||||
|
|
||||||
info := hint + spacer + model
|
info := hint + spacer + model
|
||||||
info = styles.Padded().Background(t.Background()).Render(info)
|
info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
|
||||||
|
|
||||||
content := strings.Join([]string{"", textarea, info}, "\n")
|
content := strings.Join([]string{"", textarea, info}, "\n")
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) View() string {
|
func (m *editorComponent) View(width int) string {
|
||||||
if m.Lines() > 1 {
|
if m.Lines() > 1 {
|
||||||
return ""
|
return lipgloss.Place(
|
||||||
|
width,
|
||||||
|
5,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
"",
|
||||||
|
styles.WhitespaceStyle(theme.CurrentTheme().Background()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return m.Content()
|
return m.Content(width)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) GetSize() (width, height int) {
|
func (m *editorComponent) Focused() bool {
|
||||||
return m.width, m.height
|
return m.textarea.Focused()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
|
func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
|
||||||
m.width = width
|
return m, m.textarea.Focus()
|
||||||
m.height = height
|
}
|
||||||
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
|
|
||||||
// m.textarea.SetHeight(height - 4)
|
func (m *editorComponent) Blur() {
|
||||||
return nil
|
m.textarea.Blur()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Lines() int {
|
func (m *editorComponent) Lines() int {
|
||||||
@@ -179,16 +200,6 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
|||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
attachments := m.attachments
|
attachments := m.attachments
|
||||||
|
|
||||||
// Save to history if not empty and not a duplicate of the last entry
|
|
||||||
if value != "" {
|
|
||||||
if len(m.history) == 0 || m.history[len(m.history)-1] != value {
|
|
||||||
m.history = append(m.history, value)
|
|
||||||
}
|
|
||||||
m.historyIndex = len(m.history)
|
|
||||||
m.currentMessage = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
m.attachments = nil
|
m.attachments = nil
|
||||||
|
|
||||||
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
|
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
|
||||||
@@ -221,46 +232,16 @@ func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
|
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
|
||||||
currentLine := m.textarea.Line()
|
m.interruptKeyInDebounce = inDebounce
|
||||||
|
|
||||||
// Only navigate history if we're at the first line
|
|
||||||
if currentLine == 0 && len(m.history) > 0 {
|
|
||||||
// Save current message if we're just starting to navigate
|
|
||||||
if m.historyIndex == len(m.history) {
|
|
||||||
m.currentMessage = m.textarea.Value()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go to previous message in history
|
|
||||||
if m.historyIndex > 0 {
|
|
||||||
m.historyIndex--
|
|
||||||
m.textarea.SetValue(m.history[m.historyIndex])
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
|
func (m *editorComponent) getInterruptKeyText() string {
|
||||||
currentLine := m.textarea.Line()
|
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
|
||||||
value := m.textarea.Value()
|
}
|
||||||
lines := strings.Split(value, "\n")
|
|
||||||
totalLines := len(lines)
|
|
||||||
|
|
||||||
// Only navigate history if we're at the last line
|
func (m *editorComponent) getSubmitKeyText() string {
|
||||||
if currentLine == totalLines-1 {
|
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
|
||||||
if m.historyIndex < len(m.history)-1 {
|
|
||||||
// Go to next message in history
|
|
||||||
m.historyIndex++
|
|
||||||
m.textarea.SetValue(m.history[m.historyIndex])
|
|
||||||
} else if m.historyIndex == len(m.history)-1 {
|
|
||||||
// Return to the current message being composed
|
|
||||||
m.historyIndex = len(m.history)
|
|
||||||
m.textarea.SetValue(m.currentMessage)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTextArea(existing *textarea.Model) textarea.Model {
|
func createTextArea(existing *textarea.Model) textarea.Model {
|
||||||
@@ -271,14 +252,14 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
|||||||
|
|
||||||
ta := textarea.New()
|
ta := textarea.New()
|
||||||
|
|
||||||
ta.Styles.Blurred.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
|
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Blurred.CursorLine = lipgloss.NewStyle().Background(bgColor)
|
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Blurred.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
|
ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Blurred.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
|
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Focused.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
|
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Focused.CursorLine = lipgloss.NewStyle().Background(bgColor)
|
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Focused.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
|
ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Focused.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
|
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Cursor.Color = t.Primary()
|
ta.Styles.Cursor.Color = t.Primary()
|
||||||
|
|
||||||
ta.Prompt = " "
|
ta.Prompt = " "
|
||||||
@@ -287,22 +268,24 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
|||||||
|
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
ta.SetValue(existing.Value())
|
ta.SetValue(existing.Value())
|
||||||
ta.SetWidth(existing.Width())
|
// ta.SetWidth(existing.Width())
|
||||||
ta.SetHeight(existing.Height())
|
ta.SetHeight(existing.Height())
|
||||||
}
|
}
|
||||||
|
|
||||||
ta.Focus()
|
|
||||||
return ta
|
return ta
|
||||||
}
|
}
|
||||||
|
|
||||||
func createSpinner() spinner.Model {
|
func createSpinner() spinner.Model {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
return spinner.New(
|
return spinner.New(
|
||||||
spinner.WithSpinner(spinner.Ellipsis),
|
spinner.WithSpinner(spinner.Ellipsis),
|
||||||
spinner.WithStyle(
|
spinner.WithStyle(
|
||||||
styles.
|
styles.NewStyle().
|
||||||
Muted().
|
Background(t.Background()).
|
||||||
Background(theme.CurrentTheme().Background()).
|
Foreground(t.TextMuted()).
|
||||||
Width(3)),
|
Width(3).
|
||||||
|
Lipgloss(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,11 +294,9 @@ func NewEditorComponent(app *app.App) EditorComponent {
|
|||||||
ta := createTextArea(nil)
|
ta := createTextArea(nil)
|
||||||
|
|
||||||
return &editorComponent{
|
return &editorComponent{
|
||||||
app: app,
|
app: app,
|
||||||
textarea: ta,
|
textarea: ta,
|
||||||
history: []string{},
|
spinner: s,
|
||||||
historyIndex: 0,
|
interruptKeyInDebounce: false,
|
||||||
currentMessage: "",
|
|
||||||
spinner: s,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,86 +1,114 @@
|
|||||||
package chat
|
package chat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
|
||||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
|
"github.com/sst/opencode-sdk-go"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/components/commands"
|
|
||||||
"github.com/sst/opencode/internal/components/dialog"
|
"github.com/sst/opencode/internal/components/dialog"
|
||||||
"github.com/sst/opencode/internal/layout"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/pkg/client"
|
"github.com/sst/opencode/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MessagesComponent interface {
|
type MessagesComponent interface {
|
||||||
tea.Model
|
tea.Model
|
||||||
tea.ViewModel
|
View(width, height int) string
|
||||||
|
SetWidth(width int) tea.Cmd
|
||||||
PageUp() (tea.Model, tea.Cmd)
|
PageUp() (tea.Model, tea.Cmd)
|
||||||
PageDown() (tea.Model, tea.Cmd)
|
PageDown() (tea.Model, tea.Cmd)
|
||||||
HalfPageUp() (tea.Model, tea.Cmd)
|
HalfPageUp() (tea.Model, tea.Cmd)
|
||||||
HalfPageDown() (tea.Model, tea.Cmd)
|
HalfPageDown() (tea.Model, tea.Cmd)
|
||||||
First() (tea.Model, tea.Cmd)
|
First() (tea.Model, tea.Cmd)
|
||||||
Last() (tea.Model, tea.Cmd)
|
Last() (tea.Model, tea.Cmd)
|
||||||
// Previous() (tea.Model, tea.Cmd)
|
Previous() (tea.Model, tea.Cmd)
|
||||||
// Next() (tea.Model, tea.Cmd)
|
Next() (tea.Model, tea.Cmd)
|
||||||
ToolDetailsVisible() bool
|
ToolDetailsVisible() bool
|
||||||
|
Selected() string
|
||||||
}
|
}
|
||||||
|
|
||||||
type messagesComponent struct {
|
type messagesComponent struct {
|
||||||
width, height int
|
width int
|
||||||
app *app.App
|
app *app.App
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
spinner spinner.Model
|
|
||||||
attachments viewport.Model
|
|
||||||
commands commands.CommandsComponent
|
|
||||||
cache *MessageCache
|
cache *MessageCache
|
||||||
rendering bool
|
rendering bool
|
||||||
showToolDetails bool
|
showToolDetails bool
|
||||||
tail bool
|
tail bool
|
||||||
|
partCount int
|
||||||
|
lineCount int
|
||||||
|
selectedPart int
|
||||||
|
selectedText string
|
||||||
}
|
}
|
||||||
type renderFinishedMsg struct{}
|
type renderFinishedMsg struct{}
|
||||||
|
type selectedMessagePartChangedMsg struct {
|
||||||
|
part int
|
||||||
|
}
|
||||||
|
|
||||||
type ToggleToolDetailsMsg struct{}
|
type ToggleToolDetailsMsg struct{}
|
||||||
|
|
||||||
func (m *messagesComponent) Init() tea.Cmd {
|
func (m *messagesComponent) Init() tea.Cmd {
|
||||||
return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.commands.Init())
|
return tea.Batch(m.viewport.Init())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *messagesComponent) Selected() string {
|
||||||
|
return m.selectedText
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
switch msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case app.SendMsg:
|
case app.SendMsg:
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
m.tail = true
|
m.tail = true
|
||||||
|
m.selectedPart = -1
|
||||||
|
return m, nil
|
||||||
|
case app.OptimisticMessageAddedMsg:
|
||||||
|
m.renderView(m.width)
|
||||||
|
if m.tail {
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case dialog.ThemeSelectedMsg:
|
case dialog.ThemeSelectedMsg:
|
||||||
m.cache.Clear()
|
m.cache.Clear()
|
||||||
|
m.rendering = true
|
||||||
return m, m.Reload()
|
return m, m.Reload()
|
||||||
case ToggleToolDetailsMsg:
|
case ToggleToolDetailsMsg:
|
||||||
m.showToolDetails = !m.showToolDetails
|
m.showToolDetails = !m.showToolDetails
|
||||||
|
m.rendering = true
|
||||||
return m, m.Reload()
|
return m, m.Reload()
|
||||||
case app.SessionSelectedMsg:
|
case app.SessionLoadedMsg:
|
||||||
m.cache.Clear()
|
m.cache.Clear()
|
||||||
m.tail = true
|
m.tail = true
|
||||||
|
m.rendering = true
|
||||||
return m, m.Reload()
|
return m, m.Reload()
|
||||||
case app.SessionClearedMsg:
|
case app.SessionClearedMsg:
|
||||||
m.cache.Clear()
|
m.cache.Clear()
|
||||||
cmd := m.Reload()
|
m.rendering = true
|
||||||
return m, cmd
|
return m, m.Reload()
|
||||||
case renderFinishedMsg:
|
case renderFinishedMsg:
|
||||||
m.rendering = false
|
m.rendering = false
|
||||||
if m.tail {
|
if m.tail {
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
}
|
}
|
||||||
case client.EventSessionUpdated, client.EventMessageUpdated:
|
case selectedMessagePartChangedMsg:
|
||||||
m.renderView()
|
return m, m.Reload()
|
||||||
if m.tail {
|
case opencode.EventListResponseEventSessionUpdated:
|
||||||
m.viewport.GotoBottom()
|
if msg.Properties.Info.ID == m.app.Session.ID {
|
||||||
|
m.renderView(m.width)
|
||||||
|
if m.tail {
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case opencode.EventListResponseEventMessageUpdated:
|
||||||
|
if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID {
|
||||||
|
m.renderView(m.width)
|
||||||
|
if m.tail {
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,187 +117,211 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.tail = m.viewport.AtBottom()
|
m.tail = m.viewport.AtBottom()
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
spinner, cmd := m.spinner.Update(msg)
|
|
||||||
m.spinner = spinner
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
|
|
||||||
updated, cmd := m.commands.Update(msg)
|
|
||||||
m.commands = updated.(commands.CommandsComponent)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
|
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
type blockType int
|
func (m *messagesComponent) renderView(width int) {
|
||||||
|
measure := util.Measure("messages.renderView")
|
||||||
const (
|
defer measure("messageCount", len(m.app.Messages))
|
||||||
none blockType = iota
|
|
||||||
userTextBlock
|
|
||||||
assistantTextBlock
|
|
||||||
toolInvocationBlock
|
|
||||||
errorBlock
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m *messagesComponent) renderView() {
|
|
||||||
if m.width == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
blocks := make([]string, 0)
|
blocks := make([]string, 0)
|
||||||
previousBlockType := none
|
m.partCount = 0
|
||||||
|
m.lineCount = 0
|
||||||
|
|
||||||
for _, message := range m.app.Messages {
|
for _, message := range m.app.Messages {
|
||||||
var content string
|
var content string
|
||||||
var cached bool
|
var cached bool
|
||||||
lastToolIndex := 0
|
|
||||||
lastToolIndices := []int{}
|
|
||||||
for i, p := range message.Parts {
|
|
||||||
part, _ := p.ValueByDiscriminator()
|
|
||||||
switch part.(type) {
|
|
||||||
case client.MessagePartText:
|
|
||||||
lastToolIndices = append(lastToolIndices, lastToolIndex)
|
|
||||||
case client.MessagePartToolInvocation:
|
|
||||||
lastToolIndex = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
author := ""
|
|
||||||
switch message.Role {
|
switch message.Role {
|
||||||
case client.User:
|
case opencode.MessageRoleUser:
|
||||||
author = m.app.Info.User
|
for _, part := range message.Parts {
|
||||||
case client.Assistant:
|
switch part := part.AsUnion().(type) {
|
||||||
author = message.Metadata.Assistant.ModelID
|
case opencode.TextPart:
|
||||||
}
|
key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount)
|
||||||
|
|
||||||
for i, p := range message.Parts {
|
|
||||||
part, err := p.ValueByDiscriminator()
|
|
||||||
if err != nil {
|
|
||||||
continue //TODO: handle error?
|
|
||||||
}
|
|
||||||
|
|
||||||
switch part.(type) {
|
|
||||||
// case client.MessagePartStepStart:
|
|
||||||
// messages = append(messages, "")
|
|
||||||
case client.MessagePartText:
|
|
||||||
text := part.(client.MessagePartText)
|
|
||||||
key := m.cache.GenerateKey(message.Id, text.Text, layout.Current.Viewport.Width)
|
|
||||||
content, cached = m.cache.Get(key)
|
|
||||||
if !cached {
|
|
||||||
content = renderText(message, text.Text, author)
|
|
||||||
m.cache.Set(key, content)
|
|
||||||
}
|
|
||||||
if previousBlockType != none {
|
|
||||||
blocks = append(blocks, "")
|
|
||||||
}
|
|
||||||
blocks = append(blocks, content)
|
|
||||||
if message.Role == client.User {
|
|
||||||
previousBlockType = userTextBlock
|
|
||||||
} else if message.Role == client.Assistant {
|
|
||||||
previousBlockType = assistantTextBlock
|
|
||||||
}
|
|
||||||
case client.MessagePartToolInvocation:
|
|
||||||
isLastToolInvocation := slices.Contains(lastToolIndices, i)
|
|
||||||
toolInvocationPart := part.(client.MessagePartToolInvocation)
|
|
||||||
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
|
|
||||||
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
|
|
||||||
if _, ok := message.Metadata.Tool[toolCall.ToolCallId]; ok {
|
|
||||||
metadata = message.Metadata.Tool[toolCall.ToolCallId]
|
|
||||||
}
|
|
||||||
var result *string
|
|
||||||
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
|
|
||||||
if resultError == nil {
|
|
||||||
result = &resultPart.Result
|
|
||||||
}
|
|
||||||
|
|
||||||
if toolCall.State == "result" {
|
|
||||||
key := m.cache.GenerateKey(message.Id,
|
|
||||||
toolCall.ToolCallId,
|
|
||||||
m.showToolDetails,
|
|
||||||
layout.Current.Viewport.Width,
|
|
||||||
)
|
|
||||||
content, cached = m.cache.Get(key)
|
content, cached = m.cache.Get(key)
|
||||||
if !cached {
|
if !cached {
|
||||||
content = renderToolInvocation(
|
content = renderText(
|
||||||
toolCall,
|
m.app,
|
||||||
result,
|
message,
|
||||||
metadata,
|
part.Text,
|
||||||
|
m.app.Info.User,
|
||||||
m.showToolDetails,
|
m.showToolDetails,
|
||||||
isLastToolInvocation,
|
m.partCount == m.selectedPart,
|
||||||
false,
|
width,
|
||||||
)
|
)
|
||||||
m.cache.Set(key, content)
|
m.cache.Set(key, content)
|
||||||
}
|
}
|
||||||
} else {
|
if content != "" {
|
||||||
// if the tool call isn't finished, don't cache
|
if m.selectedPart == m.partCount {
|
||||||
content = renderToolInvocation(
|
m.viewport.SetYOffset(m.lineCount - 4)
|
||||||
toolCall,
|
m.selectedText = part.Text
|
||||||
result,
|
}
|
||||||
metadata,
|
blocks = append(blocks, content)
|
||||||
m.showToolDetails,
|
m.partCount++
|
||||||
isLastToolInvocation,
|
m.lineCount += lipgloss.Height(content) + 1
|
||||||
false,
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if previousBlockType != toolInvocationBlock && m.showToolDetails {
|
case opencode.MessageRoleAssistant:
|
||||||
blocks = append(blocks, "")
|
for i, p := range message.Parts {
|
||||||
|
switch part := p.AsUnion().(type) {
|
||||||
|
case opencode.TextPart:
|
||||||
|
finished := message.Metadata.Time.Completed > 0
|
||||||
|
remainingParts := message.Parts[i+1:]
|
||||||
|
toolCallParts := make([]opencode.ToolInvocationPart, 0)
|
||||||
|
for _, part := range remainingParts {
|
||||||
|
switch part := part.AsUnion().(type) {
|
||||||
|
case opencode.TextPart:
|
||||||
|
// we only want tool calls associated with the current text part.
|
||||||
|
// if we hit another text part, we're done.
|
||||||
|
break
|
||||||
|
case opencode.ToolInvocationPart:
|
||||||
|
toolCallParts = append(toolCallParts, part)
|
||||||
|
if part.ToolInvocation.State != "result" {
|
||||||
|
// i don't think there's a case where a tool call isn't in result state
|
||||||
|
// and the message time is 0, but just in case
|
||||||
|
finished = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if finished {
|
||||||
|
key := m.cache.GenerateKey(message.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount)
|
||||||
|
content, cached = m.cache.Get(key)
|
||||||
|
if !cached {
|
||||||
|
content = renderText(
|
||||||
|
m.app,
|
||||||
|
message,
|
||||||
|
p.Text,
|
||||||
|
message.Metadata.Assistant.ModelID,
|
||||||
|
m.showToolDetails,
|
||||||
|
m.partCount == m.selectedPart,
|
||||||
|
width,
|
||||||
|
toolCallParts...,
|
||||||
|
)
|
||||||
|
m.cache.Set(key, content)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content = renderText(
|
||||||
|
m.app,
|
||||||
|
message,
|
||||||
|
p.Text,
|
||||||
|
message.Metadata.Assistant.ModelID,
|
||||||
|
m.showToolDetails,
|
||||||
|
m.partCount == m.selectedPart,
|
||||||
|
width,
|
||||||
|
toolCallParts...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if content != "" {
|
||||||
|
if m.selectedPart == m.partCount {
|
||||||
|
m.viewport.SetYOffset(m.lineCount - 4)
|
||||||
|
m.selectedText = p.Text
|
||||||
|
}
|
||||||
|
blocks = append(blocks, content)
|
||||||
|
m.partCount++
|
||||||
|
m.lineCount += lipgloss.Height(content) + 1
|
||||||
|
}
|
||||||
|
case opencode.ToolInvocationPart:
|
||||||
|
if !m.showToolDetails {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if part.ToolInvocation.State == "result" {
|
||||||
|
key := m.cache.GenerateKey(message.ID,
|
||||||
|
part.ToolInvocation.ToolCallID,
|
||||||
|
m.showToolDetails,
|
||||||
|
width,
|
||||||
|
m.partCount == m.selectedPart,
|
||||||
|
)
|
||||||
|
content, cached = m.cache.Get(key)
|
||||||
|
if !cached {
|
||||||
|
content = renderToolDetails(
|
||||||
|
m.app,
|
||||||
|
part,
|
||||||
|
message.Metadata,
|
||||||
|
m.partCount == m.selectedPart,
|
||||||
|
width,
|
||||||
|
)
|
||||||
|
m.cache.Set(key, content)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if the tool call isn't finished, don't cache
|
||||||
|
content = renderToolDetails(
|
||||||
|
m.app,
|
||||||
|
part,
|
||||||
|
message.Metadata,
|
||||||
|
m.partCount == m.selectedPart,
|
||||||
|
width,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if content != "" {
|
||||||
|
if m.selectedPart == m.partCount {
|
||||||
|
m.viewport.SetYOffset(m.lineCount - 4)
|
||||||
|
m.selectedText = ""
|
||||||
|
}
|
||||||
|
blocks = append(blocks, content)
|
||||||
|
m.partCount++
|
||||||
|
m.lineCount += lipgloss.Height(content) + 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
blocks = append(blocks, content)
|
|
||||||
previousBlockType = toolInvocationBlock
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
error := ""
|
error := ""
|
||||||
if message.Metadata.Error != nil {
|
switch err := message.Metadata.Error.AsUnion().(type) {
|
||||||
errorValue, _ := message.Metadata.Error.ValueByDiscriminator()
|
case nil:
|
||||||
switch errorValue.(type) {
|
case opencode.MessageMetadataErrorMessageOutputLengthError:
|
||||||
case client.UnknownError:
|
error = "Message output length exceeded"
|
||||||
clientError := errorValue.(client.UnknownError)
|
case opencode.ProviderAuthError:
|
||||||
error = clientError.Data.Message
|
error = err.Data.Message
|
||||||
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
|
case opencode.UnknownError:
|
||||||
blocks = append(blocks, error)
|
error = err.Data.Message
|
||||||
previousBlockType = errorBlock
|
}
|
||||||
}
|
|
||||||
|
if error != "" {
|
||||||
|
error = renderContentBlock(
|
||||||
|
m.app,
|
||||||
|
error,
|
||||||
|
false,
|
||||||
|
width,
|
||||||
|
WithBorderColor(t.Error()),
|
||||||
|
)
|
||||||
|
blocks = append(blocks, error)
|
||||||
|
m.lineCount += lipgloss.Height(error) + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
centered := []string{}
|
m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n"))
|
||||||
for _, block := range blocks {
|
if m.selectedPart == m.partCount-1 {
|
||||||
centered = append(centered, lipgloss.PlaceHorizontal(
|
m.viewport.GotoBottom()
|
||||||
m.width,
|
|
||||||
lipgloss.Center,
|
|
||||||
block,
|
|
||||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()))
|
|
||||||
m.viewport.SetContent("\n" + strings.Join(centered, "\n") + "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) header() string {
|
func (m *messagesComponent) header(width int) string {
|
||||||
if m.app.Session.Id == "" {
|
if m.app.Session.ID == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
width := layout.Current.Container.Width
|
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||||
base := styles.BaseStyle().Background(t.Background()).Render
|
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||||
muted := styles.Muted().Background(t.Background()).Render
|
|
||||||
headerLines := []string{}
|
headerLines := []string{}
|
||||||
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
|
headerLines = append(headerLines, util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
|
||||||
if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
|
if m.app.Session.Share.URL != "" {
|
||||||
headerLines = append(headerLines, muted(m.app.Session.Share.Url))
|
headerLines = append(headerLines, muted(m.app.Session.Share.URL))
|
||||||
} else {
|
} else {
|
||||||
headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
|
headerLines = append(headerLines, base("/share")+muted(" to create a shareable link"))
|
||||||
}
|
}
|
||||||
header := strings.Join(headerLines, "\n")
|
header := strings.Join(headerLines, "\n")
|
||||||
|
|
||||||
header = styles.BaseStyle().
|
header = styles.NewStyle().
|
||||||
|
Background(t.Background()).
|
||||||
Width(width).
|
Width(width).
|
||||||
PaddingLeft(2).
|
PaddingLeft(2).
|
||||||
PaddingRight(2).
|
PaddingRight(2).
|
||||||
Background(t.Background()).
|
|
||||||
BorderLeft(true).
|
BorderLeft(true).
|
||||||
BorderRight(true).
|
BorderRight(true).
|
||||||
BorderBackground(t.Background()).
|
BorderBackground(t.Background()).
|
||||||
@@ -280,98 +332,29 @@ func (m *messagesComponent) header() string {
|
|||||||
return "\n" + header + "\n"
|
return "\n" + header + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) View() string {
|
func (m *messagesComponent) View(width, height int) string {
|
||||||
if len(m.app.Messages) == 0 {
|
t := theme.CurrentTheme()
|
||||||
return m.home()
|
|
||||||
}
|
|
||||||
if m.rendering {
|
if m.rendering {
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.width,
|
width,
|
||||||
m.height,
|
height,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
"Loading session...",
|
styles.NewStyle().Background(t.Background()).Render("Loading session..."),
|
||||||
|
styles.WhitespaceStyle(t.Background()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
t := theme.CurrentTheme()
|
header := m.header(width)
|
||||||
return lipgloss.JoinVertical(
|
m.viewport.SetWidth(width)
|
||||||
lipgloss.Left,
|
m.viewport.SetHeight(height - lipgloss.Height(header))
|
||||||
lipgloss.PlaceHorizontal(
|
|
||||||
m.width,
|
|
||||||
lipgloss.Center,
|
|
||||||
m.header(),
|
|
||||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
|
||||||
),
|
|
||||||
m.viewport.View(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *messagesComponent) home() string {
|
return styles.NewStyle().
|
||||||
t := theme.CurrentTheme()
|
|
||||||
baseStyle := styles.BaseStyle().Background(t.Background())
|
|
||||||
base := baseStyle.Render
|
|
||||||
muted := styles.Muted().Background(t.Background()).Render
|
|
||||||
|
|
||||||
open := `
|
|
||||||
█▀▀█ █▀▀█ █▀▀ █▀▀▄
|
|
||||||
█░░█ █░░█ █▀▀ █░░█
|
|
||||||
▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `
|
|
||||||
code := `
|
|
||||||
█▀▀ █▀▀█ █▀▀▄ █▀▀
|
|
||||||
█░░ █░░█ █░░█ █▀▀
|
|
||||||
▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`
|
|
||||||
|
|
||||||
logo := lipgloss.JoinHorizontal(
|
|
||||||
lipgloss.Top,
|
|
||||||
muted(open),
|
|
||||||
base(code),
|
|
||||||
)
|
|
||||||
// cwd := app.Info.Path.Cwd
|
|
||||||
// config := app.Info.Path.Config
|
|
||||||
|
|
||||||
versionStyle := lipgloss.NewStyle().
|
|
||||||
Background(t.Background()).
|
Background(t.Background()).
|
||||||
Foreground(t.TextMuted()).
|
Render(header + "\n" + m.viewport.View())
|
||||||
Width(lipgloss.Width(logo)).
|
|
||||||
Align(lipgloss.Right)
|
|
||||||
version := versionStyle.Render(m.app.Version)
|
|
||||||
|
|
||||||
logoAndVersion := strings.Join([]string{logo, version}, "\n")
|
|
||||||
logoAndVersion = lipgloss.PlaceHorizontal(
|
|
||||||
m.width,
|
|
||||||
lipgloss.Center,
|
|
||||||
logoAndVersion,
|
|
||||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
|
||||||
)
|
|
||||||
m.commands.SetBackgroundColor(t.Background())
|
|
||||||
commands := lipgloss.PlaceHorizontal(
|
|
||||||
m.width,
|
|
||||||
lipgloss.Center,
|
|
||||||
m.commands.View(),
|
|
||||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
|
||||||
)
|
|
||||||
|
|
||||||
lines := []string{}
|
|
||||||
lines = append(lines, logoAndVersion)
|
|
||||||
lines = append(lines, "")
|
|
||||||
lines = append(lines, "")
|
|
||||||
// lines = append(lines, base("cwd ")+muted(cwd))
|
|
||||||
// lines = append(lines, base("config ")+muted(config))
|
|
||||||
// lines = append(lines, "")
|
|
||||||
lines = append(lines, commands)
|
|
||||||
|
|
||||||
return lipgloss.Place(
|
|
||||||
m.width,
|
|
||||||
m.height,
|
|
||||||
lipgloss.Center,
|
|
||||||
lipgloss.Center,
|
|
||||||
baseStyle.Render(strings.Join(lines, "\n")),
|
|
||||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
|
func (m *messagesComponent) SetWidth(width int) tea.Cmd {
|
||||||
if m.width == width && m.height == height {
|
if m.width == width {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Clear cache on resize since width affects rendering
|
// Clear cache on resize since width affects rendering
|
||||||
@@ -379,24 +362,14 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
|
|||||||
m.cache.Clear()
|
m.cache.Clear()
|
||||||
}
|
}
|
||||||
m.width = width
|
m.width = width
|
||||||
m.height = height
|
|
||||||
m.viewport.SetWidth(width)
|
m.viewport.SetWidth(width)
|
||||||
m.viewport.SetHeight(height - lipgloss.Height(m.header()))
|
m.renderView(width)
|
||||||
m.attachments.SetWidth(width + 40)
|
|
||||||
m.attachments.SetHeight(3)
|
|
||||||
m.commands.SetSize(width, height)
|
|
||||||
m.renderView()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) GetSize() (int, int) {
|
|
||||||
return m.width, m.height
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *messagesComponent) Reload() tea.Cmd {
|
func (m *messagesComponent) Reload() tea.Cmd {
|
||||||
m.rendering = true
|
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
m.renderView()
|
m.renderView(m.width)
|
||||||
return renderFinishedMsg{}
|
return renderFinishedMsg{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,16 +394,45 @@ func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
|
func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) {
|
||||||
m.viewport.GotoTop()
|
|
||||||
m.tail = false
|
m.tail = false
|
||||||
return m, nil
|
if m.selectedPart < 0 {
|
||||||
|
m.selectedPart = m.partCount
|
||||||
|
}
|
||||||
|
m.selectedPart--
|
||||||
|
if m.selectedPart < 0 {
|
||||||
|
m.selectedPart = 0
|
||||||
|
}
|
||||||
|
return m, util.CmdHandler(selectedMessagePartChangedMsg{
|
||||||
|
part: m.selectedPart,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *messagesComponent) Next() (tea.Model, tea.Cmd) {
|
||||||
|
m.tail = false
|
||||||
|
m.selectedPart++
|
||||||
|
if m.selectedPart >= m.partCount {
|
||||||
|
m.selectedPart = m.partCount
|
||||||
|
}
|
||||||
|
return m, util.CmdHandler(selectedMessagePartChangedMsg{
|
||||||
|
part: m.selectedPart,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
|
||||||
|
m.selectedPart = 0
|
||||||
|
m.tail = false
|
||||||
|
return m, util.CmdHandler(selectedMessagePartChangedMsg{
|
||||||
|
part: m.selectedPart,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
|
func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
|
||||||
m.viewport.GotoBottom()
|
m.selectedPart = m.partCount - 1
|
||||||
m.tail = true
|
m.tail = true
|
||||||
return m, nil
|
return m, util.CmdHandler(selectedMessagePartChangedMsg{
|
||||||
|
part: m.selectedPart,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) ToolDetailsVisible() bool {
|
func (m *messagesComponent) ToolDetailsVisible() bool {
|
||||||
@@ -438,31 +440,15 @@ func (m *messagesComponent) ToolDetailsVisible() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewMessagesComponent(app *app.App) MessagesComponent {
|
func NewMessagesComponent(app *app.App) MessagesComponent {
|
||||||
customSpinner := spinner.Spinner{
|
|
||||||
Frames: []string{" ", "┃", "┃"},
|
|
||||||
FPS: time.Second / 3,
|
|
||||||
}
|
|
||||||
s := spinner.New(spinner.WithSpinner(customSpinner))
|
|
||||||
|
|
||||||
vp := viewport.New()
|
vp := viewport.New()
|
||||||
attachments := viewport.New()
|
|
||||||
vp.KeyMap = viewport.KeyMap{}
|
vp.KeyMap = viewport.KeyMap{}
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
|
||||||
commandsView := commands.New(
|
|
||||||
app,
|
|
||||||
commands.WithBackground(t.Background()),
|
|
||||||
commands.WithLimit(6),
|
|
||||||
)
|
|
||||||
|
|
||||||
return &messagesComponent{
|
return &messagesComponent{
|
||||||
app: app,
|
app: app,
|
||||||
viewport: vp,
|
viewport: vp,
|
||||||
spinner: s,
|
|
||||||
attachments: attachments,
|
|
||||||
commands: commandsView,
|
|
||||||
showToolDetails: true,
|
showToolDetails: true,
|
||||||
cache: NewMessageCache(),
|
cache: NewMessageCache(),
|
||||||
tail: true,
|
tail: true,
|
||||||
|
selectedPart: -1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,13 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/commands"
|
"github.com/sst/opencode/internal/commands"
|
||||||
"github.com/sst/opencode/internal/layout"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommandsComponent interface {
|
type CommandsComponent interface {
|
||||||
tea.Model
|
|
||||||
tea.ViewModel
|
tea.ViewModel
|
||||||
layout.Sizeable
|
SetSize(width, height int) tea.Cmd
|
||||||
SetBackgroundColor(color compat.AdaptiveColor)
|
SetBackgroundColor(color compat.AdaptiveColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +23,7 @@ type commandsComponent struct {
|
|||||||
app *app.App
|
app *app.App
|
||||||
width, height int
|
width, height int
|
||||||
showKeybinds bool
|
showKeybinds bool
|
||||||
|
showAll bool
|
||||||
background *compat.AdaptiveColor
|
background *compat.AdaptiveColor
|
||||||
limit *int
|
limit *int
|
||||||
}
|
}
|
||||||
@@ -35,39 +34,16 @@ func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *commandsComponent) GetSize() (int, int) {
|
|
||||||
return c.width, c.height
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
|
func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
|
||||||
c.background = &color
|
c.background = &color
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *commandsComponent) Init() tea.Cmd {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *commandsComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
c.width = msg.Width
|
|
||||||
c.height = msg.Height
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *commandsComponent) View() string {
|
func (c *commandsComponent) View() string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
|
|
||||||
triggerStyle := lipgloss.NewStyle().
|
triggerStyle := styles.NewStyle().Foreground(t.Primary()).Bold(true)
|
||||||
Foreground(t.Primary()).
|
descriptionStyle := styles.NewStyle().Foreground(t.Text())
|
||||||
Bold(true)
|
keybindStyle := styles.NewStyle().Foreground(t.TextMuted())
|
||||||
|
|
||||||
descriptionStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(t.Text())
|
|
||||||
|
|
||||||
keybindStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(t.TextMuted())
|
|
||||||
|
|
||||||
if c.background != nil {
|
if c.background != nil {
|
||||||
triggerStyle = triggerStyle.Background(*c.background)
|
triggerStyle = triggerStyle.Background(*c.background)
|
||||||
@@ -75,18 +51,34 @@ func (c *commandsComponent) View() string {
|
|||||||
keybindStyle = keybindStyle.Background(*c.background)
|
keybindStyle = keybindStyle.Background(*c.background)
|
||||||
}
|
}
|
||||||
|
|
||||||
var commandsWithTriggers []commands.Command
|
var commandsToShow []commands.Command
|
||||||
|
var triggeredCommands []commands.Command
|
||||||
|
var untriggeredCommands []commands.Command
|
||||||
|
|
||||||
for _, cmd := range c.app.Commands.Sorted() {
|
for _, cmd := range c.app.Commands.Sorted() {
|
||||||
if cmd.Trigger != "" {
|
if c.showAll || cmd.Trigger != "" {
|
||||||
commandsWithTriggers = append(commandsWithTriggers, cmd)
|
if cmd.Trigger != "" {
|
||||||
|
triggeredCommands = append(triggeredCommands, cmd)
|
||||||
|
} else if c.showAll {
|
||||||
|
untriggeredCommands = append(untriggeredCommands, cmd)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.limit != nil && len(commandsWithTriggers) > *c.limit {
|
|
||||||
commandsWithTriggers = commandsWithTriggers[:*c.limit]
|
// 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 len(commandsWithTriggers) == 0 {
|
if len(commandsToShow) == 0 {
|
||||||
return styles.Muted().Render("No commands with triggers available")
|
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
|
// Calculate column widths
|
||||||
@@ -101,10 +93,15 @@ func (c *commandsComponent) View() string {
|
|||||||
keybinds string
|
keybinds string
|
||||||
}
|
}
|
||||||
|
|
||||||
rows := make([]commandRow, 0, len(commandsWithTriggers))
|
rows := make([]commandRow, 0, len(commandsToShow))
|
||||||
|
|
||||||
for _, cmd := range commandsWithTriggers {
|
for _, cmd := range commandsToShow {
|
||||||
trigger := "/" + cmd.Trigger
|
trigger := ""
|
||||||
|
if cmd.Trigger != "" {
|
||||||
|
trigger = "/" + cmd.Trigger
|
||||||
|
} else {
|
||||||
|
trigger = string(cmd.Name)
|
||||||
|
}
|
||||||
description := cmd.Description
|
description := cmd.Description
|
||||||
|
|
||||||
// Format keybindings
|
// Format keybindings
|
||||||
@@ -112,7 +109,7 @@ func (c *commandsComponent) View() string {
|
|||||||
if c.showKeybinds {
|
if c.showKeybinds {
|
||||||
for _, kb := range cmd.Keybindings {
|
for _, kb := range cmd.Keybindings {
|
||||||
if kb.RequiresLeader {
|
if kb.RequiresLeader {
|
||||||
keybindStrs = append(keybindStrs, *c.app.Config.Keybinds.Leader+" "+kb.Key)
|
keybindStrs = append(keybindStrs, c.app.Config.Keybinds.Leader+" "+kb.Key)
|
||||||
} else {
|
} else {
|
||||||
keybindStrs = append(keybindStrs, kb.Key)
|
keybindStrs = append(keybindStrs, kb.Key)
|
||||||
}
|
}
|
||||||
@@ -144,6 +141,7 @@ func (c *commandsComponent) View() string {
|
|||||||
// Build the output
|
// Build the output
|
||||||
var output strings.Builder
|
var output strings.Builder
|
||||||
|
|
||||||
|
maxWidth := 0
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
// Pad each column to align properly
|
// Pad each column to align properly
|
||||||
trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
|
trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
|
||||||
@@ -160,10 +158,14 @@ func (c *commandsComponent) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
output.WriteString(line + "\n")
|
output.WriteString(line + "\n")
|
||||||
|
maxWidth = max(maxWidth, lipgloss.Width(line))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove trailing newline
|
// Remove trailing newline
|
||||||
result := strings.TrimSuffix(output.String(), "\n")
|
result := strings.TrimSuffix(output.String(), "\n")
|
||||||
|
if c.background != nil {
|
||||||
|
result = styles.NewStyle().Background(*c.background).Width(maxWidth).Render(result)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -188,11 +190,18 @@ func WithLimit(limit int) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithShowAll(showAll bool) Option {
|
||||||
|
return func(c *commandsComponent) {
|
||||||
|
c.showAll = showAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func New(app *app.App, opts ...Option) CommandsComponent {
|
func New(app *app.App, opts ...Option) CommandsComponent {
|
||||||
c := &commandsComponent{
|
c := &commandsComponent{
|
||||||
app: app,
|
app: app,
|
||||||
background: nil,
|
background: nil,
|
||||||
showKeybinds: true,
|
showKeybinds: true,
|
||||||
|
showAll: false,
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(c)
|
opt(c)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/charmbracelet/bubbles/v2/key"
|
"github.com/charmbracelet/bubbles/v2/key"
|
||||||
"github.com/charmbracelet/bubbles/v2/textarea"
|
"github.com/charmbracelet/bubbles/v2/textarea"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/components/list"
|
"github.com/sst/opencode/internal/components/list"
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
@@ -26,7 +27,7 @@ type CompletionItemI interface {
|
|||||||
|
|
||||||
func (ci *CompletionItem) Render(selected bool, width int) string {
|
func (ci *CompletionItem) Render(selected bool, width int) string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
baseStyle := styles.BaseStyle()
|
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||||
|
|
||||||
itemStyle := baseStyle.
|
itemStyle := baseStyle.
|
||||||
Background(t.BackgroundElement()).
|
Background(t.BackgroundElement()).
|
||||||
@@ -34,14 +35,12 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
|
|||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
if selected {
|
if selected {
|
||||||
itemStyle = itemStyle.
|
itemStyle = itemStyle.Foreground(t.Primary())
|
||||||
Foreground(t.Primary())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
title := itemStyle.Render(
|
title := itemStyle.Render(
|
||||||
ci.DisplayValue(),
|
ci.DisplayValue(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return title
|
return title
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +58,6 @@ func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
|
|||||||
|
|
||||||
type CompletionProvider interface {
|
type CompletionProvider interface {
|
||||||
GetId() string
|
GetId() string
|
||||||
GetEntry() CompletionItemI
|
|
||||||
GetChildEntries(query string) ([]CompletionItemI, error)
|
GetChildEntries(query string) ([]CompletionItemI, error)
|
||||||
GetEmptyMessage() string
|
GetEmptyMessage() string
|
||||||
}
|
}
|
||||||
@@ -116,7 +114,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case []CompletionItemI:
|
case []CompletionItemI:
|
||||||
c.list.SetItems(msg)
|
c.list.SetItems(msg)
|
||||||
case app.CompletionDialogTriggerdMsg:
|
case app.CompletionDialogTriggeredMsg:
|
||||||
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
|
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if c.pseudoSearchTextArea.Focused() {
|
if c.pseudoSearchTextArea.Focused() {
|
||||||
@@ -175,9 +173,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
|
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
|
||||||
return c, tea.Batch(cmds...)
|
return c, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
c.width = msg.Width
|
|
||||||
c.height = msg.Height
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c, tea.Batch(cmds...)
|
return c, tea.Batch(cmds...)
|
||||||
@@ -185,7 +180,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (c *completionDialogComponent) View() string {
|
func (c *completionDialogComponent) View() string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
baseStyle := styles.BaseStyle()
|
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||||
|
|
||||||
maxWidth := 40
|
maxWidth := 40
|
||||||
completions := c.list.GetItems()
|
completions := c.list.GetItems()
|
||||||
@@ -199,8 +194,14 @@ func (c *completionDialogComponent) View() string {
|
|||||||
|
|
||||||
c.list.SetMaxWidth(maxWidth)
|
c.list.SetMaxWidth(maxWidth)
|
||||||
|
|
||||||
return baseStyle.Padding(0, 0).
|
return baseStyle.
|
||||||
|
Padding(0, 0).
|
||||||
Background(t.BackgroundElement()).
|
Background(t.BackgroundElement()).
|
||||||
|
BorderStyle(lipgloss.ThickBorder()).
|
||||||
|
BorderLeft(true).
|
||||||
|
BorderRight(true).
|
||||||
|
BorderForeground(t.Border()).
|
||||||
|
BorderBackground(t.Background()).
|
||||||
Width(c.width).
|
Width(c.width).
|
||||||
Render(c.list.View())
|
Render(c.list.View())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package dialog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/v2/key"
|
||||||
|
"github.com/charmbracelet/bubbles/v2/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FindSelectedMsg struct {
|
||||||
|
FilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindDialogCloseMsg struct{}
|
||||||
|
|
||||||
|
type FindDialog interface {
|
||||||
|
layout.Modal
|
||||||
|
tea.Model
|
||||||
|
tea.ViewModel
|
||||||
|
SetWidth(width int)
|
||||||
|
SetHeight(height int)
|
||||||
|
IsEmpty() bool
|
||||||
|
SetProvider(provider CompletionProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
type findDialogComponent struct {
|
||||||
|
query string
|
||||||
|
completionProvider CompletionProvider
|
||||||
|
width, height int
|
||||||
|
modal *modal.Modal
|
||||||
|
textInput textinput.Model
|
||||||
|
list list.List[CompletionItemI]
|
||||||
|
}
|
||||||
|
|
||||||
|
type findDialogKeyMap struct {
|
||||||
|
Select key.Binding
|
||||||
|
Cancel key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
var findDialogKeys = findDialogKeyMap{
|
||||||
|
Select: key.NewBinding(
|
||||||
|
key.WithKeys("enter"),
|
||||||
|
),
|
||||||
|
Cancel: key.NewBinding(
|
||||||
|
key.WithKeys("esc"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) Init() tea.Cmd {
|
||||||
|
return textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case []CompletionItemI:
|
||||||
|
f.list.SetItems(msg)
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c":
|
||||||
|
if f.textInput.Value() == "" {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
f.textInput.SetValue("")
|
||||||
|
return f.update(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, findDialogKeys.Select):
|
||||||
|
item, i := f.list.GetSelectedItem()
|
||||||
|
if i == -1 {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
return f, f.selectFile(item)
|
||||||
|
case key.Matches(msg, findDialogKeys.Cancel):
|
||||||
|
return f, f.Close()
|
||||||
|
default:
|
||||||
|
f.textInput, cmd = f.textInput.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
f, cmd = f.update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return f, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) update(msg tea.Msg) (*findDialogComponent, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
|
query := f.textInput.Value()
|
||||||
|
if query != f.query {
|
||||||
|
f.query = query
|
||||||
|
cmd = func() tea.Msg {
|
||||||
|
items, err := f.completionProvider.GetChildEntries(query)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get completion items", "error", err)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, cmd := f.list.Update(msg)
|
||||||
|
f.list = u.(list.List[CompletionItemI])
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
return f, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) View() string {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
f.textInput.SetWidth(f.width - 8)
|
||||||
|
f.list.SetMaxWidth(f.width - 4)
|
||||||
|
inputView := f.textInput.View()
|
||||||
|
inputView = styles.NewStyle().
|
||||||
|
Background(t.BackgroundPanel()).
|
||||||
|
Height(1).
|
||||||
|
Width(f.width-4).
|
||||||
|
Padding(0, 0).
|
||||||
|
Render(inputView)
|
||||||
|
|
||||||
|
listView := f.list.View()
|
||||||
|
return styles.NewStyle().Height(12).Render(inputView + "\n" + listView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) SetWidth(width int) {
|
||||||
|
f.width = width
|
||||||
|
if width > 4 {
|
||||||
|
f.textInput.SetWidth(width - 4)
|
||||||
|
f.list.SetMaxWidth(width - 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) SetHeight(height int) {
|
||||||
|
f.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) IsEmpty() bool {
|
||||||
|
return f.list.IsEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) SetProvider(provider CompletionProvider) {
|
||||||
|
f.completionProvider = provider
|
||||||
|
f.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
|
||||||
|
f.list.SetItems([]CompletionItemI{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd {
|
||||||
|
return tea.Sequence(
|
||||||
|
f.Close(),
|
||||||
|
util.CmdHandler(FindSelectedMsg{
|
||||||
|
FilePath: item.GetValue(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) Render(background string) string {
|
||||||
|
return f.modal.Render(f.View(), background)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) Close() tea.Cmd {
|
||||||
|
f.textInput.Reset()
|
||||||
|
f.textInput.Blur()
|
||||||
|
return util.CmdHandler(modal.CloseModalMsg{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTextInput(existing *textinput.Model) textinput.Model {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
bgColor := t.BackgroundPanel()
|
||||||
|
textColor := t.Text()
|
||||||
|
textMutedColor := t.TextMuted()
|
||||||
|
|
||||||
|
ti := textinput.New()
|
||||||
|
|
||||||
|
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.Cursor.Color = t.Primary()
|
||||||
|
ti.VirtualCursor = true
|
||||||
|
|
||||||
|
ti.Prompt = " "
|
||||||
|
ti.CharLimit = -1
|
||||||
|
ti.Focus()
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
ti.SetValue(existing.Value())
|
||||||
|
ti.SetWidth(existing.Width())
|
||||||
|
}
|
||||||
|
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFindDialog(completionProvider CompletionProvider) FindDialog {
|
||||||
|
ti := createTextInput(nil)
|
||||||
|
|
||||||
|
li := list.NewListComponent(
|
||||||
|
[]CompletionItemI{},
|
||||||
|
10, // max visible items
|
||||||
|
completionProvider.GetEmptyMessage(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load initial items
|
||||||
|
go func() {
|
||||||
|
items, err := completionProvider.GetChildEntries("")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get completion items", "error", err)
|
||||||
|
}
|
||||||
|
li.SetItems(items)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &findDialogComponent{
|
||||||
|
query: "",
|
||||||
|
completionProvider: completionProvider,
|
||||||
|
textInput: ti,
|
||||||
|
list: li,
|
||||||
|
modal: modal.New(
|
||||||
|
modal.WithTitle("Find Files"),
|
||||||
|
modal.WithMaxWidth(80),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,71 +1,55 @@
|
|||||||
package dialog
|
package dialog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/commands"
|
commandsComponent "github.com/sst/opencode/internal/components/commands"
|
||||||
"github.com/sst/opencode/internal/components/modal"
|
"github.com/sst/opencode/internal/components/modal"
|
||||||
"github.com/sst/opencode/internal/layout"
|
"github.com/sst/opencode/internal/layout"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
type helpDialog struct {
|
type helpDialog struct {
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
modal *modal.Modal
|
modal *modal.Modal
|
||||||
commands []commands.Command
|
app *app.App
|
||||||
|
commandsComponent commandsComponent.CommandsComponent
|
||||||
|
viewport viewport.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *helpDialog) Init() tea.Cmd {
|
func (h *helpDialog) Init() tea.Cmd {
|
||||||
return nil
|
return h.viewport.Init()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
h.width = msg.Width
|
h.width = msg.Width
|
||||||
h.height = msg.Height
|
h.height = msg.Height
|
||||||
|
// Set viewport size with some padding for the modal
|
||||||
|
h.viewport = viewport.New(viewport.WithWidth(msg.Width-4), viewport.WithHeight(msg.Height-6))
|
||||||
|
h.commandsComponent.SetSize(msg.Width-4, msg.Height-6)
|
||||||
}
|
}
|
||||||
return h, nil
|
|
||||||
|
// 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 {
|
func (h *helpDialog) View() string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
keyStyle := lipgloss.NewStyle().
|
h.commandsComponent.SetBackgroundColor(t.BackgroundElement())
|
||||||
Background(t.BackgroundElement()).
|
return h.viewport.View()
|
||||||
Foreground(t.Text()).
|
|
||||||
Bold(true)
|
|
||||||
descStyle := lipgloss.NewStyle().
|
|
||||||
Background(t.BackgroundElement()).
|
|
||||||
Foreground(t.TextMuted())
|
|
||||||
contentStyle := lipgloss.NewStyle().
|
|
||||||
PaddingLeft(1).Background(t.BackgroundElement())
|
|
||||||
|
|
||||||
lines := []string{}
|
|
||||||
for _, b := range h.commands {
|
|
||||||
// Only interested in slash commands
|
|
||||||
if b.Trigger == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
content := keyStyle.Render("/" + b.Trigger)
|
|
||||||
content += descStyle.Render(" " + b.Description)
|
|
||||||
// for i, key := range b.Keybindings {
|
|
||||||
// if i == 0 {
|
|
||||||
// keyString := " (" + key.Key + ")"
|
|
||||||
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
|
|
||||||
// spacer := strings.Repeat(" ", space)
|
|
||||||
// content += descStyle.Render(spacer)
|
|
||||||
// content += descStyle.Render(keyString)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
lines = append(lines, contentStyle.Render(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *helpDialog) Render(background string) string {
|
func (h *helpDialog) Render(background string) string {
|
||||||
@@ -80,9 +64,16 @@ type HelpDialog interface {
|
|||||||
layout.Modal
|
layout.Modal
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHelpDialog(commands []commands.Command) HelpDialog {
|
func NewHelpDialog(app *app.App) HelpDialog {
|
||||||
|
vp := viewport.New(viewport.WithHeight(12))
|
||||||
return &helpDialog{
|
return &helpDialog{
|
||||||
commands: commands,
|
app: app,
|
||||||
|
commandsComponent: commandsComponent.New(app,
|
||||||
|
commandsComponent.WithBackground(theme.CurrentTheme().BackgroundElement()),
|
||||||
|
commandsComponent.WithShowAll(true),
|
||||||
|
commandsComponent.WithKeybinds(true),
|
||||||
|
),
|
||||||
modal: modal.New(modal.WithTitle("Help")),
|
modal: modal.New(modal.WithTitle("Help")),
|
||||||
|
viewport: vp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// View implements tea.Model.
|
// View implements tea.Model.
|
||||||
func (m InitDialogCmp) View() string {
|
func (m InitDialogCmp) View() string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
baseStyle := styles.BaseStyle()
|
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||||
|
|
||||||
// Calculate width needed for content
|
// Calculate width needed for content
|
||||||
maxWidth := 60 // Width for explanation text
|
maxWidth := 60 // Width for explanation text
|
||||||
|
|||||||
@@ -3,25 +3,25 @@ package dialog
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"sort"
|
||||||
"slices"
|
"time"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/key"
|
"github.com/charmbracelet/bubbles/v2/key"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/sst/opencode-sdk-go"
|
||||||
"github.com/sst/opencode/internal/app"
|
"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/modal"
|
||||||
"github.com/sst/opencode/internal/layout"
|
"github.com/sst/opencode/internal/layout"
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/internal/util"
|
"github.com/sst/opencode/internal/util"
|
||||||
"github.com/sst/opencode/pkg/client"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
numVisibleModels = 6
|
numVisibleModels = 10
|
||||||
maxDialogWidth = 40
|
minDialogWidth = 40
|
||||||
|
maxDialogWidth = 80
|
||||||
)
|
)
|
||||||
|
|
||||||
// ModelDialog interface for the model selection dialog
|
// ModelDialog interface for the model selection dialog
|
||||||
@@ -30,46 +30,61 @@ type ModelDialog interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type modelDialog struct {
|
type modelDialog struct {
|
||||||
app *app.App
|
app *app.App
|
||||||
availableProviders []client.ProviderInfo
|
allModels []ModelWithProvider
|
||||||
provider client.ProviderInfo
|
width int
|
||||||
|
height int
|
||||||
|
modal *modal.Modal
|
||||||
|
modelList list.List[ModelItem]
|
||||||
|
dialogWidth int
|
||||||
|
}
|
||||||
|
|
||||||
selectedIdx int
|
type ModelWithProvider struct {
|
||||||
width int
|
Model opencode.Model
|
||||||
height int
|
Provider opencode.Provider
|
||||||
scrollOffset int
|
}
|
||||||
hScrollOffset int
|
|
||||||
hScrollPossible bool
|
|
||||||
|
|
||||||
modal *modal.Modal
|
type ModelItem struct {
|
||||||
|
ModelName string
|
||||||
|
ProviderName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ModelItem) Render(selected bool, width int) string {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
|
||||||
|
if selected {
|
||||||
|
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
|
||||||
|
return styles.NewStyle().
|
||||||
|
Background(t.Primary()).
|
||||||
|
Foreground(t.BackgroundElement()).
|
||||||
|
Width(width).
|
||||||
|
PaddingLeft(1).
|
||||||
|
Render(displayText)
|
||||||
|
} else {
|
||||||
|
modelStyle := styles.NewStyle().
|
||||||
|
Foreground(t.Text()).
|
||||||
|
Background(t.BackgroundElement())
|
||||||
|
providerStyle := styles.NewStyle().
|
||||||
|
Foreground(t.TextMuted()).
|
||||||
|
Background(t.BackgroundElement())
|
||||||
|
|
||||||
|
modelPart := modelStyle.Render(m.ModelName)
|
||||||
|
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
|
||||||
|
|
||||||
|
combinedText := modelPart + providerPart
|
||||||
|
return styles.NewStyle().
|
||||||
|
Background(t.BackgroundElement()).
|
||||||
|
PaddingLeft(1).
|
||||||
|
Render(combinedText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type modelKeyMap struct {
|
type modelKeyMap struct {
|
||||||
Up key.Binding
|
|
||||||
Down key.Binding
|
|
||||||
Left key.Binding
|
|
||||||
Right key.Binding
|
|
||||||
Enter key.Binding
|
Enter key.Binding
|
||||||
Escape key.Binding
|
Escape key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var modelKeys = modelKeyMap{
|
var modelKeys = modelKeyMap{
|
||||||
Up: key.NewBinding(
|
|
||||||
key.WithKeys("up", "k"),
|
|
||||||
key.WithHelp("↑", "previous model"),
|
|
||||||
),
|
|
||||||
Down: key.NewBinding(
|
|
||||||
key.WithKeys("down", "j"),
|
|
||||||
key.WithHelp("↓", "next model"),
|
|
||||||
),
|
|
||||||
Left: key.NewBinding(
|
|
||||||
key.WithKeys("left", "h"),
|
|
||||||
key.WithHelp("←", "scroll left"),
|
|
||||||
),
|
|
||||||
Right: key.NewBinding(
|
|
||||||
key.WithKeys("right", "l"),
|
|
||||||
key.WithHelp("→", "scroll right"),
|
|
||||||
),
|
|
||||||
Enter: key.NewBinding(
|
Enter: key.NewBinding(
|
||||||
key.WithKeys("enter"),
|
key.WithKeys("enter"),
|
||||||
key.WithHelp("enter", "select model"),
|
key.WithHelp("enter", "select model"),
|
||||||
@@ -81,15 +96,7 @@ var modelKeys = modelKeyMap{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelDialog) Init() tea.Cmd {
|
func (m *modelDialog) Init() tea.Cmd {
|
||||||
// cfg := config.Get()
|
m.setupAllModels()
|
||||||
// modelInfo := GetSelectedModel(cfg)
|
|
||||||
// m.availableProviders = getEnabledProviders(cfg)
|
|
||||||
// m.hScrollPossible = len(m.availableProviders) > 1
|
|
||||||
|
|
||||||
// m.provider = modelInfo.Provider
|
|
||||||
// m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
|
|
||||||
|
|
||||||
// m.setupModelsForProvider(m.provider)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,28 +104,20 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, modelKeys.Up):
|
|
||||||
m.moveSelectionUp()
|
|
||||||
case key.Matches(msg, modelKeys.Down):
|
|
||||||
m.moveSelectionDown()
|
|
||||||
case key.Matches(msg, modelKeys.Left):
|
|
||||||
if m.hScrollPossible {
|
|
||||||
m.switchProvider(-1)
|
|
||||||
}
|
|
||||||
case key.Matches(msg, modelKeys.Right):
|
|
||||||
if m.hScrollPossible {
|
|
||||||
m.switchProvider(1)
|
|
||||||
}
|
|
||||||
case key.Matches(msg, modelKeys.Enter):
|
case key.Matches(msg, modelKeys.Enter):
|
||||||
models := m.models()
|
_, selectedIndex := m.modelList.GetSelectedItem()
|
||||||
return m, tea.Sequence(
|
if selectedIndex >= 0 && selectedIndex < len(m.allModels) {
|
||||||
util.CmdHandler(modal.CloseModalMsg{}),
|
selectedModel := m.allModels[selectedIndex]
|
||||||
util.CmdHandler(
|
return m, tea.Sequence(
|
||||||
app.ModelSelectedMsg{
|
util.CmdHandler(modal.CloseModalMsg{}),
|
||||||
Provider: m.provider,
|
util.CmdHandler(
|
||||||
Model: models[m.selectedIdx],
|
app.ModelSelectedMsg{
|
||||||
}),
|
Provider: selectedModel.Provider,
|
||||||
)
|
Model: selectedModel.Model,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||||
case key.Matches(msg, modelKeys.Escape):
|
case key.Matches(msg, modelKeys.Escape):
|
||||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||||
}
|
}
|
||||||
@@ -127,163 +126,126 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
// Update the list component
|
||||||
}
|
updatedList, cmd := m.modelList.Update(msg)
|
||||||
|
m.modelList = updatedList.(list.List[ModelItem])
|
||||||
func (m *modelDialog) models() []client.ModelInfo {
|
return m, cmd
|
||||||
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ModelInfo) int {
|
|
||||||
return strings.Compare(a.Name, b.Name)
|
|
||||||
})
|
|
||||||
return models
|
|
||||||
}
|
|
||||||
|
|
||||||
// moveSelectionUp moves the selection up or wraps to bottom
|
|
||||||
func (m *modelDialog) moveSelectionUp() {
|
|
||||||
if m.selectedIdx > 0 {
|
|
||||||
m.selectedIdx--
|
|
||||||
} else {
|
|
||||||
m.selectedIdx = len(m.provider.Models) - 1
|
|
||||||
m.scrollOffset = max(0, len(m.provider.Models)-numVisibleModels)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep selection visible
|
|
||||||
if m.selectedIdx < m.scrollOffset {
|
|
||||||
m.scrollOffset = m.selectedIdx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// moveSelectionDown moves the selection down or wraps to top
|
|
||||||
func (m *modelDialog) moveSelectionDown() {
|
|
||||||
if m.selectedIdx < len(m.provider.Models)-1 {
|
|
||||||
m.selectedIdx++
|
|
||||||
} else {
|
|
||||||
m.selectedIdx = 0
|
|
||||||
m.scrollOffset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep selection visible
|
|
||||||
if m.selectedIdx >= m.scrollOffset+numVisibleModels {
|
|
||||||
m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *modelDialog) switchProvider(offset int) {
|
|
||||||
newOffset := m.hScrollOffset + offset
|
|
||||||
|
|
||||||
// Ensure we stay within bounds
|
|
||||||
if newOffset < 0 {
|
|
||||||
newOffset = len(m.availableProviders) - 1
|
|
||||||
}
|
|
||||||
if newOffset >= len(m.availableProviders) {
|
|
||||||
newOffset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
m.hScrollOffset = newOffset
|
|
||||||
m.provider = m.availableProviders[m.hScrollOffset]
|
|
||||||
m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name))
|
|
||||||
m.setupModelsForProvider(m.provider.Id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelDialog) View() string {
|
func (m *modelDialog) View() string {
|
||||||
t := theme.CurrentTheme()
|
return m.modelList.View()
|
||||||
baseStyle := lipgloss.NewStyle().
|
|
||||||
Background(t.BackgroundElement()).
|
|
||||||
Foreground(t.Text())
|
|
||||||
|
|
||||||
// Render visible models
|
|
||||||
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
|
|
||||||
modelItems := make([]string, 0, endIdx-m.scrollOffset)
|
|
||||||
|
|
||||||
models := m.models()
|
|
||||||
for i := m.scrollOffset; i < endIdx; i++ {
|
|
||||||
itemStyle := baseStyle.Width(maxDialogWidth)
|
|
||||||
if i == m.selectedIdx {
|
|
||||||
itemStyle = itemStyle.
|
|
||||||
Background(t.Primary()).
|
|
||||||
Foreground(t.BackgroundElement()).
|
|
||||||
Bold(true)
|
|
||||||
}
|
|
||||||
modelItems = append(modelItems, itemStyle.Render(models[i].Name))
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
|
|
||||||
|
|
||||||
content := lipgloss.JoinVertical(
|
|
||||||
lipgloss.Left,
|
|
||||||
baseStyle.
|
|
||||||
Width(maxDialogWidth).
|
|
||||||
Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
|
|
||||||
scrollIndicator,
|
|
||||||
)
|
|
||||||
|
|
||||||
return content
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelDialog) getScrollIndicators(maxWidth int) string {
|
func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
|
||||||
var indicator string
|
maxWidth := minDialogWidth
|
||||||
|
|
||||||
if len(m.provider.Models) > numVisibleModels {
|
for _, item := range modelItems {
|
||||||
if m.scrollOffset > 0 {
|
// Calculate the width needed for this item: "ModelName (ProviderName)"
|
||||||
indicator += "↑ "
|
// Add 4 for the parentheses, space, and some padding
|
||||||
}
|
itemWidth := len(item.ModelName) + len(item.ProviderName) + 4
|
||||||
if m.scrollOffset+numVisibleModels < len(m.provider.Models) {
|
if itemWidth > maxWidth {
|
||||||
indicator += "↓ "
|
maxWidth = itemWidth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.hScrollPossible {
|
if maxWidth > maxDialogWidth {
|
||||||
indicator = "← " + indicator + "→"
|
maxWidth = maxDialogWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
if indicator == "" {
|
return maxWidth
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
|
||||||
baseStyle := styles.BaseStyle()
|
|
||||||
|
|
||||||
return baseStyle.
|
|
||||||
Foreground(t.Primary()).
|
|
||||||
Width(maxWidth).
|
|
||||||
Align(lipgloss.Right).
|
|
||||||
Bold(true).
|
|
||||||
Render(indicator)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// findProviderIndex returns the index of the provider in the list, or -1 if not found
|
func (m *modelDialog) setupAllModels() {
|
||||||
// func findProviderIndex(providers []string, provider string) int {
|
providers, _ := m.app.ListProviders(context.Background())
|
||||||
// for i, p := range providers {
|
|
||||||
// if p == provider {
|
|
||||||
// return i
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// return -1
|
|
||||||
// }
|
|
||||||
|
|
||||||
func (m *modelDialog) setupModelsForProvider(_ string) {
|
m.allModels = make([]ModelWithProvider, 0)
|
||||||
m.selectedIdx = 0
|
for _, provider := range providers {
|
||||||
m.scrollOffset = 0
|
for _, model := range provider.Models {
|
||||||
|
m.allModels = append(m.allModels, ModelWithProvider{
|
||||||
|
Model: model,
|
||||||
|
Provider: provider,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cfg := config.Get()
|
m.sortModels()
|
||||||
// agentCfg := cfg.Agents[config.AgentPrimary]
|
|
||||||
// selectedModelId := agentCfg.Model
|
|
||||||
|
|
||||||
// m.provider = provider
|
modelItems := make([]ModelItem, len(m.allModels))
|
||||||
// m.models = getModelsForProvider(provider)
|
for i, modelWithProvider := range m.allModels {
|
||||||
|
modelItems[i] = ModelItem{
|
||||||
|
ModelName: modelWithProvider.Model.Name,
|
||||||
|
ProviderName: modelWithProvider.Provider.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try to select the current model if it belongs to this provider
|
m.dialogWidth = m.calculateOptimalWidth(modelItems)
|
||||||
// if provider == models.SupportedModels[selectedModelId].Provider {
|
|
||||||
// for i, model := range m.models {
|
m.modelList = list.NewListComponent(modelItems, numVisibleModels, "No models available", true)
|
||||||
// if model.ID == selectedModelId {
|
m.modelList.SetMaxWidth(m.dialogWidth)
|
||||||
// m.selectedIdx = i
|
|
||||||
// // Adjust scroll position to keep selected model visible
|
if len(m.allModels) > 0 {
|
||||||
// if m.selectedIdx >= numVisibleModels {
|
m.modelList.SetSelectedIndex(0)
|
||||||
// m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
|
}
|
||||||
// }
|
}
|
||||||
// break
|
|
||||||
// }
|
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{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelDialog) Render(background string) string {
|
func (m *modelDialog) Render(background string) string {
|
||||||
@@ -295,13 +257,16 @@ func (s *modelDialog) Close() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewModelDialog(app *app.App) ModelDialog {
|
func NewModelDialog(app *app.App) ModelDialog {
|
||||||
availableProviders, _ := app.ListProviders(context.Background())
|
dialog := &modelDialog{
|
||||||
|
app: app,
|
||||||
return &modelDialog{
|
|
||||||
availableProviders: availableProviders,
|
|
||||||
hScrollOffset: 0,
|
|
||||||
hScrollPossible: len(availableProviders) > 1,
|
|
||||||
provider: availableProviders[0],
|
|
||||||
modal: modal.New(modal.WithTitle(fmt.Sprintf("Select %s Model", availableProviders[0].Name))),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dialog.setupAllModels()
|
||||||
|
|
||||||
|
dialog.modal = modal.New(
|
||||||
|
modal.WithTitle("Select Model"),
|
||||||
|
modal.WithMaxWidth(dialog.dialogWidth+4),
|
||||||
|
)
|
||||||
|
|
||||||
|
return dialog
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ func (p *permissionDialogComponent) selectCurrentOption() tea.Cmd {
|
|||||||
|
|
||||||
func (p *permissionDialogComponent) renderButtons() string {
|
func (p *permissionDialogComponent) renderButtons() string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
baseStyle := styles.BaseStyle()
|
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||||
|
|
||||||
allowStyle := baseStyle
|
allowStyle := baseStyle
|
||||||
allowSessionStyle := baseStyle
|
allowSessionStyle := baseStyle
|
||||||
@@ -355,8 +355,7 @@ func (p *permissionDialogComponent) renderDefaultContent() string {
|
|||||||
|
|
||||||
func (p *permissionDialogComponent) styleViewport() string {
|
func (p *permissionDialogComponent) styleViewport() string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
contentStyle := lipgloss.NewStyle().
|
contentStyle := styles.NewStyle().Background(t.Background())
|
||||||
Background(t.Background())
|
|
||||||
|
|
||||||
return contentStyle.Render(p.contentViewPort.View())
|
return contentStyle.Render(p.contentViewPort.View())
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user