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 | ||
|
|
b4c2fcccf5 | ||
|
|
e950ad5306 |
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,6 +40,9 @@ For more info on how to configure opencode [**head over to our docs**](https://o
|
|||||||
|
|
||||||
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.
|
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
|
||||||
@@ -54,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
|
||||||
|
|
||||||
@@ -74,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,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "0.0.0",
|
"version": "0.0.5",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -36,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",
|
||||||
@@ -81,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:",
|
||||||
@@ -541,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=="],
|
||||||
@@ -633,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=="],
|
||||||
@@ -669,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=="],
|
||||||
@@ -725,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=="],
|
||||||
@@ -939,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=="],
|
||||||
@@ -987,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=="],
|
||||||
@@ -1169,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=="],
|
||||||
@@ -1247,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=="],
|
||||||
|
|
||||||
@@ -1257,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=="],
|
||||||
@@ -1267,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=="],
|
||||||
@@ -1417,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=="],
|
||||||
@@ -1525,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=="],
|
||||||
|
|
||||||
@@ -1793,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=="],
|
||||||
|
|||||||
+5
-1
@@ -3,7 +3,11 @@
|
|||||||
"experimental": {
|
"experimental": {
|
||||||
"hook": {
|
"hook": {
|
||||||
"file_edited": {
|
"file_edited": {
|
||||||
".json": []
|
".json": [
|
||||||
|
{
|
||||||
|
"command": ["bun", "run", "prettier", "$FILE"]
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"session_completed": [
|
"session_completed": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ set "current_dir=%parent_dir%"
|
|||||||
goto :search_loop
|
goto :search_loop
|
||||||
|
|
||||||
:not_found
|
: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
|
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
|
exit /b 1
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
rem Execute the binary with all arguments
|
rem Execute the binary with all arguments
|
||||||
"%resolved%" %*
|
"%resolved%" %*
|
||||||
|
|||||||
@@ -202,10 +202,7 @@
|
|||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["input", "output"],
|
||||||
"input",
|
|
||||||
"output"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"limit": {
|
"limit": {
|
||||||
@@ -218,10 +215,7 @@
|
|||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["context", "output"],
|
||||||
"context",
|
|
||||||
"output"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
@@ -240,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"
|
||||||
@@ -272,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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -291,12 +284,13 @@
|
|||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -329,9 +323,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["command"],
|
||||||
"command"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,9 +346,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["command"],
|
||||||
"command"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,4 +359,4 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,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",
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -13,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(),
|
||||||
@@ -26,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,
|
||||||
})
|
})
|
||||||
@@ -62,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,
|
||||||
},
|
},
|
||||||
@@ -72,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>(
|
||||||
@@ -107,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()
|
||||||
@@ -142,4 +154,3 @@ export namespace App {
|
|||||||
.replace(/[^A-Za-z0-9_]/g, "-")
|
.replace(/[^A-Za-z0-9_]/g, "-")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" })
|
||||||
@@ -25,11 +26,9 @@ export namespace BunProc {
|
|||||||
BUN_BE_BUN: "1",
|
BUN_BE_BUN: "1",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const code = await result.exited
|
const code = await result.exited;
|
||||||
// @ts-ignore
|
const stdout = result.stdout ? typeof result.stdout === "number" ? result.stdout : await readableStreamToText(result.stdout) : undefined
|
||||||
const stdout = await result.stdout.text()
|
const stderr = result.stderr ? typeof result.stderr === "number" ? result.stderr : await readableStreamToText(result.stderr) : undefined
|
||||||
// @ts-ignore
|
|
||||||
const stderr = await result.stderr.text()
|
|
||||||
log.info("done", {
|
log.info("done", {
|
||||||
code,
|
code,
|
||||||
stdout,
|
stdout,
|
||||||
@@ -65,7 +64,7 @@ export namespace BunProc {
|
|||||||
await BunProc.run(["install", "--registry=https://registry.npmjs.org"], {
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export const AuthListCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
const authPath = path.join(Global.Path.data, "auth.json")
|
const authPath = path.join(Global.Path.data, "auth.json")
|
||||||
const homedir = os.homedir()
|
const homedir = os.homedir()
|
||||||
const displayPath = authPath.startsWith(homedir)
|
const displayPath = authPath.startsWith(homedir)
|
||||||
? authPath.replace(homedir, "~")
|
? authPath.replace(homedir, "~")
|
||||||
: authPath
|
: authPath
|
||||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||||
@@ -46,14 +46,14 @@ export const AuthListCommand = cmd({
|
|||||||
prompts.outro(`${results.length} credentials`)
|
prompts.outro(`${results.length} credentials`)
|
||||||
|
|
||||||
// Environment variables section
|
// Environment variables section
|
||||||
const activeEnvVars: Array<{ provider: string, envVar: string }> = []
|
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||||
|
|
||||||
for (const [providerID, provider] of Object.entries(database)) {
|
for (const [providerID, provider] of Object.entries(database)) {
|
||||||
for (const envVar of provider.env) {
|
for (const envVar of provider.env) {
|
||||||
if (process.env[envVar]) {
|
if (process.env[envVar]) {
|
||||||
activeEnvVars.push({
|
activeEnvVars.push({
|
||||||
provider: provider.name || providerID,
|
provider: provider.name || providerID,
|
||||||
envVar
|
envVar,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,11 +62,11 @@ export const AuthListCommand = cmd({
|
|||||||
if (activeEnvVars.length > 0) {
|
if (activeEnvVars.length > 0) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Environment")
|
prompts.intro("Environment")
|
||||||
|
|
||||||
for (const { provider, envVar } of activeEnvVars) {
|
for (const { provider, envVar } of activeEnvVars) {
|
||||||
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts.outro(`${activeEnvVars.length} environment variables`)
|
prompts.outro(`${activeEnvVars.length} environment variables`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -8,7 +8,7 @@ export const ModelsCommand = cmd({
|
|||||||
handler: async () => {
|
handler: async () => {
|
||||||
await App.provide({ cwd: process.cwd() }, async () => {
|
await App.provide({ cwd: process.cwd() }, async () => {
|
||||||
const providers = await Provider.list()
|
const providers = await Provider.list()
|
||||||
|
|
||||||
for (const [providerID, provider] of Object.entries(providers)) {
|
for (const [providerID, provider] of Object.entries(providers)) {
|
||||||
for (const modelID of Object.keys(provider.info.models)) {
|
for (const modelID of Object.keys(provider.info.models)) {
|
||||||
console.log(`${providerID}/${modelID}`)
|
console.log(`${providerID}/${modelID}`)
|
||||||
@@ -16,4 +16,4 @@ export const ModelsCommand = cmd({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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],
|
||||||
@@ -56,118 +55,109 @@ export const RunCommand = cmd({
|
|||||||
},
|
},
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPiped = !process.stdout.isTTY
|
if (args.session) return Session.get(args.session)
|
||||||
|
|
||||||
UI.empty()
|
return Session.create()
|
||||||
UI.println(UI.logo())
|
})()
|
||||||
UI.empty()
|
|
||||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
|
||||||
UI.empty()
|
|
||||||
|
|
||||||
const cfg = await Config.get()
|
if (!session) {
|
||||||
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
UI.error("Session not found")
|
||||||
await Session.share(session.id)
|
return
|
||||||
UI.println(
|
}
|
||||||
UI.Style.TEXT_INFO_BOLD +
|
|
||||||
"~ https://opencode.ai/s/" +
|
|
||||||
session.id.slice(-8),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
UI.empty()
|
|
||||||
|
|
||||||
const { providerID, modelID } = args.model
|
const isPiped = !process.stdout.isTTY
|
||||||
? Provider.parseModel(args.model)
|
|
||||||
: await Provider.defaultModel()
|
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.println(
|
||||||
UI.Style.TEXT_NORMAL_BOLD + "@ ",
|
UI.Style.TEXT_INFO_BOLD +
|
||||||
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
|
"~ https://opencode.ai/s/" +
|
||||||
|
session.id.slice(-8),
|
||||||
)
|
)
|
||||||
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 || "Unknown")
|
|
||||||
}
|
}
|
||||||
|
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||||
if (part.type === "text") {
|
|
||||||
if (part.text.includes("\n")) {
|
|
||||||
UI.empty()
|
|
||||||
UI.println(part.text)
|
|
||||||
UI.empty()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await Session.chat({
|
|
||||||
sessionID: session.id,
|
|
||||||
providerID,
|
|
||||||
modelID,
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: message,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isPiped) {
|
|
||||||
const match = result.parts.findLast((x) => x.type === "text")
|
|
||||||
if (match) process.stdout.write(match.text)
|
|
||||||
}
|
}
|
||||||
UI.empty()
|
})
|
||||||
},
|
|
||||||
)
|
const result = await Session.chat({
|
||||||
|
sessionID: session.id,
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isPiped) {
|
||||||
|
const match = result.parts.findLast((x) => x.type === "text")
|
||||||
|
if (match) process.stdout.write(match.text)
|
||||||
|
}
|
||||||
|
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())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { App } from "../../app/app"
|
|
||||||
import { Provider } from "../../provider/provider"
|
import { Provider } from "../../provider/provider"
|
||||||
import { Server } from "../../server/server"
|
import { Server } from "../../server/server"
|
||||||
import { Share } from "../../share/share"
|
import { Share } from "../../share/share"
|
||||||
|
import { bootstrap } from "../bootstrap"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
|
|
||||||
export const ServeCommand = cmd({
|
export const ServeCommand = cmd({
|
||||||
@@ -23,7 +23,7 @@ export const ServeCommand = cmd({
|
|||||||
describe: "starts a headless opencode server",
|
describe: "starts a headless opencode server",
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
await App.provide({ cwd }, async () => {
|
await bootstrap({ cwd }, async () => {
|
||||||
const providers = await Provider.list()
|
const providers = await Provider.list()
|
||||||
if (Object.keys(providers).length === 0) {
|
if (Object.keys(providers).length === 0) {
|
||||||
return "needs_provider"
|
return "needs_provider"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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({
|
||||||
@@ -196,7 +205,7 @@ export namespace Config {
|
|||||||
})
|
})
|
||||||
.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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
-132
@@ -1,132 +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
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,143 +1,65 @@
|
|||||||
import { App } from '../app/app'
|
import { App } from "../app/app"
|
||||||
import { BunProc } from '../bun'
|
import { Bus } from "../bus"
|
||||||
import { Config } from '../config/config'
|
import { File } from "../file"
|
||||||
import { Log } from '../util/log'
|
import { Log } from "../util/log"
|
||||||
import path from 'path'
|
import path from "path"
|
||||||
|
|
||||||
|
import * as Formatter from "./formatter"
|
||||||
|
|
||||||
export namespace Format {
|
export namespace Format {
|
||||||
const log = Log.create({ service: 'format' })
|
const log = Log.create({ service: "format" })
|
||||||
|
|
||||||
const state = App.state('format', async () => {
|
const state = App.state("format", () => {
|
||||||
const hooks: Record<string, Hook[]> = {}
|
const enabled: Record<string, boolean> = {}
|
||||||
for (const item of FORMATTERS) {
|
|
||||||
if (await item.enabled()) {
|
|
||||||
for (const ext of item.extensions) {
|
|
||||||
const list = hooks[ext] ?? []
|
|
||||||
list.push({
|
|
||||||
command: item.command,
|
|
||||||
environment: item.environment,
|
|
||||||
})
|
|
||||||
hooks[ext] = list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = await Config.get()
|
|
||||||
for (const [file, items] of Object.entries(
|
|
||||||
cfg.experimental?.hook?.file_edited ?? {},
|
|
||||||
)) {
|
|
||||||
for (const item of items) {
|
|
||||||
const list = hooks[file] ?? []
|
|
||||||
list.push({
|
|
||||||
command: item.command,
|
|
||||||
environment: item.environment,
|
|
||||||
})
|
|
||||||
hooks[file] = list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hooks,
|
enabled,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function run(file: string) {
|
async function isEnabled(item: Formatter.Info) {
|
||||||
log.info('formatting', { file })
|
const s = state()
|
||||||
const { hooks } = await state()
|
let status = s.enabled[item.name]
|
||||||
const ext = path.extname(file)
|
if (status === undefined) {
|
||||||
const match = hooks[ext]
|
status = await item.enabled()
|
||||||
if (!match) return
|
s.enabled[item.name] = status
|
||||||
|
|
||||||
for (const item of match) {
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
const exit = await proc.exited
|
|
||||||
if (exit !== 0)
|
|
||||||
log.error('failed', {
|
|
||||||
command: item.command,
|
|
||||||
...item.environment,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Hook {
|
async function getFormatter(ext: string) {
|
||||||
command: string[]
|
const result = []
|
||||||
environment?: Record<string, string>
|
for (const item of Object.values(Formatter)) {
|
||||||
|
if (!item.extensions.includes(ext)) continue
|
||||||
|
if (!(await isEnabled(item))) continue
|
||||||
|
result.push(item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Native {
|
export function init() {
|
||||||
name: string
|
log.info("init")
|
||||||
command: string[]
|
Bus.subscribe(File.Event.Edited, async (payload) => {
|
||||||
environment?: Record<string, string>
|
const file = payload.properties.file
|
||||||
extensions: string[]
|
log.info("formatting", { file })
|
||||||
enabled(): Promise<boolean>
|
const ext = path.extname(file)
|
||||||
}
|
|
||||||
|
|
||||||
const FORMATTERS: Native[] = [
|
for (const item of await getFormatter(ext)) {
|
||||||
{
|
log.info("running", { command: item.command })
|
||||||
name: 'prettier',
|
const proc = Bun.spawn({
|
||||||
extensions: [
|
cmd: item.command.map((x) => x.replace("$FILE", file)),
|
||||||
'.js',
|
cwd: App.info().path.cwd,
|
||||||
'.jsx',
|
env: item.environment,
|
||||||
'.mjs',
|
stdout: "ignore",
|
||||||
'.cjs',
|
stderr: "ignore",
|
||||||
'.ts',
|
})
|
||||||
'.tsx',
|
const exit = await proc.exited
|
||||||
'.mts',
|
if (exit !== 0)
|
||||||
'.cts',
|
log.error("failed", {
|
||||||
'.html',
|
command: item.command,
|
||||||
'.htm',
|
...item.environment,
|
||||||
'.css',
|
|
||||||
'.scss',
|
|
||||||
'.sass',
|
|
||||||
'.less',
|
|
||||||
'.vue',
|
|
||||||
'.svelte',
|
|
||||||
'.json',
|
|
||||||
'.jsonc',
|
|
||||||
'.yaml',
|
|
||||||
'.yml',
|
|
||||||
'.toml',
|
|
||||||
'.xml',
|
|
||||||
'.md',
|
|
||||||
'.mdx',
|
|
||||||
'.php',
|
|
||||||
'.rb',
|
|
||||||
'.java',
|
|
||||||
'.go',
|
|
||||||
'.rs',
|
|
||||||
'.swift',
|
|
||||||
'.kt',
|
|
||||||
'.kts',
|
|
||||||
'.sol',
|
|
||||||
'.graphql',
|
|
||||||
'.gql',
|
|
||||||
],
|
|
||||||
command: [BunProc.which(), 'run', 'prettier', '--write', '$FILE'],
|
|
||||||
environment: {
|
|
||||||
BUN_BE_BUN: '1',
|
|
||||||
},
|
|
||||||
async enabled() {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-111
@@ -1,28 +1,19 @@
|
|||||||
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 url from "node:url"
|
|
||||||
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 { ModelsCommand } from "./cli/cmd/models"
|
import { ModelsCommand } from "./cli/cmd/models"
|
||||||
import { Provider } from "./provider/provider"
|
|
||||||
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 { ServeCommand } from "./cli/cmd/serve"
|
||||||
|
import { TuiCommand } from "./cli/cmd/tui"
|
||||||
|
import { DebugCommand } from "./cli/cmd/debug"
|
||||||
|
|
||||||
const cancel = new AbortController()
|
const cancel = new AbortController()
|
||||||
|
|
||||||
@@ -55,106 +46,10 @@ 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()
|
|
||||||
try {
|
|
||||||
process.chdir(cwd)
|
|
||||||
} catch (e) {
|
|
||||||
UI.error("Failed to change directory to " + cwd)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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({
|
|
||||||
port: 0,
|
|
||||||
hostname: "127.0.0.1",
|
|
||||||
})
|
|
||||||
|
|
||||||
let cmd = ["go", "run", "./main.go"]
|
|
||||||
let cwd = url.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)],
|
|
||||||
signal: cancel.signal,
|
|
||||||
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(" "))
|
|
||||||
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(ServeCommand)
|
||||||
@@ -172,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,
|
||||||
@@ -186,6 +82,18 @@ 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)
|
||||||
@@ -193,6 +101,7 @@ try {
|
|||||||
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()
|
cancel.abort()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ 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 })
|
log.info("found", { key, type: mcp.type })
|
||||||
if (mcp.type === "remote") {
|
if (mcp.type === "remote") {
|
||||||
const client = await experimental_createMCPClient({
|
const client = await experimental_createMCPClient({
|
||||||
|
|||||||
@@ -10,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(),
|
||||||
@@ -25,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>
|
||||||
|
|
||||||
@@ -43,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>
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ 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"
|
||||||
@@ -23,6 +21,7 @@ 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"
|
||||||
|
|
||||||
export namespace Provider {
|
export namespace Provider {
|
||||||
const log = Log.create({ service: "provider" })
|
const log = Log.create({ service: "provider" })
|
||||||
@@ -100,11 +99,25 @@ export namespace Provider {
|
|||||||
})
|
})
|
||||||
info.access = tokens.access
|
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 = {
|
const headers = {
|
||||||
...init.headers,
|
...init.headers,
|
||||||
...copilot.HEADERS,
|
...copilot.HEADERS,
|
||||||
Authorization: `Bearer ${info.access}`,
|
Authorization: `Bearer ${info.access}`,
|
||||||
"Openai-Intent": "conversation-edits",
|
"Openai-Intent": "conversation-edits",
|
||||||
|
"X-Initiator": isAgentCall ? "agent" : "user",
|
||||||
}
|
}
|
||||||
delete headers["x-api-key"]
|
delete headers["x-api-key"]
|
||||||
return fetch(input, {
|
return fetch(input, {
|
||||||
@@ -140,14 +153,69 @@ export namespace Provider {
|
|||||||
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 () => {
|
||||||
@@ -185,6 +253,7 @@ export namespace Provider {
|
|||||||
source,
|
source,
|
||||||
info,
|
info,
|
||||||
options,
|
options,
|
||||||
|
getModel,
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -202,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 ?? {},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,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,
|
||||||
@@ -243,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
|
||||||
@@ -402,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[]> = {
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ export namespace ProviderTransform {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
for (const msg of unique([...system, ...final])) {
|
||||||
|
msg.providerMetadata = {
|
||||||
|
...msg.providerMetadata,
|
||||||
|
bedrock: {
|
||||||
|
cachePoint: { type: "ephemeral" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return msgs
|
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,33 +201,28 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
"/session_abort",
|
"/session",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Abort a session",
|
description: "Create a new session",
|
||||||
responses: {
|
responses: {
|
||||||
|
...ERRORS,
|
||||||
200: {
|
200: {
|
||||||
description: "Aborted session",
|
description: "Successfully created session",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(z.boolean()),
|
schema: resolver(Session.Info),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
zValidator(
|
|
||||||
"json",
|
|
||||||
z.object({
|
|
||||||
sessionID: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json")
|
const session = await Session.create()
|
||||||
return c.json(Session.abort(body.sessionID))
|
return c.json(session)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.delete(
|
||||||
"/session_delete",
|
"/session/:id",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Delete a session and all its data",
|
description: "Delete a session and all its data",
|
||||||
responses: {
|
responses: {
|
||||||
@@ -406,24 +237,23 @@ 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")
|
await Session.remove(c.req.valid("param").id)
|
||||||
await Session.remove(body.sessionID)
|
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
"/session_summarize",
|
"/session/:id/init",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Summarize the session",
|
description: "Analyze the app and create an AGENTS.md file",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "Summarize the session",
|
description: "200",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(z.boolean()),
|
schema: resolver(z.boolean()),
|
||||||
@@ -432,27 +262,175 @@ 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 sessionID = c.req.valid("param").id
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
await Session.summarize(body)
|
await Session.initialize({ ...body, sessionID })
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
"/session_chat",
|
"/session/:id/abort",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Chat with a model",
|
description: "Abort a session",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "Chat with a model",
|
description: "Aborted session",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
return c.json(Session.abort(c.req.valid("param").id))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/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({
|
||||||
|
description: "Summarize the session",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Summarized session",
|
||||||
|
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 id = c.req.valid("param").id
|
||||||
|
const body = c.req.valid("json")
|
||||||
|
await Session.summarize({ ...body, sessionID: id })
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/session/:id/message",
|
||||||
|
describeRoute({
|
||||||
|
description: "List messages for a session",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
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),
|
||||||
@@ -461,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: {
|
||||||
@@ -509,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()),
|
||||||
@@ -525,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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,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" })
|
||||||
@@ -53,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 = {
|
||||||
@@ -78,6 +90,12 @@ export namespace Session {
|
|||||||
info: Info,
|
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({
|
||||||
@@ -267,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[]
|
||||||
}) {
|
}) {
|
||||||
@@ -275,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
|
||||||
@@ -309,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,
|
||||||
@@ -339,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"),
|
||||||
@@ -349,6 +398,7 @@ export namespace Session {
|
|||||||
},
|
},
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
tool: {},
|
tool: {},
|
||||||
|
snapshot,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
await updateMessage(msg)
|
await updateMessage(msg)
|
||||||
@@ -363,6 +413,7 @@ export namespace Session {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [],
|
parts: [],
|
||||||
metadata: {
|
metadata: {
|
||||||
|
snapshot,
|
||||||
assistant: {
|
assistant: {
|
||||||
system,
|
system,
|
||||||
path: {
|
path: {
|
||||||
@@ -414,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(),
|
||||||
@@ -426,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(),
|
||||||
@@ -447,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(),
|
||||||
@@ -461,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,
|
||||||
@@ -492,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) {
|
||||||
@@ -537,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,
|
||||||
@@ -670,7 +717,7 @@ export namespace Session {
|
|||||||
value.usage,
|
value.usage,
|
||||||
value.providerMetadata,
|
value.providerMetadata,
|
||||||
)
|
)
|
||||||
assistant.cost = usage.cost
|
assistant.cost += usage.cost
|
||||||
await updateMessage(next)
|
await updateMessage(next)
|
||||||
if (value.finishReason === "length")
|
if (value.finishReason === "length")
|
||||||
throw new Message.OutputLengthError({})
|
throw new Message.OutputLengthError({})
|
||||||
@@ -733,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
|
||||||
@@ -819,7 +911,7 @@ export namespace Session {
|
|||||||
async onFinish(input) {
|
async onFinish(input) {
|
||||||
const assistant = next.metadata!.assistant!
|
const assistant = next.metadata!.assistant!
|
||||||
const usage = getUsage(model.info, input.usage, input.providerMetadata)
|
const usage = getUsage(model.info, input.usage, input.providerMetadata)
|
||||||
assistant.cost = usage.cost
|
assistant.cost += usage.cost
|
||||||
assistant.tokens = usage.tokens
|
assistant.tokens = usage.tokens
|
||||||
next.metadata!.time.completed = Date.now()
|
next.metadata!.time.completed = Date.now()
|
||||||
await updateMessage(next)
|
await updateMessage(next)
|
||||||
@@ -853,16 +945,8 @@ export namespace Session {
|
|||||||
[Symbol.dispose]() {
|
[Symbol.dispose]() {
|
||||||
log.info("unlocking", { sessionID })
|
log.info("unlocking", { sessionID })
|
||||||
state().pending.delete(sessionID)
|
state().pending.delete(sessionID)
|
||||||
Config.get().then((cfg) => {
|
Bus.publish(Event.Idle, {
|
||||||
if (cfg.experimental?.hook?.session_completed) {
|
sessionID,
|
||||||
for (const item of cfg.experimental.hook.session_completed) {
|
|
||||||
Bun.spawn({
|
|
||||||
cmd: item.command,
|
|
||||||
cwd: App.info().path.cwd,
|
|
||||||
env: item.environment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -879,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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -952,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) {
|
||||||
|
|||||||
@@ -18,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>
|
||||||
|
|
||||||
@@ -31,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>
|
||||||
|
|
||||||
@@ -45,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>
|
||||||
|
|
||||||
@@ -62,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>
|
||||||
|
|
||||||
@@ -73,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>
|
||||||
|
|
||||||
@@ -83,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>
|
||||||
|
|
||||||
@@ -96,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>
|
||||||
|
|
||||||
@@ -108,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>
|
||||||
|
|
||||||
@@ -117,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,
|
||||||
@@ -131,15 +131,15 @@ 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
|
metadata: z
|
||||||
.object({
|
.object({
|
||||||
time: z.object({
|
time: z.object({
|
||||||
@@ -159,6 +159,7 @@ export namespace Message {
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
|
snapshot: z.string().optional(),
|
||||||
time: z.object({
|
time: z.object({
|
||||||
start: z.number(),
|
start: z.number(),
|
||||||
end: z.number(),
|
end: z.number(),
|
||||||
@@ -188,11 +189,12 @@ export namespace Message {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
snapshot: z.string().optional(),
|
||||||
})
|
})
|
||||||
.openapi({ ref: "Message.Metadata" }),
|
.openapi({ ref: "MessageMetadata" }),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Message.Info",
|
ref: "Message",
|
||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
@@ -203,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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
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 { Format } from "../format"
|
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",
|
||||||
@@ -60,7 +61,9 @@ 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 Format.run(filepath)
|
await Bus.publish(File.Event.Edited, {
|
||||||
|
file: filepath,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +72,7 @@ export const EditTool = Tool.define({
|
|||||||
if (!stats) throw new Error(`File ${filepath} not found`)
|
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()
|
||||||
|
|
||||||
contentNew = replace(
|
contentNew = replace(
|
||||||
@@ -79,14 +82,17 @@ export const EditTool = Tool.define({
|
|||||||
params.replaceAll,
|
params.replaceAll,
|
||||||
)
|
)
|
||||||
await file.write(contentNew)
|
await file.write(contentNew)
|
||||||
await Format.run(filepath)
|
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)
|
||||||
|
|||||||
@@ -3,7 +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 "../external/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
|
|
||||||
export const GlobTool = Tool.define({
|
export const GlobTool = Tool.define({
|
||||||
id: "glob",
|
id: "glob",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +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 { Format } from "../format"
|
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",
|
||||||
@@ -27,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",
|
||||||
@@ -43,8 +44,10 @@ export const WriteTool = Tool.define({
|
|||||||
})
|
})
|
||||||
|
|
||||||
await Bun.write(filepath, params.content)
|
await Bun.write(filepath, params.content)
|
||||||
await Format.run(filepath)
|
await Bus.publish(File.Event.Edited, {
|
||||||
FileTimes.read(ctx.sessionID, filepath)
|
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].replace(/:/g, "") + ".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 {}
|
||||||
|
|||||||
@@ -316,13 +316,13 @@ const testCases: TestCase[] = [
|
|||||||
// WhitespaceNormalizedReplacer - test regex special characters that could cause errors
|
// WhitespaceNormalizedReplacer - test regex special characters that could cause errors
|
||||||
{
|
{
|
||||||
content: 'const pattern = "test[123]";',
|
content: 'const pattern = "test[123]";',
|
||||||
find: 'test[123]',
|
find: "test[123]",
|
||||||
replace: 'test[456]',
|
replace: "test[456]",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: 'const regex = "^start.*end$";',
|
content: 'const regex = "^start.*end$";',
|
||||||
find: '^start.*end$',
|
find: "^start.*end$",
|
||||||
replace: '^begin.*finish$',
|
replace: "^begin.*finish$",
|
||||||
},
|
},
|
||||||
|
|
||||||
// EscapeNormalizedReplacer - test single backslash vs double backslash
|
// EscapeNormalizedReplacer - test single backslash vs double backslash
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
- **Build**: `go build ./cmd/opencode` (builds main binary)
|
- **Build**: `go build ./cmd/opencode` (builds main binary)
|
||||||
- **Test**: `go test ./...` (runs all tests)
|
- **Test**: `go test ./...` (runs all tests)
|
||||||
- **Single test**: `go test ./internal/theme -run TestLoadThemesFromJSON` (specific test)
|
- **Single test**: `go test ./internal/theme -run TestLoadThemesFromJSON` (specific test)
|
||||||
- **Generate client**: `go generate ./pkg/client/` (after server endpoint changes)
|
|
||||||
- **Release build**: Uses `.goreleaser.yml` configuration
|
- **Release build**: Uses `.goreleaser.yml` configuration
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
@@ -23,4 +22,4 @@
|
|||||||
- **Client**: Generated OpenAPI client communicates with TypeScript server
|
- **Client**: Generated OpenAPI client communicates with TypeScript server
|
||||||
- **Components**: Reusable UI components in `internal/components/`
|
- **Components**: Reusable UI components in `internal/components/`
|
||||||
- **Themes**: JSON-based theming system with override hierarchy
|
- **Themes**: JSON-based theming system with override hierarchy
|
||||||
- **State**: Centralized app state with message passing
|
- **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,7 +26,7 @@ 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
|
||||||
err := json.Unmarshal([]byte(appInfoStr), &appInfo)
|
err := json.Unmarshal([]byte(appInfoStr), &appInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to unmarshal app info", "error", err)
|
slog.Error("Failed to unmarshal app info", "error", err)
|
||||||
@@ -49,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)
|
||||||
@@ -66,27 +72,20 @@ func main() {
|
|||||||
|
|
||||||
program := tea.NewProgram(
|
program := tea.NewProgram(
|
||||||
tui.NewModel(app_),
|
tui.NewModel(app_),
|
||||||
// tea.WithColorProfile(colorprofile.ANSI),
|
|
||||||
tea.WithAltScreen(),
|
tea.WithAltScreen(),
|
||||||
tea.WithKeyboardEnhancements(),
|
tea.WithKeyboardEnhancements(),
|
||||||
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=
|
||||||
|
|||||||
+107
-150
@@ -11,35 +11,34 @@ import (
|
|||||||
"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/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/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{}
|
||||||
@@ -51,31 +50,28 @@ type CompletionDialogTriggeredMsg struct {
|
|||||||
InitialValue string
|
InitialValue string
|
||||||
}
|
}
|
||||||
type OptimisticMessageAddedMsg struct {
|
type OptimisticMessageAddedMsg struct {
|
||||||
Message client.MessageInfo
|
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")
|
||||||
@@ -85,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,
|
||||||
@@ -122,33 +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
|
||||||
}
|
}
|
||||||
if providersResponse != nil && providersResponse.StatusCode() != 200 {
|
providers := providersResponse.Providers
|
||||||
slog.Error("failed to retrieve providers", "status", providersResponse.StatusCode(), "message", string(providersResponse.Body))
|
var defaultProvider *opencode.Provider
|
||||||
return nil
|
var defaultModel *opencode.Model
|
||||||
}
|
|
||||||
providers := []client.ProviderInfo{}
|
|
||||||
var defaultProvider *client.ProviderInfo
|
|
||||||
var defaultModel *client.ModelInfo
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,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)
|
||||||
@@ -171,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,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,
|
||||||
@@ -197,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 {
|
||||||
@@ -222,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() {
|
||||||
@@ -245,19 +249,14 @@ 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...)
|
||||||
@@ -265,48 +264,37 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
|||||||
|
|
||||||
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
||||||
go func() {
|
go func() {
|
||||||
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
|
_, err := a.Client.Session.Summarize(ctx, a.Session.ID, opencode.SessionSummarizeParams{
|
||||||
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 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 {
|
||||||
return toast.NewErrorToast(err.Error())
|
return toast.NewErrorToast(err.Error())
|
||||||
@@ -315,26 +303,18 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
|||||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||||
}
|
}
|
||||||
|
|
||||||
part := client.MessagePart{}
|
optimisticMessage := opencode.Message{
|
||||||
part.FromMessagePartText(client.MessagePartText{
|
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
|
||||||
Type: "text",
|
Role: opencode.MessageRoleUser,
|
||||||
Text: text,
|
Parts: []opencode.MessagePart{{
|
||||||
})
|
Type: opencode.MessagePartTypeText,
|
||||||
parts := []client.MessagePart{part}
|
Text: text,
|
||||||
|
}},
|
||||||
optimisticMessage := client.MessageInfo{
|
Metadata: opencode.MessageMetadata{
|
||||||
Id: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
|
SessionID: a.Session.ID,
|
||||||
Role: client.User,
|
Time: opencode.MessageMetadataTime{
|
||||||
Parts: parts,
|
Created: float64(time.Now().Unix()),
|
||||||
Metadata: client.MessageMetadata{
|
|
||||||
SessionID: a.Session.Id,
|
|
||||||
Time: struct {
|
|
||||||
Completed *float32 `json:"completed,omitempty"`
|
|
||||||
Created float32 `json:"created"`
|
|
||||||
}{
|
|
||||||
Created: float32(time.Now().Unix()),
|
|
||||||
},
|
},
|
||||||
Tool: make(map[string]client.MessageMetadata_Tool_AdditionalProperties),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,22 +322,21 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
|||||||
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
|
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
|
||||||
|
|
||||||
cmds = append(cmds, func() tea.Msg {
|
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 {
|
||||||
errormsg := fmt.Sprintf("failed to send message: %v", err)
|
errormsg := fmt.Sprintf("failed to send message: %v", err)
|
||||||
slog.Error(errormsg)
|
slog.Error(errormsg)
|
||||||
return toast.NewErrorToast(errormsg)()
|
return toast.NewErrorToast(errormsg)()
|
||||||
}
|
}
|
||||||
if response != nil && response.StatusCode != 200 {
|
|
||||||
errormsg := fmt.Sprintf("failed to send message: %d", response.StatusCode)
|
|
||||||
slog.Error(errormsg)
|
|
||||||
return toast.NewErrorToast(errormsg)()
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -367,83 +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 {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
|
func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
|
||||||
resp, err := a.Client.PostSessionListWithResponse(ctx)
|
response, err := a.Client.Session.List(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.Session{}, nil
|
||||||
}
|
}
|
||||||
if resp.JSON200 == nil {
|
sessions := *response
|
||||||
return []client.SessionInfo{}, nil
|
|
||||||
}
|
|
||||||
sessions := *resp.JSON200
|
|
||||||
|
|
||||||
sort.Slice(sessions, func(i, j int) bool {
|
sort.Slice(sessions, func(i, j int) bool {
|
||||||
return sessions[i].Time.Created-sessions[j].Time.Created > 0
|
return sessions[i].Time.Created-sessions[j].Time.Created > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
return sessions, nil
|
return sessions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
|
func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
|
||||||
resp, err := a.Client.PostSessionDeleteWithResponse(ctx, client.PostSessionDeleteJSONRequestBody{
|
_, err := a.Client.Session.Delete(ctx, sessionID)
|
||||||
SessionID: sessionID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("Failed to delete session", "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resp.StatusCode() != 200 {
|
|
||||||
return fmt.Errorf("failed to delete session: %d", resp.StatusCode())
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) {
|
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]opencode.Message, error) {
|
||||||
resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId})
|
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 messages: %d", resp.StatusCode())
|
return []opencode.Message{}, nil
|
||||||
}
|
}
|
||||||
if resp.JSON200 == nil {
|
messages := *response
|
||||||
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
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +20,8 @@ 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
|
Focused() bool
|
||||||
@@ -33,19 +31,13 @@ type EditorComponent interface {
|
|||||||
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)
|
|
||||||
Next() (tea.Model, tea.Cmd)
|
|
||||||
SetInterruptKeyInDebounce(inDebounce bool)
|
SetInterruptKeyInDebounce(inDebounce bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
history []string
|
|
||||||
historyIndex int
|
|
||||||
currentMessage string
|
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
interruptKeyInDebounce bool
|
interruptKeyInDebounce bool
|
||||||
}
|
}
|
||||||
@@ -105,7 +97,7 @@ 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.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||||
@@ -114,6 +106,7 @@ func (m *editorComponent) Content() string {
|
|||||||
Bold(true)
|
Bold(true)
|
||||||
prompt := promptStyle.Render(">")
|
prompt := promptStyle.Render(">")
|
||||||
|
|
||||||
|
m.textarea.SetWidth(width - 6)
|
||||||
textarea := lipgloss.JoinHorizontal(
|
textarea := lipgloss.JoinHorizontal(
|
||||||
lipgloss.Top,
|
lipgloss.Top,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -121,7 +114,7 @@ func (m *editorComponent) Content() string {
|
|||||||
)
|
)
|
||||||
textarea = styles.NewStyle().
|
textarea = styles.NewStyle().
|
||||||
Background(t.BackgroundElement()).
|
Background(t.BackgroundElement()).
|
||||||
Width(m.width).
|
Width(width).
|
||||||
PaddingTop(1).
|
PaddingTop(1).
|
||||||
PaddingBottom(1).
|
PaddingBottom(1).
|
||||||
BorderStyle(lipgloss.ThickBorder()).
|
BorderStyle(lipgloss.ThickBorder()).
|
||||||
@@ -146,7 +139,7 @@ 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 := styles.NewStyle().Background(t.Background()).Width(space).Render("")
|
spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
|
||||||
|
|
||||||
info := hint + spacer + model
|
info := hint + spacer + model
|
||||||
@@ -156,11 +149,18 @@ func (m *editorComponent) Content() string {
|
|||||||
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) Focused() bool {
|
func (m *editorComponent) Focused() bool {
|
||||||
@@ -175,16 +175,6 @@ func (m *editorComponent) Blur() {
|
|||||||
m.textarea.Blur()
|
m.textarea.Blur()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) GetSize() (width, height int) {
|
|
||||||
return m.width, m.height
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
|
|
||||||
m.width = width
|
|
||||||
m.height = height
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *editorComponent) Lines() int {
|
func (m *editorComponent) Lines() int {
|
||||||
return m.textarea.LineCount()
|
return m.textarea.LineCount()
|
||||||
}
|
}
|
||||||
@@ -210,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}))
|
||||||
@@ -252,48 +232,6 @@ func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
|
|
||||||
currentLine := m.textarea.Line()
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
currentLine := m.textarea.Line()
|
|
||||||
value := m.textarea.Value()
|
|
||||||
lines := strings.Split(value, "\n")
|
|
||||||
totalLines := len(lines)
|
|
||||||
|
|
||||||
// Only navigate history if we're at the last line
|
|
||||||
if currentLine == totalLines-1 {
|
|
||||||
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 (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
|
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
|
||||||
m.interruptKeyInDebounce = inDebounce
|
m.interruptKeyInDebounce = inDebounce
|
||||||
}
|
}
|
||||||
@@ -327,7 +265,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
|||||||
ta.Prompt = " "
|
ta.Prompt = " "
|
||||||
ta.ShowLineNumbers = false
|
ta.ShowLineNumbers = false
|
||||||
ta.CharLimit = -1
|
ta.CharLimit = -1
|
||||||
ta.SetWidth(layout.Current.Container.Width - 6)
|
|
||||||
|
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
ta.SetValue(existing.Value())
|
ta.SetValue(existing.Value())
|
||||||
@@ -335,7 +272,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
|||||||
ta.SetHeight(existing.Height())
|
ta.SetHeight(existing.Height())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ta.Focus()
|
|
||||||
return ta
|
return ta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,9 +296,6 @@ func NewEditorComponent(app *app.App) EditorComponent {
|
|||||||
return &editorComponent{
|
return &editorComponent{
|
||||||
app: app,
|
app: app,
|
||||||
textarea: ta,
|
textarea: ta,
|
||||||
history: []string{},
|
|
||||||
historyIndex: 0,
|
|
||||||
currentMessage: "",
|
|
||||||
spinner: s,
|
spinner: s,
|
||||||
interruptKeyInDebounce: false,
|
interruptKeyInDebounce: false,
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,92 +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
|
return m, nil
|
||||||
case app.OptimisticMessageAddedMsg:
|
case app.OptimisticMessageAddedMsg:
|
||||||
m.renderView()
|
m.renderView(m.width)
|
||||||
if m.tail {
|
if m.tail {
|
||||||
m.viewport.GotoBottom()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,177 +117,201 @@ 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.MessageMetadata_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,
|
|
||||||
styles.WhitespaceStyle(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.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
muted := styles.NewStyle().Foreground(t.TextMuted()).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"))
|
||||||
}
|
}
|
||||||
@@ -286,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(),
|
|
||||||
styles.WhitespaceStyle(t.Background()),
|
|
||||||
),
|
|
||||||
m.viewport.View(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *messagesComponent) home() string {
|
return styles.NewStyle().
|
||||||
t := theme.CurrentTheme()
|
|
||||||
baseStyle := styles.NewStyle().Background(t.Background())
|
|
||||||
base := baseStyle.Render
|
|
||||||
muted := styles.NewStyle().Foreground(t.TextMuted()).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 := styles.NewStyle().
|
|
||||||
Foreground(t.TextMuted()).
|
|
||||||
Background(t.Background()).
|
Background(t.Background()).
|
||||||
Width(lipgloss.Width(logo)).
|
Render(header + "\n" + m.viewport.View())
|
||||||
Align(lipgloss.Right)
|
|
||||||
version := versionStyle.Render(m.app.Version)
|
|
||||||
|
|
||||||
logoAndVersion := strings.Join([]string{logo, version}, "\n")
|
|
||||||
logoAndVersion = lipgloss.PlaceHorizontal(
|
|
||||||
m.width,
|
|
||||||
lipgloss.Center,
|
|
||||||
logoAndVersion,
|
|
||||||
styles.WhitespaceStyle(t.Background()),
|
|
||||||
)
|
|
||||||
m.commands.SetBackgroundColor(t.Background())
|
|
||||||
commands := lipgloss.PlaceHorizontal(
|
|
||||||
m.width,
|
|
||||||
lipgloss.Center,
|
|
||||||
m.commands.View(),
|
|
||||||
styles.WhitespaceStyle(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")),
|
|
||||||
styles.WhitespaceStyle(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
|
||||||
@@ -385,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{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,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 {
|
||||||
@@ -444,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,27 +34,10 @@ 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()
|
||||||
|
|
||||||
@@ -128,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
|
|||||||
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
|
||||||
}
|
}
|
||||||
@@ -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...)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,10 +20,7 @@ type helpDialog struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *helpDialog) Init() tea.Cmd {
|
func (h *helpDialog) Init() tea.Cmd {
|
||||||
return tea.Batch(
|
return h.viewport.Init()
|
||||||
h.commandsComponent.Init(),
|
|
||||||
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) {
|
||||||
@@ -38,10 +35,6 @@ func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
h.commandsComponent.SetSize(msg.Width-4, msg.Height-6)
|
h.commandsComponent.SetSize(msg.Width-4, msg.Height-6)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update commands component first to get the latest content
|
|
||||||
_, cmdCmd := h.commandsComponent.Update(msg)
|
|
||||||
cmds = append(cmds, cmdCmd)
|
|
||||||
|
|
||||||
// Update viewport content
|
// Update viewport content
|
||||||
h.viewport.SetContent(h.commandsComponent.View())
|
h.viewport.SetContent(h.commandsComponent.View())
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ 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/list"
|
||||||
"github.com/sst/opencode/internal/components/modal"
|
"github.com/sst/opencode/internal/components/modal"
|
||||||
@@ -17,12 +16,12 @@ import (
|
|||||||
"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
|
||||||
@@ -31,33 +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
|
||||||
width int
|
height int
|
||||||
height int
|
modal *modal.Modal
|
||||||
hScrollOffset int
|
modelList list.List[ModelItem]
|
||||||
hScrollPossible bool
|
dialogWidth int
|
||||||
modal *modal.Modal
|
}
|
||||||
modelList list.List[list.StringItem]
|
|
||||||
|
type ModelWithProvider struct {
|
||||||
|
Model opencode.Model
|
||||||
|
Provider opencode.Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
Left key.Binding
|
|
||||||
Right key.Binding
|
|
||||||
Enter key.Binding
|
Enter key.Binding
|
||||||
Escape key.Binding
|
Escape key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var modelKeys = modelKeyMap{
|
var modelKeys = modelKeyMap{
|
||||||
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"),
|
||||||
@@ -69,7 +96,7 @@ var modelKeys = modelKeyMap{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelDialog) Init() tea.Cmd {
|
func (m *modelDialog) Init() tea.Cmd {
|
||||||
m.setupModelsForProvider(m.provider.Id)
|
m.setupAllModels()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,34 +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.Left):
|
|
||||||
if m.hScrollPossible {
|
|
||||||
m.switchProvider(-1)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case key.Matches(msg, modelKeys.Right):
|
|
||||||
if m.hScrollPossible {
|
|
||||||
m.switchProvider(1)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case key.Matches(msg, modelKeys.Enter):
|
case key.Matches(msg, modelKeys.Enter):
|
||||||
selectedItem, _ := m.modelList.GetSelectedItem()
|
_, selectedIndex := m.modelList.GetSelectedItem()
|
||||||
models := m.models()
|
if selectedIndex >= 0 && selectedIndex < len(m.allModels) {
|
||||||
var selectedModel client.ModelInfo
|
selectedModel := m.allModels[selectedIndex]
|
||||||
for _, model := range models {
|
return m, tea.Sequence(
|
||||||
if model.Name == string(selectedItem) {
|
util.CmdHandler(modal.CloseModalMsg{}),
|
||||||
selectedModel = model
|
util.CmdHandler(
|
||||||
break
|
app.ModelSelectedMsg{
|
||||||
}
|
Provider: selectedModel.Provider,
|
||||||
|
Model: selectedModel.Model,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return m, tea.Sequence(
|
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||||
util.CmdHandler(modal.CloseModalMsg{}),
|
|
||||||
util.CmdHandler(
|
|
||||||
app.ModelSelectedMsg{
|
|
||||||
Provider: m.provider,
|
|
||||||
Model: selectedModel,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
case key.Matches(msg, modelKeys.Escape):
|
case key.Matches(msg, modelKeys.Escape):
|
||||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||||
}
|
}
|
||||||
@@ -115,74 +128,124 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
// Update the list component
|
// Update the list component
|
||||||
updatedList, cmd := m.modelList.Update(msg)
|
updatedList, cmd := m.modelList.Update(msg)
|
||||||
m.modelList = updatedList.(list.List[list.StringItem])
|
m.modelList = updatedList.(list.List[ModelItem])
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelDialog) models() []client.ModelInfo {
|
|
||||||
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ModelInfo) int {
|
|
||||||
return strings.Compare(a.Name, b.Name)
|
|
||||||
})
|
|
||||||
return models
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *modelDialog) switchProvider(offset int) {
|
|
||||||
newOffset := m.hScrollOffset + offset
|
|
||||||
|
|
||||||
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 {
|
||||||
listView := m.modelList.View()
|
return m.modelList.View()
|
||||||
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
|
|
||||||
return strings.Join([]string{listView, scrollIndicator}, "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelDialog) getScrollIndicators(maxWidth int) string {
|
func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
|
||||||
var indicator string
|
maxWidth := minDialogWidth
|
||||||
if m.hScrollPossible {
|
|
||||||
indicator = "← → (switch provider) "
|
|
||||||
}
|
|
||||||
if indicator == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
for _, item := range modelItems {
|
||||||
return styles.NewStyle().
|
// Calculate the width needed for this item: "ModelName (ProviderName)"
|
||||||
Foreground(t.TextMuted()).
|
// Add 4 for the parentheses, space, and some padding
|
||||||
Width(maxWidth).
|
itemWidth := len(item.ModelName) + len(item.ProviderName) + 4
|
||||||
Align(lipgloss.Right).
|
if itemWidth > maxWidth {
|
||||||
Render(indicator)
|
maxWidth = itemWidth
|
||||||
}
|
|
||||||
|
|
||||||
func (m *modelDialog) setupModelsForProvider(providerId string) {
|
|
||||||
models := m.models()
|
|
||||||
modelNames := make([]string, len(models))
|
|
||||||
for i, model := range models {
|
|
||||||
modelNames[i] = model.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true)
|
|
||||||
m.modelList.SetMaxWidth(maxDialogWidth)
|
|
||||||
|
|
||||||
if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.Id == providerId {
|
|
||||||
for i, model := range models {
|
|
||||||
if model.Id == m.app.Model.Id {
|
|
||||||
m.modelList.SetSelectedIndex(i)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if maxWidth > maxDialogWidth {
|
||||||
|
maxWidth = maxDialogWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modelDialog) setupAllModels() {
|
||||||
|
providers, _ := m.app.ListProviders(context.Background())
|
||||||
|
|
||||||
|
m.allModels = make([]ModelWithProvider, 0)
|
||||||
|
for _, provider := range providers {
|
||||||
|
for _, model := range provider.Models {
|
||||||
|
m.allModels = append(m.allModels, ModelWithProvider{
|
||||||
|
Model: model,
|
||||||
|
Provider: provider,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.sortModels()
|
||||||
|
|
||||||
|
modelItems := make([]ModelItem, len(m.allModels))
|
||||||
|
for i, modelWithProvider := range m.allModels {
|
||||||
|
modelItems[i] = ModelItem{
|
||||||
|
ModelName: modelWithProvider.Model.Name,
|
||||||
|
ProviderName: modelWithProvider.Provider.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.dialogWidth = m.calculateOptimalWidth(modelItems)
|
||||||
|
|
||||||
|
m.modelList = list.NewListComponent(modelItems, numVisibleModels, "No models available", true)
|
||||||
|
m.modelList.SetMaxWidth(m.dialogWidth)
|
||||||
|
|
||||||
|
if len(m.allModels) > 0 {
|
||||||
|
m.modelList.SetSelectedIndex(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -194,32 +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())
|
|
||||||
|
|
||||||
currentProvider := availableProviders[0]
|
|
||||||
hScrollOffset := 0
|
|
||||||
if app.Provider != nil {
|
|
||||||
for i, provider := range availableProviders {
|
|
||||||
if provider.Id == app.Provider.Id {
|
|
||||||
currentProvider = provider
|
|
||||||
hScrollOffset = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog := &modelDialog{
|
dialog := &modelDialog{
|
||||||
app: app,
|
app: app,
|
||||||
availableProviders: availableProviders,
|
|
||||||
hScrollOffset: hScrollOffset,
|
|
||||||
hScrollPossible: len(availableProviders) > 1,
|
|
||||||
provider: currentProvider,
|
|
||||||
modal: modal.New(
|
|
||||||
modal.WithTitle(fmt.Sprintf("Select %s Model", currentProvider.Name)),
|
|
||||||
modal.WithMaxWidth(maxDialogWidth+4),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.setupModelsForProvider(currentProvider.Id)
|
dialog.setupAllModels()
|
||||||
|
|
||||||
|
dialog.modal = modal.New(
|
||||||
|
modal.WithTitle("Select Model"),
|
||||||
|
modal.WithMaxWidth(dialog.dialogWidth+4),
|
||||||
|
)
|
||||||
|
|
||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/muesli/reflow/truncate"
|
"github.com/muesli/reflow/truncate"
|
||||||
|
"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/list"
|
||||||
"github.com/sst/opencode/internal/components/modal"
|
"github.com/sst/opencode/internal/components/modal"
|
||||||
@@ -16,7 +17,6 @@ import (
|
|||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionDialog interface for the session switching dialog
|
// SessionDialog interface for the session switching dialog
|
||||||
@@ -79,7 +79,7 @@ type sessionDialog struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
modal *modal.Modal
|
modal *modal.Modal
|
||||||
sessions []client.SessionInfo
|
sessions []opencode.Session
|
||||||
list list.List[sessionItem]
|
list list.List[sessionItem]
|
||||||
app *app.App
|
app *app.App
|
||||||
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
|
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
|
||||||
@@ -122,7 +122,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
s.updateListItems()
|
s.updateListItems()
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
s.deleteSession(sessionToDelete.Id),
|
s.deleteSession(sessionToDelete.ID),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// First press - enter delete confirmation mode
|
// First press - enter delete confirmation mode
|
||||||
@@ -193,10 +193,10 @@ func (s *sessionDialog) Close() tea.Cmd {
|
|||||||
func NewSessionDialog(app *app.App) SessionDialog {
|
func NewSessionDialog(app *app.App) SessionDialog {
|
||||||
sessions, _ := app.ListSessions(context.Background())
|
sessions, _ := app.ListSessions(context.Background())
|
||||||
|
|
||||||
var filteredSessions []client.SessionInfo
|
var filteredSessions []opencode.Session
|
||||||
var items []sessionItem
|
var items []sessionItem
|
||||||
for _, sess := range sessions {
|
for _, sess := range sessions {
|
||||||
if sess.ParentID != nil {
|
if sess.ParentID != "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
filteredSessions = append(filteredSessions, sess)
|
filteredSessions = append(filteredSessions, sess)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package diff
|
package diff
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/alecthomas/chroma/v2"
|
"github.com/alecthomas/chroma/v2"
|
||||||
"github.com/alecthomas/chroma/v2/formatters"
|
"github.com/alecthomas/chroma/v2/formatters"
|
||||||
@@ -19,6 +21,7 @@ import (
|
|||||||
"github.com/sergi/go-diff/diffmatchpatch"
|
"github.com/sergi/go-diff/diffmatchpatch"
|
||||||
stylesi "github.com/sst/opencode/internal/styles"
|
stylesi "github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
|
"github.com/sst/opencode/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -70,44 +73,6 @@ type linePair struct {
|
|||||||
right *DiffLine
|
right *DiffLine
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Side-by-Side Configuration
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// SideBySideConfig configures the rendering of side-by-side diffs
|
|
||||||
type SideBySideConfig struct {
|
|
||||||
TotalWidth int
|
|
||||||
}
|
|
||||||
|
|
||||||
// SideBySideOption modifies a SideBySideConfig
|
|
||||||
type SideBySideOption func(*SideBySideConfig)
|
|
||||||
|
|
||||||
// NewSideBySideConfig creates a SideBySideConfig with default values
|
|
||||||
func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
|
|
||||||
config := SideBySideConfig{
|
|
||||||
TotalWidth: 160, // Default width for side-by-side view
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&config)
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithTotalWidth sets the total width for side-by-side view
|
|
||||||
func WithTotalWidth(width int) SideBySideOption {
|
|
||||||
return func(s *SideBySideConfig) {
|
|
||||||
if width > 0 {
|
|
||||||
s.TotalWidth = width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Unified Configuration
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// UnifiedConfig configures the rendering of unified diffs
|
// UnifiedConfig configures the rendering of unified diffs
|
||||||
type UnifiedConfig struct {
|
type UnifiedConfig struct {
|
||||||
Width int
|
Width int
|
||||||
@@ -119,13 +84,22 @@ type UnifiedOption func(*UnifiedConfig)
|
|||||||
// NewUnifiedConfig creates a UnifiedConfig with default values
|
// NewUnifiedConfig creates a UnifiedConfig with default values
|
||||||
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
|
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
|
||||||
config := UnifiedConfig{
|
config := UnifiedConfig{
|
||||||
Width: 80, // Default width for unified view
|
Width: 80,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(&config)
|
opt(&config)
|
||||||
}
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSideBySideConfig creates a SideBySideConfig with default values
|
||||||
|
func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig {
|
||||||
|
config := UnifiedConfig{
|
||||||
|
Width: 160,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&config)
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,101 +120,87 @@ func WithWidth(width int) UnifiedOption {
|
|||||||
func ParseUnifiedDiff(diff string) (DiffResult, error) {
|
func ParseUnifiedDiff(diff string) (DiffResult, error) {
|
||||||
var result DiffResult
|
var result DiffResult
|
||||||
var currentHunk *Hunk
|
var currentHunk *Hunk
|
||||||
|
result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
|
||||||
|
|
||||||
hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
|
scanner := bufio.NewScanner(strings.NewReader(diff))
|
||||||
lines := strings.Split(diff, "\n")
|
|
||||||
|
|
||||||
var oldLine, newLine int
|
var oldLine, newLine int
|
||||||
inFileHeader := true
|
inFileHeader := true
|
||||||
|
|
||||||
for _, line := range lines {
|
for scanner.Scan() {
|
||||||
// Parse file headers
|
line := scanner.Text()
|
||||||
|
|
||||||
if inFileHeader {
|
if inFileHeader {
|
||||||
if strings.HasPrefix(line, "--- a/") {
|
if strings.HasPrefix(line, "--- a/") {
|
||||||
result.OldFile = strings.TrimPrefix(line, "--- a/")
|
result.OldFile = line[6:]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "+++ b/") {
|
if strings.HasPrefix(line, "+++ b/") {
|
||||||
result.NewFile = strings.TrimPrefix(line, "+++ b/")
|
result.NewFile = line[6:]
|
||||||
inFileHeader = false
|
inFileHeader = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse hunk headers
|
if strings.HasPrefix(line, "@@") {
|
||||||
if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
|
|
||||||
if currentHunk != nil {
|
if currentHunk != nil {
|
||||||
result.Hunks = append(result.Hunks, *currentHunk)
|
result.Hunks = append(result.Hunks, *currentHunk)
|
||||||
}
|
}
|
||||||
currentHunk = &Hunk{
|
currentHunk = &Hunk{
|
||||||
Header: line,
|
Header: line,
|
||||||
Lines: []DiffLine{},
|
Lines: make([]DiffLine, 0, 10), // Pre-allocate
|
||||||
}
|
}
|
||||||
|
|
||||||
oldStart, _ := strconv.Atoi(matches[1])
|
// Manual parsing of hunk header is faster than regex
|
||||||
newStart, _ := strconv.Atoi(matches[3])
|
parts := strings.Split(line, " ")
|
||||||
oldLine = oldStart
|
if len(parts) > 2 {
|
||||||
newLine = newStart
|
oldRange := strings.Split(parts[1][1:], ",")
|
||||||
|
newRange := strings.Split(parts[2][1:], ",")
|
||||||
|
oldLine, _ = strconv.Atoi(oldRange[0])
|
||||||
|
newLine, _ = strconv.Atoi(newRange[0])
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore "No newline at end of file" markers
|
if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
|
||||||
if strings.HasPrefix(line, "\\ No newline at end of file") {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentHunk == nil {
|
var dl DiffLine
|
||||||
continue
|
dl.Content = line
|
||||||
}
|
|
||||||
|
|
||||||
// Process the line based on its prefix
|
|
||||||
if len(line) > 0 {
|
if len(line) > 0 {
|
||||||
switch line[0] {
|
switch line[0] {
|
||||||
case '+':
|
case '+':
|
||||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
dl.Kind = LineAdded
|
||||||
OldLineNo: 0,
|
dl.NewLineNo = newLine
|
||||||
NewLineNo: newLine,
|
dl.Content = line[1:]
|
||||||
Kind: LineAdded,
|
|
||||||
Content: line[1:],
|
|
||||||
})
|
|
||||||
newLine++
|
newLine++
|
||||||
case '-':
|
case '-':
|
||||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
dl.Kind = LineRemoved
|
||||||
OldLineNo: oldLine,
|
dl.OldLineNo = oldLine
|
||||||
NewLineNo: 0,
|
dl.Content = line[1:]
|
||||||
Kind: LineRemoved,
|
|
||||||
Content: line[1:],
|
|
||||||
})
|
|
||||||
oldLine++
|
oldLine++
|
||||||
default:
|
default: // context line
|
||||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
dl.Kind = LineContext
|
||||||
OldLineNo: oldLine,
|
dl.OldLineNo = oldLine
|
||||||
NewLineNo: newLine,
|
dl.NewLineNo = newLine
|
||||||
Kind: LineContext,
|
|
||||||
Content: line,
|
|
||||||
})
|
|
||||||
oldLine++
|
oldLine++
|
||||||
newLine++
|
newLine++
|
||||||
}
|
}
|
||||||
} else {
|
} else { // empty context line
|
||||||
// Handle empty lines
|
dl.Kind = LineContext
|
||||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
dl.OldLineNo = oldLine
|
||||||
OldLineNo: oldLine,
|
dl.NewLineNo = newLine
|
||||||
NewLineNo: newLine,
|
|
||||||
Kind: LineContext,
|
|
||||||
Content: "",
|
|
||||||
})
|
|
||||||
oldLine++
|
oldLine++
|
||||||
newLine++
|
newLine++
|
||||||
}
|
}
|
||||||
|
currentHunk.Lines = append(currentHunk.Lines, dl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the last hunk if there is one
|
|
||||||
if currentHunk != nil {
|
if currentHunk != nil {
|
||||||
result.Hunks = append(result.Hunks, *currentHunk)
|
result.Hunks = append(result.Hunks, *currentHunk)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
|
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
|
||||||
@@ -742,8 +702,6 @@ func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, high
|
|||||||
content,
|
content,
|
||||||
width,
|
width,
|
||||||
"...",
|
"...",
|
||||||
// stylesi.NewStyleWithColors(t.TextMuted(), bgStyle.GetBackground()).Render("..."),
|
|
||||||
// stylesi.WithForeground(stylesi.NewStyle().Background(bgStyle.GetBackground()), t.TextMuted()).Render("..."),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -910,16 +868,17 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
|
|||||||
HighlightIntralineChanges(&hunkCopy)
|
HighlightIntralineChanges(&hunkCopy)
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, line := range hunkCopy.Lines {
|
sb.Grow(len(hunkCopy.Lines) * config.Width)
|
||||||
sb.WriteString(renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()))
|
|
||||||
sb.WriteString("\n")
|
util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
|
||||||
}
|
return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
|
||||||
|
})
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderSideBySideHunk formats a hunk for side-by-side display
|
// RenderSideBySideHunk formats a hunk for side-by-side display
|
||||||
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
|
func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
|
||||||
// Apply options to create the configuration
|
// Apply options to create the configuration
|
||||||
config := NewSideBySideConfig(opts...)
|
config := NewSideBySideConfig(opts...)
|
||||||
|
|
||||||
@@ -934,16 +893,27 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
|
|||||||
pairs := pairLines(hunkCopy.Lines)
|
pairs := pairLines(hunkCopy.Lines)
|
||||||
|
|
||||||
// Calculate column width
|
// Calculate column width
|
||||||
colWidth := config.TotalWidth / 2
|
colWidth := config.Width / 2
|
||||||
|
|
||||||
leftWidth := colWidth
|
leftWidth := colWidth
|
||||||
rightWidth := config.TotalWidth - colWidth
|
rightWidth := config.Width - colWidth
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, p := range pairs {
|
|
||||||
leftStr := renderLeftColumn(fileName, p.left, leftWidth)
|
util.WriteStringsPar(&sb, pairs, func(p linePair) string {
|
||||||
rightStr := renderRightColumn(fileName, p.right, rightWidth)
|
wg := &sync.WaitGroup{}
|
||||||
sb.WriteString(leftStr + rightStr + "\n")
|
var leftStr, rightStr string
|
||||||
}
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
leftStr = renderLeftColumn(fileName, p.left, leftWidth)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
rightStr = renderRightColumn(fileName, p.right, rightWidth)
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
return leftStr + rightStr + "\n"
|
||||||
|
})
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
@@ -956,33 +926,24 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, h := range diffResult.Hunks {
|
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
|
||||||
sb.WriteString(RenderUnifiedHunk(filename, h, opts...))
|
return RenderUnifiedHunk(filename, h, opts...)
|
||||||
}
|
})
|
||||||
|
|
||||||
return sb.String(), nil
|
return sb.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatDiff creates a side-by-side formatted view of a diff
|
// FormatDiff creates a side-by-side formatted view of a diff
|
||||||
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
|
func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
|
||||||
// t := theme.CurrentTheme()
|
|
||||||
diffResult, err := ParseUnifiedDiff(diffText)
|
diffResult, err := ParseUnifiedDiff(diffText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
// config := NewSideBySideConfig(opts...)
|
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
|
||||||
for _, h := range diffResult.Hunks {
|
return RenderSideBySideHunk(filename, h, opts...)
|
||||||
// sb.WriteString(
|
})
|
||||||
// lipgloss.NewStyle().
|
|
||||||
// Background(t.DiffHunkHeader()).
|
|
||||||
// Foreground(t.Background()).
|
|
||||||
// Width(config.TotalWidth).
|
|
||||||
// Render(h.Header) + "\n",
|
|
||||||
// )
|
|
||||||
sb.WriteString(RenderSideBySideHunk(filename, h, opts...))
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String(), nil
|
return sb.String(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package fileviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/sst/opencode/internal/app"
|
||||||
|
"github.com/sst/opencode/internal/commands"
|
||||||
|
"github.com/sst/opencode/internal/components/dialog"
|
||||||
|
"github.com/sst/opencode/internal/components/diff"
|
||||||
|
"github.com/sst/opencode/internal/layout"
|
||||||
|
"github.com/sst/opencode/internal/styles"
|
||||||
|
"github.com/sst/opencode/internal/theme"
|
||||||
|
"github.com/sst/opencode/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiffStyle int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DiffStyleSplit DiffStyle = iota
|
||||||
|
DiffStyleUnified
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
app *app.App
|
||||||
|
width, height int
|
||||||
|
viewport viewport.Model
|
||||||
|
filename *string
|
||||||
|
content *string
|
||||||
|
isDiff *bool
|
||||||
|
diffStyle DiffStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileRenderedMsg struct {
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(app *app.App) Model {
|
||||||
|
vp := viewport.New()
|
||||||
|
m := Model{
|
||||||
|
app: app,
|
||||||
|
viewport: vp,
|
||||||
|
diffStyle: DiffStyleUnified,
|
||||||
|
}
|
||||||
|
if app.State.SplitDiff {
|
||||||
|
m.diffStyle = DiffStyleSplit
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return m.viewport.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case fileRenderedMsg:
|
||||||
|
m.viewport.SetContent(msg.content)
|
||||||
|
return m, util.CmdHandler(app.FileRenderedMsg{
|
||||||
|
FilePath: *m.filename,
|
||||||
|
})
|
||||||
|
case dialog.ThemeSelectedMsg:
|
||||||
|
return m, m.render()
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vp, cmd := m.viewport.Update(msg)
|
||||||
|
m.viewport = vp
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) View() string {
|
||||||
|
if !m.HasFile() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
header := *m.filename
|
||||||
|
header = styles.NewStyle().
|
||||||
|
Padding(1, 2).
|
||||||
|
Width(m.width).
|
||||||
|
Background(theme.CurrentTheme().BackgroundElement()).
|
||||||
|
Foreground(theme.CurrentTheme().Text()).
|
||||||
|
Render(header)
|
||||||
|
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
|
||||||
|
close := m.app.Key(commands.FileCloseCommand)
|
||||||
|
diffToggle := m.app.Key(commands.FileDiffToggleCommand)
|
||||||
|
if m.isDiff == nil || *m.isDiff == false {
|
||||||
|
diffToggle = ""
|
||||||
|
}
|
||||||
|
layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand)
|
||||||
|
|
||||||
|
background := t.Background()
|
||||||
|
footer := layout.Render(
|
||||||
|
layout.FlexOptions{
|
||||||
|
Background: &background,
|
||||||
|
Direction: layout.Row,
|
||||||
|
Justify: layout.JustifyCenter,
|
||||||
|
Align: layout.AlignStretch,
|
||||||
|
Width: m.width - 2,
|
||||||
|
Gap: 5,
|
||||||
|
},
|
||||||
|
layout.FlexItem{
|
||||||
|
View: close,
|
||||||
|
},
|
||||||
|
layout.FlexItem{
|
||||||
|
View: layoutToggle,
|
||||||
|
},
|
||||||
|
layout.FlexItem{
|
||||||
|
View: diffToggle,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer)
|
||||||
|
|
||||||
|
return header + "\n" + m.viewport.View() + "\n" + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Clear() (Model, tea.Cmd) {
|
||||||
|
m.filename = nil
|
||||||
|
m.content = nil
|
||||||
|
m.isDiff = nil
|
||||||
|
return *m, m.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) ToggleDiff() (Model, tea.Cmd) {
|
||||||
|
switch m.diffStyle {
|
||||||
|
case DiffStyleSplit:
|
||||||
|
m.diffStyle = DiffStyleUnified
|
||||||
|
default:
|
||||||
|
m.diffStyle = DiffStyleSplit
|
||||||
|
}
|
||||||
|
return *m, m.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) DiffStyle() DiffStyle {
|
||||||
|
return m.diffStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) HasFile() bool {
|
||||||
|
return m.filename != nil && m.content != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Filename() string {
|
||||||
|
if m.filename == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *m.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) SetSize(width, height int) (Model, tea.Cmd) {
|
||||||
|
if m.width != width || m.height != height {
|
||||||
|
m.width = width
|
||||||
|
m.height = height
|
||||||
|
m.viewport.SetWidth(width)
|
||||||
|
m.viewport.SetHeight(height - 4)
|
||||||
|
return *m, m.render()
|
||||||
|
}
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) {
|
||||||
|
m.filename = &filename
|
||||||
|
m.content = &content
|
||||||
|
m.isDiff = &isDiff
|
||||||
|
return *m, m.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) render() tea.Cmd {
|
||||||
|
if m.filename == nil || m.content == nil {
|
||||||
|
m.viewport.SetContent("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() tea.Msg {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
var rendered string
|
||||||
|
|
||||||
|
if m.isDiff != nil && *m.isDiff {
|
||||||
|
diffResult := ""
|
||||||
|
var err error
|
||||||
|
if m.diffStyle == DiffStyleSplit {
|
||||||
|
diffResult, err = diff.FormatDiff(
|
||||||
|
*m.filename,
|
||||||
|
*m.content,
|
||||||
|
diff.WithWidth(m.width),
|
||||||
|
)
|
||||||
|
} else if m.diffStyle == DiffStyleUnified {
|
||||||
|
diffResult, err = diff.FormatUnifiedDiff(
|
||||||
|
*m.filename,
|
||||||
|
*m.content,
|
||||||
|
diff.WithWidth(m.width),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
rendered = styles.NewStyle().
|
||||||
|
Foreground(t.Error()).
|
||||||
|
Render(fmt.Sprintf("Error rendering diff: %v", err))
|
||||||
|
} else {
|
||||||
|
rendered = strings.TrimRight(diffResult, "\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rendered = util.RenderFile(
|
||||||
|
*m.filename,
|
||||||
|
*m.content,
|
||||||
|
m.width,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered = styles.NewStyle().
|
||||||
|
Width(m.width).
|
||||||
|
Background(t.BackgroundPanel()).
|
||||||
|
Render(rendered)
|
||||||
|
|
||||||
|
return fileRenderedMsg{
|
||||||
|
content: rendered,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) ScrollTo(line int) {
|
||||||
|
m.viewport.SetYOffset(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) ScrollToBottom() {
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) ScrollToTop() {
|
||||||
|
m.viewport.GotoTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) PageUp() (Model, tea.Cmd) {
|
||||||
|
m.viewport.ViewUp()
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) PageDown() (Model, tea.Cmd) {
|
||||||
|
m.viewport.ViewDown()
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) HalfPageUp() (Model, tea.Cmd) {
|
||||||
|
m.viewport.HalfViewUp()
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) HalfPageDown() (Model, tea.Cmd) {
|
||||||
|
m.viewport.HalfViewDown()
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) AtTop() bool {
|
||||||
|
return m.viewport.AtTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) AtBottom() bool {
|
||||||
|
return m.viewport.AtBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) ScrollPercent() float64 {
|
||||||
|
return m.viewport.ScrollPercent()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) TotalLineCount() int {
|
||||||
|
return m.viewport.TotalLineCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) VisibleLineCount() int {
|
||||||
|
return m.viewport.VisibleLineCount()
|
||||||
|
}
|
||||||
@@ -135,11 +135,11 @@ func (m *Modal) Render(contentView string, background string) string {
|
|||||||
col := (bgWidth - modalWidth) / 2
|
col := (bgWidth - modalWidth) / 2
|
||||||
|
|
||||||
return layout.PlaceOverlay(
|
return layout.PlaceOverlay(
|
||||||
col,
|
col-1, // TODO: whyyyyy
|
||||||
row,
|
row,
|
||||||
modalView,
|
modalView,
|
||||||
background,
|
background,
|
||||||
layout.WithOverlayBorder(),
|
layout.WithOverlayBorder(),
|
||||||
layout.WithOverlayBorderColor(t.Primary()),
|
layout.WithOverlayBorderColor(t.BorderActive()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func (m statusComponent) logo() string {
|
|||||||
Render(open + code + version)
|
Render(open + code + version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
|
func formatTokensAndCost(tokens float64, contextWindow float64, cost float64) string {
|
||||||
// Format tokens in human-readable format (e.g., 110K, 1.2M)
|
// Format tokens in human-readable format (e.g., 110K, 1.2M)
|
||||||
var formattedTokens string
|
var formattedTokens string
|
||||||
switch {
|
switch {
|
||||||
@@ -77,7 +77,7 @@ func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) st
|
|||||||
|
|
||||||
func (m statusComponent) View() string {
|
func (m statusComponent) View() string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
if m.app.Session.Id == "" {
|
if m.app.Session.ID == "" {
|
||||||
return styles.NewStyle().
|
return styles.NewStyle().
|
||||||
Background(t.Background()).
|
Background(t.Background()).
|
||||||
Width(m.width).
|
Width(m.width).
|
||||||
@@ -94,22 +94,24 @@ func (m statusComponent) View() string {
|
|||||||
Render(m.app.Info.Path.Cwd)
|
Render(m.app.Info.Path.Cwd)
|
||||||
|
|
||||||
sessionInfo := ""
|
sessionInfo := ""
|
||||||
if m.app.Session.Id != "" {
|
if m.app.Session.ID != "" {
|
||||||
tokens := float32(0)
|
tokens := float64(0)
|
||||||
cost := float32(0)
|
cost := float64(0)
|
||||||
contextWindow := m.app.Model.Limit.Context
|
contextWindow := m.app.Model.Limit.Context
|
||||||
|
|
||||||
for _, message := range m.app.Messages {
|
for _, message := range m.app.Messages {
|
||||||
if message.Metadata.Assistant != nil {
|
cost += message.Metadata.Assistant.Cost
|
||||||
cost += message.Metadata.Assistant.Cost
|
usage := message.Metadata.Assistant.Tokens
|
||||||
usage := message.Metadata.Assistant.Tokens
|
if usage.Output > 0 {
|
||||||
if usage.Output > 0 {
|
if message.Metadata.Assistant.Summary {
|
||||||
tokens = (usage.Input +
|
tokens = usage.Output
|
||||||
usage.Cache.Write +
|
continue
|
||||||
usage.Cache.Read +
|
|
||||||
usage.Output +
|
|
||||||
usage.Reasoning)
|
|
||||||
}
|
}
|
||||||
|
tokens = (usage.Input +
|
||||||
|
usage.Cache.Write +
|
||||||
|
usage.Cache.Read +
|
||||||
|
usage.Output +
|
||||||
|
usage.Reasoning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,28 +5,59 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
"github.com/sst/opencode/pkg/client"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ModelUsage struct {
|
||||||
|
ProviderID string `toml:"provider_id"`
|
||||||
|
ModelID string `toml:"model_id"`
|
||||||
|
LastUsed time.Time `toml:"last_used"`
|
||||||
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
Theme string `toml:"theme"`
|
Theme string `toml:"theme"`
|
||||||
Provider string `toml:"provider"`
|
Provider string `toml:"provider"`
|
||||||
Model string `toml:"model"`
|
Model string `toml:"model"`
|
||||||
|
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
|
||||||
|
MessagesRight bool `toml:"messages_right"`
|
||||||
|
SplitDiff bool `toml:"split_diff"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewState() *State {
|
func NewState() *State {
|
||||||
return &State{
|
return &State{
|
||||||
Theme: "opencode",
|
Theme: "opencode",
|
||||||
|
RecentlyUsedModels: make([]ModelUsage, 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func MergeState(state *State, config *client.ConfigInfo) *client.ConfigInfo {
|
// UpdateModelUsage updates the recently used models list with the specified model
|
||||||
if config.Theme == nil {
|
func (s *State) UpdateModelUsage(providerID, modelID string) {
|
||||||
config.Theme = &state.Theme
|
now := time.Now()
|
||||||
|
|
||||||
|
// Check if this model is already in the list
|
||||||
|
for i, usage := range s.RecentlyUsedModels {
|
||||||
|
if usage.ProviderID == providerID && usage.ModelID == modelID {
|
||||||
|
s.RecentlyUsedModels[i].LastUsed = now
|
||||||
|
usage := s.RecentlyUsedModels[i]
|
||||||
|
copy(s.RecentlyUsedModels[1:i+1], s.RecentlyUsedModels[0:i])
|
||||||
|
s.RecentlyUsedModels[0] = usage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newUsage := ModelUsage{
|
||||||
|
ProviderID: providerID,
|
||||||
|
ModelID: modelID,
|
||||||
|
LastUsed: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepend to slice and limit to last 50 entries
|
||||||
|
s.RecentlyUsedModels = append([]ModelUsage{newUsage}, s.RecentlyUsedModels...)
|
||||||
|
if len(s.RecentlyUsedModels) > 50 {
|
||||||
|
s.RecentlyUsedModels = s.RecentlyUsedModels[:50]
|
||||||
}
|
}
|
||||||
return config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveState writes the provided Config struct to the specified TOML file.
|
// SaveState writes the provided Config struct to the specified TOML file.
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import (
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
|
||||||
"github.com/sst/opencode/internal/theme"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Container interface {
|
|
||||||
tea.Model
|
|
||||||
tea.ViewModel
|
|
||||||
Sizeable
|
|
||||||
Focusable
|
|
||||||
Alignable
|
|
||||||
}
|
|
||||||
|
|
||||||
type container struct {
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
x int
|
|
||||||
y int
|
|
||||||
|
|
||||||
content tea.ViewModel
|
|
||||||
|
|
||||||
paddingTop int
|
|
||||||
paddingRight int
|
|
||||||
paddingBottom int
|
|
||||||
paddingLeft int
|
|
||||||
|
|
||||||
borderTop bool
|
|
||||||
borderRight bool
|
|
||||||
borderBottom bool
|
|
||||||
borderLeft bool
|
|
||||||
borderStyle lipgloss.Border
|
|
||||||
|
|
||||||
maxWidth int
|
|
||||||
align lipgloss.Position
|
|
||||||
|
|
||||||
focused bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *container) Init() tea.Cmd {
|
|
||||||
if model, ok := c.content.(tea.Model); ok {
|
|
||||||
return model.Init()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if model, ok := c.content.(tea.Model); ok {
|
|
||||||
u, cmd := model.Update(msg)
|
|
||||||
c.content = u.(tea.ViewModel)
|
|
||||||
return c, cmd
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *container) View() string {
|
|
||||||
t := theme.CurrentTheme()
|
|
||||||
style := styles.NewStyle().Background(t.Background())
|
|
||||||
width := c.width
|
|
||||||
height := c.height
|
|
||||||
|
|
||||||
// Apply max width constraint if set
|
|
||||||
if c.maxWidth > 0 && width > c.maxWidth {
|
|
||||||
width = c.maxWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply border if any side is enabled
|
|
||||||
if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
|
|
||||||
// Adjust width and height for borders
|
|
||||||
if c.borderTop {
|
|
||||||
height--
|
|
||||||
}
|
|
||||||
if c.borderBottom {
|
|
||||||
height--
|
|
||||||
}
|
|
||||||
if c.borderLeft {
|
|
||||||
width--
|
|
||||||
}
|
|
||||||
if c.borderRight {
|
|
||||||
width--
|
|
||||||
}
|
|
||||||
style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
|
|
||||||
|
|
||||||
// Use primary color for border if focused
|
|
||||||
if c.focused {
|
|
||||||
style = style.BorderBackground(t.Background()).BorderForeground(t.Primary())
|
|
||||||
} else {
|
|
||||||
style = style.BorderBackground(t.Background()).BorderForeground(t.Border())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
style = style.
|
|
||||||
Width(width).
|
|
||||||
Height(height).
|
|
||||||
PaddingTop(c.paddingTop).
|
|
||||||
PaddingRight(c.paddingRight).
|
|
||||||
PaddingBottom(c.paddingBottom).
|
|
||||||
PaddingLeft(c.paddingLeft)
|
|
||||||
|
|
||||||
return style.Render(c.content.View())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *container) SetSize(width, height int) tea.Cmd {
|
|
||||||
c.width = width
|
|
||||||
c.height = height
|
|
||||||
|
|
||||||
// Apply max width constraint if set
|
|
||||||
effectiveWidth := width
|
|
||||||
if c.maxWidth > 0 && width > c.maxWidth {
|
|
||||||
effectiveWidth = c.maxWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the content implements Sizeable, adjust its size to account for padding and borders
|
|
||||||
if sizeable, ok := c.content.(Sizeable); ok {
|
|
||||||
// Calculate horizontal space taken by padding and borders
|
|
||||||
horizontalSpace := c.paddingLeft + c.paddingRight
|
|
||||||
if c.borderLeft {
|
|
||||||
horizontalSpace++
|
|
||||||
}
|
|
||||||
if c.borderRight {
|
|
||||||
horizontalSpace++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate vertical space taken by padding and borders
|
|
||||||
verticalSpace := c.paddingTop + c.paddingBottom
|
|
||||||
if c.borderTop {
|
|
||||||
verticalSpace++
|
|
||||||
}
|
|
||||||
if c.borderBottom {
|
|
||||||
verticalSpace++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set content size with adjusted dimensions
|
|
||||||
contentWidth := max(0, effectiveWidth-horizontalSpace)
|
|
||||||
contentHeight := max(0, height-verticalSpace)
|
|
||||||
return sizeable.SetSize(contentWidth, contentHeight)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *container) GetSize() (int, int) {
|
|
||||||
return min(c.width, c.maxWidth), c.height
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *container) MaxWidth() int {
|
|
||||||
return c.maxWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *container) Alignment() lipgloss.Position {
|
|
||||||
return c.align
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus sets the container as focused
|
|
||||||
func (c *container) Focus() tea.Cmd {
|
|
||||||
c.focused = true
|
|
||||||
if focusable, ok := c.content.(Focusable); ok {
|
|
||||||
return focusable.Focus()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur removes focus from the container
|
|
||||||
func (c *container) Blur() tea.Cmd {
|
|
||||||
c.focused = false
|
|
||||||
if blurable, ok := c.content.(Focusable); ok {
|
|
||||||
return blurable.Blur()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *container) IsFocused() bool {
|
|
||||||
if blurable, ok := c.content.(Focusable); ok {
|
|
||||||
return blurable.IsFocused()
|
|
||||||
}
|
|
||||||
return c.focused
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPosition returns the x, y coordinates of the container
|
|
||||||
func (c *container) GetPosition() (x, y int) {
|
|
||||||
return c.x, c.y
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *container) SetPosition(x, y int) {
|
|
||||||
c.x = x
|
|
||||||
c.y = y
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContainerOption func(*container)
|
|
||||||
|
|
||||||
func NewContainer(content tea.ViewModel, options ...ContainerOption) Container {
|
|
||||||
c := &container{
|
|
||||||
content: content,
|
|
||||||
borderStyle: lipgloss.NormalBorder(),
|
|
||||||
}
|
|
||||||
for _, option := range options {
|
|
||||||
option(c)
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// Padding options
|
|
||||||
func WithPadding(top, right, bottom, left int) ContainerOption {
|
|
||||||
return func(c *container) {
|
|
||||||
c.paddingTop = top
|
|
||||||
c.paddingRight = right
|
|
||||||
c.paddingBottom = bottom
|
|
||||||
c.paddingLeft = left
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithPaddingAll(padding int) ContainerOption {
|
|
||||||
return WithPadding(padding, padding, padding, padding)
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithPaddingHorizontal(padding int) ContainerOption {
|
|
||||||
return func(c *container) {
|
|
||||||
c.paddingLeft = padding
|
|
||||||
c.paddingRight = padding
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithPaddingVertical(padding int) ContainerOption {
|
|
||||||
return func(c *container) {
|
|
||||||
c.paddingTop = padding
|
|
||||||
c.paddingBottom = padding
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithBorder(top, right, bottom, left bool) ContainerOption {
|
|
||||||
return func(c *container) {
|
|
||||||
c.borderTop = top
|
|
||||||
c.borderRight = right
|
|
||||||
c.borderBottom = bottom
|
|
||||||
c.borderLeft = left
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithBorderAll() ContainerOption {
|
|
||||||
return WithBorder(true, true, true, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithBorderHorizontal() ContainerOption {
|
|
||||||
return WithBorder(true, false, true, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithBorderVertical() ContainerOption {
|
|
||||||
return WithBorder(false, true, false, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithBorderStyle(style lipgloss.Border) ContainerOption {
|
|
||||||
return func(c *container) {
|
|
||||||
c.borderStyle = style
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithRoundedBorder() ContainerOption {
|
|
||||||
return WithBorderStyle(lipgloss.RoundedBorder())
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithThickBorder() ContainerOption {
|
|
||||||
return WithBorderStyle(lipgloss.ThickBorder())
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithDoubleBorder() ContainerOption {
|
|
||||||
return WithBorderStyle(lipgloss.DoubleBorder())
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithMaxWidth(maxWidth int) ContainerOption {
|
|
||||||
return func(c *container) {
|
|
||||||
c.maxWidth = maxWidth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithAlign(align lipgloss.Position) ContainerOption {
|
|
||||||
return func(c *container) {
|
|
||||||
c.align = align
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithAlignLeft() ContainerOption {
|
|
||||||
return WithAlign(lipgloss.Left)
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithAlignCenter() ContainerOption {
|
|
||||||
return WithAlign(lipgloss.Center)
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithAlignRight() ContainerOption {
|
|
||||||
return WithAlign(lipgloss.Right)
|
|
||||||
}
|
|
||||||
@@ -1,255 +1,325 @@
|
|||||||
package layout
|
package layout
|
||||||
|
|
||||||
import (
|
import (
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
|
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||||
"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 FlexDirection int
|
type Direction int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
FlexDirectionHorizontal FlexDirection = iota
|
Row Direction = iota
|
||||||
FlexDirectionVertical
|
Column
|
||||||
)
|
)
|
||||||
|
|
||||||
type FlexChildSize struct {
|
type Justify int
|
||||||
Fixed bool
|
|
||||||
Size int
|
const (
|
||||||
|
JustifyStart Justify = iota
|
||||||
|
JustifyEnd
|
||||||
|
JustifyCenter
|
||||||
|
JustifySpaceBetween
|
||||||
|
JustifySpaceAround
|
||||||
|
)
|
||||||
|
|
||||||
|
type Align int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlignStart Align = iota
|
||||||
|
AlignEnd
|
||||||
|
AlignCenter
|
||||||
|
AlignStretch // Only applicable in the cross-axis
|
||||||
|
)
|
||||||
|
|
||||||
|
type FlexOptions struct {
|
||||||
|
Background *compat.AdaptiveColor
|
||||||
|
Direction Direction
|
||||||
|
Justify Justify
|
||||||
|
Align Align
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Gap int
|
||||||
}
|
}
|
||||||
|
|
||||||
var FlexChildSizeGrow = FlexChildSize{Fixed: false}
|
type FlexItem struct {
|
||||||
|
View string
|
||||||
func FlexChildSizeFixed(size int) FlexChildSize {
|
FixedSize int // Fixed size in the main axis (width for Row, height for Column)
|
||||||
return FlexChildSize{Fixed: true, Size: size}
|
Grow bool // If true, the item will grow to fill available space
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlexLayout interface {
|
// Render lays out a series of view strings based on flexbox-like rules.
|
||||||
tea.ViewModel
|
func Render(opts FlexOptions, items ...FlexItem) string {
|
||||||
Sizeable
|
if len(items) == 0 {
|
||||||
SetChildren(panes []tea.ViewModel) tea.Cmd
|
|
||||||
SetSizes(sizes []FlexChildSize) tea.Cmd
|
|
||||||
SetDirection(direction FlexDirection) tea.Cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
type flexLayout struct {
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
direction FlexDirection
|
|
||||||
children []tea.ViewModel
|
|
||||||
sizes []FlexChildSize
|
|
||||||
}
|
|
||||||
|
|
||||||
type FlexLayoutOption func(*flexLayout)
|
|
||||||
|
|
||||||
func (f *flexLayout) View() string {
|
|
||||||
if len(f.children) == 0 {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
views := make([]string, 0, len(f.children))
|
if opts.Background == nil {
|
||||||
for i, child := range f.children {
|
background := t.Background()
|
||||||
if child == nil {
|
opts.Background = &background
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
|
|
||||||
alignment := lipgloss.Center
|
// Calculate dimensions for each item
|
||||||
if alignable, ok := child.(Alignable); ok {
|
mainAxisSize := opts.Width
|
||||||
alignment = alignable.Alignment()
|
crossAxisSize := opts.Height
|
||||||
|
if opts.Direction == Column {
|
||||||
|
mainAxisSize = opts.Height
|
||||||
|
crossAxisSize = opts.Width
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total fixed size and count grow items
|
||||||
|
totalFixedSize := 0
|
||||||
|
growCount := 0
|
||||||
|
for _, item := range items {
|
||||||
|
if item.FixedSize > 0 {
|
||||||
|
totalFixedSize += item.FixedSize
|
||||||
|
} else if item.Grow {
|
||||||
|
growCount++
|
||||||
}
|
}
|
||||||
var childWidth, childHeight int
|
}
|
||||||
if f.direction == FlexDirectionHorizontal {
|
|
||||||
childWidth, childHeight = f.calculateChildSize(i)
|
// Account for gaps between items
|
||||||
view := lipgloss.PlaceHorizontal(
|
totalGapSize := 0
|
||||||
childWidth,
|
if len(items) > 1 && opts.Gap > 0 {
|
||||||
alignment,
|
totalGapSize = opts.Gap * (len(items) - 1)
|
||||||
child.View(),
|
}
|
||||||
// TODO: make configurable WithBackgroundStyle
|
|
||||||
lipgloss.WithWhitespaceStyle(styles.NewStyle().Background(t.Background()).Lipgloss()),
|
// Calculate available space for grow items
|
||||||
)
|
availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0)
|
||||||
views = append(views, view)
|
|
||||||
|
// Calculate size for each grow item
|
||||||
|
growItemSize := 0
|
||||||
|
if growCount > 0 && availableSpace > 0 {
|
||||||
|
growItemSize = availableSpace / growCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare sized views
|
||||||
|
sizedViews := make([]string, len(items))
|
||||||
|
actualSizes := make([]int, len(items))
|
||||||
|
|
||||||
|
for i, item := range items {
|
||||||
|
view := item.View
|
||||||
|
|
||||||
|
// Determine the size for this item
|
||||||
|
itemSize := 0
|
||||||
|
if item.FixedSize > 0 {
|
||||||
|
itemSize = item.FixedSize
|
||||||
|
} else if item.Grow && growItemSize > 0 {
|
||||||
|
itemSize = growItemSize
|
||||||
} else {
|
} else {
|
||||||
childWidth, childHeight = f.calculateChildSize(i)
|
// No fixed size and not growing - use natural size
|
||||||
view := lipgloss.Place(
|
if opts.Direction == Row {
|
||||||
f.width,
|
itemSize = lipgloss.Width(view)
|
||||||
childHeight,
|
|
||||||
lipgloss.Center,
|
|
||||||
alignment,
|
|
||||||
child.View(),
|
|
||||||
// TODO: make configurable WithBackgroundStyle
|
|
||||||
lipgloss.WithWhitespaceStyle(styles.NewStyle().Background(t.Background()).Lipgloss()),
|
|
||||||
)
|
|
||||||
views = append(views, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if f.direction == FlexDirectionHorizontal {
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Center, views...)
|
|
||||||
}
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Center, views...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *flexLayout) calculateChildSize(index int) (width, height int) {
|
|
||||||
if index >= len(f.children) {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
totalFixed := 0
|
|
||||||
flexCount := 0
|
|
||||||
|
|
||||||
for i, child := range f.children {
|
|
||||||
if child == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if i < len(f.sizes) && f.sizes[i].Fixed {
|
|
||||||
if f.direction == FlexDirectionHorizontal {
|
|
||||||
totalFixed += f.sizes[i].Size
|
|
||||||
} else {
|
} else {
|
||||||
totalFixed += f.sizes[i].Size
|
itemSize = lipgloss.Height(view)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
flexCount++
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if f.direction == FlexDirectionHorizontal {
|
// Apply size constraints
|
||||||
height = f.height
|
if opts.Direction == Row {
|
||||||
if index < len(f.sizes) && f.sizes[index].Fixed {
|
// For row direction, constrain width and handle height alignment
|
||||||
width = f.sizes[index].Size
|
if itemSize > 0 {
|
||||||
} else if flexCount > 0 {
|
view = styles.NewStyle().
|
||||||
remainingSpace := f.width - totalFixed
|
Background(*opts.Background).
|
||||||
width = remainingSpace / flexCount
|
Width(itemSize).
|
||||||
}
|
Height(crossAxisSize).
|
||||||
} else {
|
Render(view)
|
||||||
width = f.width
|
|
||||||
if index < len(f.sizes) && f.sizes[index].Fixed {
|
|
||||||
height = f.sizes[index].Size
|
|
||||||
} else if flexCount > 0 {
|
|
||||||
remainingSpace := f.height - totalFixed
|
|
||||||
height = remainingSpace / flexCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return width, height
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *flexLayout) SetSize(width, height int) tea.Cmd {
|
|
||||||
f.width = width
|
|
||||||
f.height = height
|
|
||||||
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
currentX, currentY := 0, 0
|
|
||||||
|
|
||||||
for i, child := range f.children {
|
|
||||||
if child != nil {
|
|
||||||
paneWidth, paneHeight := f.calculateChildSize(i)
|
|
||||||
alignment := lipgloss.Center
|
|
||||||
if alignable, ok := child.(Alignable); ok {
|
|
||||||
alignment = alignable.Alignment()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate actual position based on alignment
|
// Apply cross-axis alignment
|
||||||
actualX, actualY := currentX, currentY
|
switch opts.Align {
|
||||||
|
case AlignCenter:
|
||||||
|
view = lipgloss.PlaceVertical(
|
||||||
|
crossAxisSize,
|
||||||
|
lipgloss.Center,
|
||||||
|
view,
|
||||||
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
|
)
|
||||||
|
case AlignEnd:
|
||||||
|
view = lipgloss.PlaceVertical(
|
||||||
|
crossAxisSize,
|
||||||
|
lipgloss.Bottom,
|
||||||
|
view,
|
||||||
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
|
)
|
||||||
|
case AlignStart:
|
||||||
|
view = lipgloss.PlaceVertical(
|
||||||
|
crossAxisSize,
|
||||||
|
lipgloss.Top,
|
||||||
|
view,
|
||||||
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
|
)
|
||||||
|
case AlignStretch:
|
||||||
|
// Already stretched by Height setting above
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For column direction, constrain height and handle width alignment
|
||||||
|
if itemSize > 0 {
|
||||||
|
style := styles.NewStyle().
|
||||||
|
Background(*opts.Background).
|
||||||
|
Height(itemSize)
|
||||||
|
// Only set width for stretch alignment
|
||||||
|
if opts.Align == AlignStretch {
|
||||||
|
style = style.Width(crossAxisSize)
|
||||||
|
}
|
||||||
|
view = style.Render(view)
|
||||||
|
}
|
||||||
|
|
||||||
if f.direction == FlexDirectionHorizontal {
|
// Apply cross-axis alignment
|
||||||
// In horizontal layout, vertical alignment affects Y position
|
switch opts.Align {
|
||||||
// (lipgloss.Center is used for vertical alignment in JoinHorizontal)
|
case AlignCenter:
|
||||||
actualY = (f.height - paneHeight) / 2
|
view = lipgloss.PlaceHorizontal(
|
||||||
} else {
|
crossAxisSize,
|
||||||
// In vertical layout, horizontal alignment affects X position
|
lipgloss.Center,
|
||||||
contentWidth := paneWidth
|
view,
|
||||||
if alignable, ok := child.(Alignable); ok {
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
if alignable.MaxWidth() > 0 && contentWidth > alignable.MaxWidth() {
|
)
|
||||||
contentWidth = alignable.MaxWidth()
|
case AlignEnd:
|
||||||
|
view = lipgloss.PlaceHorizontal(
|
||||||
|
crossAxisSize,
|
||||||
|
lipgloss.Right,
|
||||||
|
view,
|
||||||
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
|
)
|
||||||
|
case AlignStart:
|
||||||
|
view = lipgloss.PlaceHorizontal(
|
||||||
|
crossAxisSize,
|
||||||
|
lipgloss.Left,
|
||||||
|
view,
|
||||||
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
|
)
|
||||||
|
case AlignStretch:
|
||||||
|
// Already stretched by Width setting above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sizedViews[i] = view
|
||||||
|
if opts.Direction == Row {
|
||||||
|
actualSizes[i] = lipgloss.Width(view)
|
||||||
|
} else {
|
||||||
|
actualSizes[i] = lipgloss.Height(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total actual size including gaps
|
||||||
|
totalActualSize := 0
|
||||||
|
for _, size := range actualSizes {
|
||||||
|
totalActualSize += size
|
||||||
|
}
|
||||||
|
if len(items) > 1 && opts.Gap > 0 {
|
||||||
|
totalActualSize += opts.Gap * (len(items) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply justification
|
||||||
|
remainingSpace := max(mainAxisSize-totalActualSize, 0)
|
||||||
|
|
||||||
|
// Calculate spacing based on justification
|
||||||
|
var spaceBefore, spaceBetween, spaceAfter int
|
||||||
|
switch opts.Justify {
|
||||||
|
case JustifyStart:
|
||||||
|
spaceAfter = remainingSpace
|
||||||
|
case JustifyEnd:
|
||||||
|
spaceBefore = remainingSpace
|
||||||
|
case JustifyCenter:
|
||||||
|
spaceBefore = remainingSpace / 2
|
||||||
|
spaceAfter = remainingSpace - spaceBefore
|
||||||
|
case JustifySpaceBetween:
|
||||||
|
if len(items) > 1 {
|
||||||
|
spaceBetween = remainingSpace / (len(items) - 1)
|
||||||
|
} else {
|
||||||
|
spaceAfter = remainingSpace
|
||||||
|
}
|
||||||
|
case JustifySpaceAround:
|
||||||
|
if len(items) > 0 {
|
||||||
|
spaceAround := remainingSpace / (len(items) * 2)
|
||||||
|
spaceBefore = spaceAround
|
||||||
|
spaceAfter = spaceAround
|
||||||
|
spaceBetween = spaceAround * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the final layout
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
spaceStyle := styles.NewStyle().Background(*opts.Background)
|
||||||
|
// Add space before if needed
|
||||||
|
if spaceBefore > 0 {
|
||||||
|
if opts.Direction == Row {
|
||||||
|
space := strings.Repeat(" ", spaceBefore)
|
||||||
|
parts = append(parts, spaceStyle.Render(space))
|
||||||
|
} else {
|
||||||
|
// For vertical layout, add empty lines as separate parts
|
||||||
|
for range spaceBefore {
|
||||||
|
parts = append(parts, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add items with spacing
|
||||||
|
for i, view := range sizedViews {
|
||||||
|
parts = append(parts, view)
|
||||||
|
|
||||||
|
// Add space between items (not after the last one)
|
||||||
|
if i < len(sizedViews)-1 {
|
||||||
|
// Add gap first, then any additional spacing from justification
|
||||||
|
totalSpacing := opts.Gap + spaceBetween
|
||||||
|
if totalSpacing > 0 {
|
||||||
|
if opts.Direction == Row {
|
||||||
|
space := strings.Repeat(" ", totalSpacing)
|
||||||
|
parts = append(parts, spaceStyle.Render(space))
|
||||||
|
} else {
|
||||||
|
// For vertical layout, add empty lines as separate parts
|
||||||
|
for range totalSpacing {
|
||||||
|
parts = append(parts, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch alignment {
|
|
||||||
case lipgloss.Center:
|
|
||||||
actualX = (f.width - contentWidth) / 2
|
|
||||||
case lipgloss.Right:
|
|
||||||
actualX = f.width - contentWidth
|
|
||||||
case lipgloss.Left:
|
|
||||||
actualX = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set position if the pane is Alignable
|
|
||||||
if c, ok := child.(Alignable); ok {
|
|
||||||
c.SetPosition(actualX, actualY)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sizeable, ok := child.(Sizeable); ok {
|
|
||||||
cmd := sizeable.SetSize(paneWidth, paneHeight)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update position for next pane
|
|
||||||
if f.direction == FlexDirectionHorizontal {
|
|
||||||
currentX += paneWidth
|
|
||||||
} else {
|
|
||||||
currentY += paneHeight
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *flexLayout) GetSize() (int, int) {
|
// Add space after if needed
|
||||||
return f.width, f.height
|
if spaceAfter > 0 {
|
||||||
}
|
if opts.Direction == Row {
|
||||||
|
space := strings.Repeat(" ", spaceAfter)
|
||||||
func (f *flexLayout) SetChildren(children []tea.ViewModel) tea.Cmd {
|
parts = append(parts, spaceStyle.Render(space))
|
||||||
f.children = children
|
} else {
|
||||||
if f.width > 0 && f.height > 0 {
|
// For vertical layout, add empty lines as separate parts
|
||||||
return f.SetSize(f.width, f.height)
|
for range spaceAfter {
|
||||||
|
parts = append(parts, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *flexLayout) SetSizes(sizes []FlexChildSize) tea.Cmd {
|
// Join the parts
|
||||||
f.sizes = sizes
|
if opts.Direction == Row {
|
||||||
if f.width > 0 && f.height > 0 {
|
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
|
||||||
return f.SetSize(f.width, f.height)
|
} else {
|
||||||
}
|
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
|
|
||||||
f.direction = direction
|
|
||||||
if f.width > 0 && f.height > 0 {
|
|
||||||
return f.SetSize(f.width, f.height)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFlexLayout(children []tea.ViewModel, options ...FlexLayoutOption) FlexLayout {
|
|
||||||
layout := &flexLayout{
|
|
||||||
children: children,
|
|
||||||
direction: FlexDirectionHorizontal,
|
|
||||||
sizes: []FlexChildSize{},
|
|
||||||
}
|
|
||||||
for _, option := range options {
|
|
||||||
option(layout)
|
|
||||||
}
|
|
||||||
return layout
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithDirection(direction FlexDirection) FlexLayoutOption {
|
|
||||||
return func(f *flexLayout) {
|
|
||||||
f.direction = direction
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithChildren(children ...tea.ViewModel) FlexLayoutOption {
|
// Helper function to create a simple vertical layout
|
||||||
return func(f *flexLayout) {
|
func Vertical(width, height int, items ...FlexItem) string {
|
||||||
f.children = children
|
return Render(FlexOptions{
|
||||||
}
|
Direction: Column,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Justify: JustifyStart,
|
||||||
|
Align: AlignStretch,
|
||||||
|
}, items...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithSizes(sizes ...FlexChildSize) FlexLayoutOption {
|
// Helper function to create a simple horizontal layout
|
||||||
return func(f *flexLayout) {
|
func Horizontal(width, height int, items ...FlexItem) string {
|
||||||
f.sizes = sizes
|
return Render(FlexOptions{
|
||||||
}
|
Direction: Row,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Justify: JustifyStart,
|
||||||
|
Align: AlignStretch,
|
||||||
|
}, items...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package layout_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/sst/opencode/internal/layout"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleRender_withGap() {
|
||||||
|
// Create a horizontal layout with 3px gap between items
|
||||||
|
result := layout.Render(
|
||||||
|
layout.FlexOptions{
|
||||||
|
Direction: layout.Row,
|
||||||
|
Width: 30,
|
||||||
|
Height: 1,
|
||||||
|
Gap: 3,
|
||||||
|
},
|
||||||
|
layout.FlexItem{View: "Item1"},
|
||||||
|
layout.FlexItem{View: "Item2"},
|
||||||
|
layout.FlexItem{View: "Item3"},
|
||||||
|
)
|
||||||
|
fmt.Println(result)
|
||||||
|
// Output: Item1 Item2 Item3
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleRender_withGapAndJustify() {
|
||||||
|
// Create a horizontal layout with gap and space-between justification
|
||||||
|
result := layout.Render(
|
||||||
|
layout.FlexOptions{
|
||||||
|
Direction: layout.Row,
|
||||||
|
Width: 30,
|
||||||
|
Height: 1,
|
||||||
|
Gap: 2,
|
||||||
|
Justify: layout.JustifySpaceBetween,
|
||||||
|
},
|
||||||
|
layout.FlexItem{View: "A"},
|
||||||
|
layout.FlexItem{View: "B"},
|
||||||
|
layout.FlexItem{View: "C"},
|
||||||
|
)
|
||||||
|
fmt.Println(result)
|
||||||
|
// Output: A B C
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFlexGap(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts FlexOptions
|
||||||
|
items []FlexItem
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Row with gap",
|
||||||
|
opts: FlexOptions{
|
||||||
|
Direction: Row,
|
||||||
|
Width: 20,
|
||||||
|
Height: 1,
|
||||||
|
Gap: 2,
|
||||||
|
},
|
||||||
|
items: []FlexItem{
|
||||||
|
{View: "A"},
|
||||||
|
{View: "B"},
|
||||||
|
{View: "C"},
|
||||||
|
},
|
||||||
|
expected: "A B C",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Column with gap",
|
||||||
|
opts: FlexOptions{
|
||||||
|
Direction: Column,
|
||||||
|
Width: 1,
|
||||||
|
Height: 5,
|
||||||
|
Gap: 1,
|
||||||
|
Align: AlignStart,
|
||||||
|
},
|
||||||
|
items: []FlexItem{
|
||||||
|
{View: "A", FixedSize: 1},
|
||||||
|
{View: "B", FixedSize: 1},
|
||||||
|
{View: "C", FixedSize: 1},
|
||||||
|
},
|
||||||
|
expected: "A\n \nB\n \nC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Row with gap and justify space between",
|
||||||
|
opts: FlexOptions{
|
||||||
|
Direction: Row,
|
||||||
|
Width: 15,
|
||||||
|
Height: 1,
|
||||||
|
Gap: 1,
|
||||||
|
Justify: JustifySpaceBetween,
|
||||||
|
},
|
||||||
|
items: []FlexItem{
|
||||||
|
{View: "A"},
|
||||||
|
{View: "B"},
|
||||||
|
{View: "C"},
|
||||||
|
},
|
||||||
|
expected: "A B C",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No gap specified",
|
||||||
|
opts: FlexOptions{
|
||||||
|
Direction: Row,
|
||||||
|
Width: 10,
|
||||||
|
Height: 1,
|
||||||
|
},
|
||||||
|
items: []FlexItem{
|
||||||
|
{View: "A"},
|
||||||
|
{View: "B"},
|
||||||
|
{View: "C"},
|
||||||
|
},
|
||||||
|
expected: "ABC",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := Render(tt.opts, tt.items...)
|
||||||
|
// Trim any trailing spaces for comparison
|
||||||
|
result = strings.TrimRight(result, " ")
|
||||||
|
expected := strings.TrimRight(tt.expected, " ")
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Render() = %q, want %q", result, expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
package layout
|
package layout
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/key"
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var Current *LayoutInfo
|
var Current *LayoutInfo
|
||||||
@@ -34,33 +30,3 @@ type Modal interface {
|
|||||||
Render(background string) string
|
Render(background string) string
|
||||||
Close() tea.Cmd
|
Close() tea.Cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
type Focusable interface {
|
|
||||||
Focus() tea.Cmd
|
|
||||||
Blur() tea.Cmd
|
|
||||||
IsFocused() bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type Sizeable interface {
|
|
||||||
SetSize(width, height int) tea.Cmd
|
|
||||||
GetSize() (int, int)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Alignable interface {
|
|
||||||
MaxWidth() int
|
|
||||||
Alignment() lipgloss.Position
|
|
||||||
SetPosition(x, y int)
|
|
||||||
GetPosition() (x, y int)
|
|
||||||
}
|
|
||||||
|
|
||||||
func KeyMapToSlice(t any) (bindings []key.Binding) {
|
|
||||||
typ := reflect.TypeOf(t)
|
|
||||||
if typ.Kind() != reflect.Struct {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for i := range typ.NumField() {
|
|
||||||
v := reflect.ValueOf(t).Field(i)
|
|
||||||
bindings = append(bindings, v.Interface().(key.Binding))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ type LoadedTheme struct {
|
|||||||
name string
|
name string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *LoadedTheme) Name() string {
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
|
||||||
type colorRef struct {
|
type colorRef struct {
|
||||||
value any
|
value any
|
||||||
resolved bool
|
resolved bool
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ func NewSystemTheme(terminalBg color.Color, isDark bool) *SystemTheme {
|
|||||||
return theme
|
return theme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *SystemTheme) Name() string {
|
||||||
|
return "system"
|
||||||
|
}
|
||||||
|
|
||||||
// initializeColors sets up all theme colors
|
// initializeColors sets up all theme colors
|
||||||
func (t *SystemTheme) initializeColors() {
|
func (t *SystemTheme) initializeColors() {
|
||||||
// Generate gray scale based on terminal background
|
// Generate gray scale based on terminal background
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
// All colors must be defined as compat.AdaptiveColor to support
|
// All colors must be defined as compat.AdaptiveColor to support
|
||||||
// both light and dark terminal backgrounds.
|
// both light and dark terminal backgrounds.
|
||||||
type Theme interface {
|
type Theme interface {
|
||||||
|
Name() string
|
||||||
|
|
||||||
// Background colors
|
// Background colors
|
||||||
Background() compat.AdaptiveColor // Radix 1
|
Background() compat.AdaptiveColor // Radix 1
|
||||||
BackgroundPanel() compat.AdaptiveColor // Radix 2
|
BackgroundPanel() compat.AdaptiveColor // Radix 2
|
||||||
|
|||||||
@@ -78,4 +78,3 @@
|
|||||||
"syntaxPunctuation": "darkFg"
|
"syntaxPunctuation": "darkFg"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,4 +110,3 @@
|
|||||||
"syntaxPunctuation": { "dark": "darkText", "light": "lightText" }
|
"syntaxPunctuation": { "dark": "darkText", "light": "lightText" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,4 +225,4 @@
|
|||||||
"light": "#193549"
|
"light": "#193549"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user