mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-05 00:03:03 +08:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = 80
|
||||
32
.github/workflows/stats.yml
vendored
Normal file
32
.github/workflows/stats.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: stats
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * *" # Run daily at 12:00 UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
stats:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Run stats script
|
||||
run: bun scripts/stats.ts
|
||||
|
||||
- name: Commit stats
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add STATS.md
|
||||
git diff --staged --quiet || git commit -m "Update download stats $(date -I)"
|
||||
git push
|
||||
@@ -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.
|
||||
|
||||
> **Note**: Please talk to us via github issues before spending time working on
|
||||
> a new feature
|
||||
|
||||
To run opencode locally you need.
|
||||
|
||||
- Bun
|
||||
@@ -67,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 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?
|
||||
|
||||
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).
|
||||
|
||||
7
STATS.md
Normal file
7
STATS.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 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) |
|
||||
25
bun.lock
25
bun.lock
@@ -36,6 +36,7 @@
|
||||
"env-paths": "3.0.0",
|
||||
"hono": "4.7.10",
|
||||
"hono-openapi": "0.4.8",
|
||||
"isomorphic-git": "1.32.1",
|
||||
"open": "10.1.2",
|
||||
"remeda": "2.22.3",
|
||||
"ts-lsp-client": "1.0.3",
|
||||
@@ -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=="],
|
||||
|
||||
"async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -633,6 +636,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="],
|
||||
|
||||
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
@@ -939,6 +948,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
@@ -987,6 +998,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
||||
@@ -1169,6 +1182,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1257,6 +1272,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
@@ -1267,6 +1284,8 @@
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1793,6 +1814,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="],
|
||||
|
||||
@@ -264,6 +264,10 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Environment variables to set when running the MCP server"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable the MCP server on startup"
|
||||
}
|
||||
},
|
||||
"required": ["type", "command"],
|
||||
@@ -280,6 +284,10 @@
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL of the remote MCP server"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable the MCP server on startup"
|
||||
}
|
||||
},
|
||||
"required": ["type", "url"],
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"env-paths": "3.0.0",
|
||||
"hono": "4.7.10",
|
||||
"hono-openapi": "0.4.8",
|
||||
"isomorphic-git": "1.32.1",
|
||||
"open": "10.1.2",
|
||||
"remeda": "2.22.3",
|
||||
"ts-lsp-client": "1.0.3",
|
||||
|
||||
@@ -36,12 +36,15 @@ export namespace App {
|
||||
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
|
||||
}>("app")
|
||||
|
||||
export const use = ctx.use
|
||||
|
||||
const APP_JSON = "app.json"
|
||||
|
||||
export type Input = {
|
||||
cwd: string
|
||||
}
|
||||
|
||||
export const provideExisting = ctx.provide
|
||||
export async function provide<T>(
|
||||
input: Input,
|
||||
cb: (app: App.Info) => Promise<T>,
|
||||
@@ -96,13 +99,16 @@ export namespace App {
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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>(
|
||||
@@ -11,6 +13,8 @@ export async function bootstrap<T>(
|
||||
Share.init()
|
||||
Format.init()
|
||||
ConfigHooks.init()
|
||||
LSP.init()
|
||||
FileWatcher.init()
|
||||
|
||||
return cb(app)
|
||||
})
|
||||
|
||||
26
packages/opencode/src/cli/cmd/debug/file.ts
Normal file
26
packages/opencode/src/cli/cmd/debug/file.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { File } from "../../../file"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import path from "path"
|
||||
|
||||
export const FileCommand = cmd({
|
||||
command: "file",
|
||||
builder: (yargs) => yargs.command(FileReadCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
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(path.resolve(args.path))
|
||||
console.log(content)
|
||||
})
|
||||
},
|
||||
})
|
||||
28
packages/opencode/src/cli/cmd/debug/index.ts
Normal file
28
packages/opencode/src/cli/cmd/debug/index.ts
Normal file
@@ -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() {},
|
||||
})
|
||||
37
packages/opencode/src/cli/cmd/debug/lsp.ts
Normal file
37
packages/opencode/src/cli/cmd/debug/lsp.ts
Normal file
@@ -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))
|
||||
})
|
||||
},
|
||||
})
|
||||
87
packages/opencode/src/cli/cmd/debug/ripgrep.ts
Normal file
87
packages/opencode/src/cli/cmd/debug/ripgrep.ts
Normal file
@@ -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))
|
||||
},
|
||||
})
|
||||
39
packages/opencode/src/cli/cmd/debug/snapshot.ts
Normal file
39
packages/opencode/src/cli/cmd/debug/snapshot.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Snapshot } from "../../../snapshot"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const SnapshotCommand = cmd({
|
||||
command: "snapshot",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(SnapshotCreateCommand)
|
||||
.command(SnapshotRestoreCommand)
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const SnapshotCreateCommand = cmd({
|
||||
command: "create",
|
||||
async handler() {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
const result = await Snapshot.create("test")
|
||||
console.log(result)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const SnapshotRestoreCommand = cmd({
|
||||
command: "restore <commit>",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("commit", {
|
||||
type: "string",
|
||||
description: "commit",
|
||||
demandOption: true,
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
await Snapshot.restore("test", args.commit)
|
||||
console.log("restored")
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -1,15 +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 { Server } from "../../server/server"
|
||||
import { Share } from "../../share/share"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
export const ServeCommand = cmd({
|
||||
@@ -23,7 +23,7 @@ export const ServeCommand = cmd({
|
||||
describe: "starts a headless opencode server",
|
||||
handler: async (args) => {
|
||||
const cwd = process.cwd()
|
||||
await App.provide({ cwd }, async () => {
|
||||
await bootstrap({ cwd }, async () => {
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
|
||||
@@ -37,6 +37,10 @@ export namespace Config {
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe("Environment variables to set when running the MCP server"),
|
||||
enabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Enable or disable the MCP server on startup"),
|
||||
})
|
||||
.strict()
|
||||
.openapi({
|
||||
@@ -47,6 +51,10 @@ export namespace Config {
|
||||
.object({
|
||||
type: z.literal("remote").describe("Type of MCP server connection"),
|
||||
url: z.string().describe("URL of the remote MCP server"),
|
||||
enabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Enable or disable the MCP server on startup"),
|
||||
})
|
||||
.strict()
|
||||
.openapi({
|
||||
|
||||
132
packages/opencode/src/external/ripgrep.ts
vendored
132
packages/opencode/src/external/ripgrep.ts
vendored
@@ -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 { lazy } from "../util/lazy"
|
||||
import { Log } from "../util/log"
|
||||
import { $ } from "bun"
|
||||
|
||||
export namespace Fzf {
|
||||
const log = Log.create({ service: "fzf" })
|
||||
@@ -115,24 +114,4 @@ export namespace Fzf {
|
||||
const { filepath } = await state()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { $ } from "bun"
|
||||
import { createPatch } from "diff"
|
||||
import path from "path"
|
||||
|
||||
export namespace File {
|
||||
export const Event = {
|
||||
@@ -10,4 +13,26 @@ export namespace File {
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export async function read(file: string) {
|
||||
const content = await Bun.file(file).text()
|
||||
const gitDiff = await $`git diff HEAD -- ${file}`
|
||||
.cwd(path.dirname(file))
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
if (gitDiff.trim()) {
|
||||
const relativePath = path.relative(process.cwd(), file)
|
||||
const originalContent = await $`git show HEAD:./${relativePath}`
|
||||
.cwd(process.cwd())
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
if (originalContent.trim()) {
|
||||
const patch = createPatch(file, originalContent, content)
|
||||
return patch
|
||||
}
|
||||
}
|
||||
return content.trim()
|
||||
}
|
||||
}
|
||||
|
||||
350
packages/opencode/src/file/ripgrep.ts
Normal file
350
packages/opencode/src/file/ripgrep.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
// 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(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
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 = {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
50
packages/opencode/src/file/watch.ts
Normal file
50
packages/opencode/src/file/watch.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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()
|
||||
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,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
state.watcher.close()
|
||||
},
|
||||
)()
|
||||
}
|
||||
}
|
||||
160
packages/opencode/src/format/formatter.ts
Normal file
160
packages/opencode/src/format/formatter.ts
Normal file
@@ -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,10 +1,11 @@
|
||||
import { App } from "../app/app"
|
||||
import { BunProc } from "../bun"
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
|
||||
import * as Formatter from "./formatter"
|
||||
|
||||
export namespace Format {
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
@@ -16,7 +17,7 @@ export namespace Format {
|
||||
}
|
||||
})
|
||||
|
||||
async function isEnabled(item: Definition) {
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
const s = state()
|
||||
let status = s.enabled[item.name]
|
||||
if (status === undefined) {
|
||||
@@ -28,7 +29,7 @@ export namespace Format {
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const result = []
|
||||
for (const item of FORMATTERS) {
|
||||
for (const item of Object.values(Formatter)) {
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
if (!isEnabled(item)) continue
|
||||
result.push(item)
|
||||
@@ -61,105 +62,4 @@ export namespace Format {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface Definition {
|
||||
name: string
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
extensions: string[]
|
||||
enabled(): Promise<boolean>
|
||||
}
|
||||
|
||||
const FORMATTERS: Definition[] = [
|
||||
{
|
||||
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() {
|
||||
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
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mix",
|
||||
command: ["mix", "format", "$FILE"],
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
async enabled() {
|
||||
try {
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["mix", "--version"],
|
||||
cwd: App.info().path.cwd,
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
return exit === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gofmt",
|
||||
command: ["gofmt", "-w", "$FILE"],
|
||||
extensions: [".go"],
|
||||
async enabled() {
|
||||
try {
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["gofmt", "-h"],
|
||||
cwd: App.info().path.cwd,
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
return exit === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import yargs from "yargs"
|
||||
import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { ScrapCommand } from "./cli/cmd/scrap"
|
||||
import { Log } from "./util/log"
|
||||
import { AuthCommand } from "./cli/cmd/auth"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
@@ -14,6 +13,7 @@ import { NamedError } from "./util/error"
|
||||
import { FormatError } from "./cli/error"
|
||||
import { ServeCommand } from "./cli/cmd/serve"
|
||||
import { TuiCommand } from "./cli/cmd/tui"
|
||||
import { DebugCommand } from "./cli/cmd/debug"
|
||||
|
||||
const cancel = new AbortController()
|
||||
|
||||
@@ -49,7 +49,7 @@ const cli = yargs(hideBin(process.argv))
|
||||
.command(TuiCommand)
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
.command(ScrapCommand)
|
||||
.command(DebugCommand)
|
||||
.command(AuthCommand)
|
||||
.command(UpgradeCommand)
|
||||
.command(ServeCommand)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Bus } from "../bus"
|
||||
import z from "zod"
|
||||
import type { LSPServer } from "./server"
|
||||
import { NamedError } from "../util/error"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
|
||||
export namespace LSPClient {
|
||||
const log = Log.create({ service: "lsp.client" })
|
||||
@@ -52,7 +53,9 @@ export namespace LSPClient {
|
||||
log.info("textDocument/publishDiagnostics", {
|
||||
path,
|
||||
})
|
||||
const exists = diagnostics.has(path)
|
||||
diagnostics.set(path, params.diagnostics)
|
||||
if (!exists && serverID === "typescript") return
|
||||
Bus.publish(Event.Diagnostics, { path, serverID })
|
||||
})
|
||||
connection.onRequest("workspace/configuration", async () => {
|
||||
@@ -61,7 +64,7 @@ export namespace LSPClient {
|
||||
connection.listen()
|
||||
|
||||
log.info("sending initialize", { id: serverID })
|
||||
await Promise.race([
|
||||
await withTimeout(
|
||||
connection.sendRequest("initialize", {
|
||||
processId: server.process.pid,
|
||||
workspaceFolders: [
|
||||
@@ -88,12 +91,10 @@ export namespace LSPClient {
|
||||
},
|
||||
},
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new InitializeError({ serverID }))
|
||||
}, 5_000)
|
||||
}),
|
||||
])
|
||||
5_000,
|
||||
).catch(() => {
|
||||
throw new InitializeError({ serverID })
|
||||
})
|
||||
await connection.sendNotification("initialized", {})
|
||||
log.info("initialized")
|
||||
|
||||
@@ -116,36 +117,28 @@ export namespace LSPClient {
|
||||
const file = Bun.file(input.path)
|
||||
const text = await file.text()
|
||||
const version = files[input.path]
|
||||
if (version === undefined) {
|
||||
log.info("textDocument/didOpen", input)
|
||||
if (version !== undefined) {
|
||||
diagnostics.delete(input.path)
|
||||
const extension = path.extname(input.path)
|
||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||
await connection.sendNotification("textDocument/didOpen", {
|
||||
await connection.sendNotification("textDocument/didClose", {
|
||||
textDocument: {
|
||||
uri: `file://` + input.path,
|
||||
languageId,
|
||||
version: 0,
|
||||
text,
|
||||
},
|
||||
})
|
||||
files[input.path] = 0
|
||||
return
|
||||
}
|
||||
|
||||
log.info("textDocument/didChange", input)
|
||||
log.info("textDocument/didOpen", input)
|
||||
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: {
|
||||
uri: `file://` + input.path,
|
||||
version: ++files[input.path],
|
||||
languageId,
|
||||
version: 0,
|
||||
text,
|
||||
},
|
||||
contentChanges: [
|
||||
{
|
||||
text,
|
||||
},
|
||||
],
|
||||
})
|
||||
files[input.path] = 0
|
||||
return
|
||||
},
|
||||
},
|
||||
get diagnostics() {
|
||||
@@ -157,35 +150,32 @@ export namespace LSPClient {
|
||||
: path.resolve(app.path.cwd, input.path)
|
||||
log.info("waiting for diagnostics", input)
|
||||
let unsub: () => void
|
||||
let timeout: NodeJS.Timeout
|
||||
return await Promise.race([
|
||||
new Promise<void>(async (resolve) => {
|
||||
return await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||
if (
|
||||
event.properties.path === input.path &&
|
||||
event.properties.serverID === result.serverID
|
||||
) {
|
||||
log.info("got diagnostics", input)
|
||||
clearTimeout(timeout)
|
||||
unsub?.()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
log.info("timed out refreshing diagnostics", input)
|
||||
unsub?.()
|
||||
resolve()
|
||||
}, 5000)
|
||||
}),
|
||||
])
|
||||
3000,
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
unsub?.()
|
||||
})
|
||||
},
|
||||
async shutdown() {
|
||||
log.info("shutting down")
|
||||
log.info("shutting down", { serverID })
|
||||
connection.end()
|
||||
connection.dispose()
|
||||
server.process.kill("SIGKILL")
|
||||
server.process.kill("SIGTERM")
|
||||
log.info("shutdown", { serverID })
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -3,19 +3,36 @@ import { Log } from "../util/log"
|
||||
import { LSPClient } from "./client"
|
||||
import path from "path"
|
||||
import { LSPServer } from "./server"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
const state = App.state(
|
||||
"lsp",
|
||||
async () => {
|
||||
async (app) => {
|
||||
log.info("initializing")
|
||||
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 {
|
||||
clients,
|
||||
skip,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
@@ -25,35 +42,23 @@ export namespace LSP {
|
||||
},
|
||||
)
|
||||
|
||||
export async function init() {
|
||||
return state()
|
||||
}
|
||||
|
||||
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
|
||||
const extension = path.parse(input).ext
|
||||
const s = await state()
|
||||
const matches = LSPServer.All.filter((x) =>
|
||||
x.extensions.includes(extension),
|
||||
)
|
||||
for (const match of matches) {
|
||||
if (s.skip.has(match.id)) continue
|
||||
const existing = s.clients.get(match.id)
|
||||
if (existing) continue
|
||||
const handle = await match.spawn(App.info())
|
||||
if (!handle) {
|
||||
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
|
||||
})
|
||||
}
|
||||
const matches = Object.values(LSPServer)
|
||||
.filter((x) => x.extensions.includes(extension))
|
||||
.map((x) => x.id)
|
||||
await run(async (client) => {
|
||||
if (!matches.includes(client.serverID)) return
|
||||
const wait = waitForDiagnostics
|
||||
? client.waitForDiagnostics({ path: input })
|
||||
: Promise.resolve()
|
||||
await client.notify.open({ path: input })
|
||||
return wait
|
||||
})
|
||||
}
|
||||
|
||||
export async function diagnostics() {
|
||||
@@ -86,6 +91,14 @@ export namespace LSP {
|
||||
})
|
||||
}
|
||||
|
||||
export async function workspaceSymbol(query: string) {
|
||||
return run((client) =>
|
||||
client.connection.sendRequest("workspace/symbol", {
|
||||
query,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function run<T>(
|
||||
input: (client: LSPClient.Info) => Promise<T>,
|
||||
): Promise<T[]> {
|
||||
|
||||
@@ -63,6 +63,14 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
||||
".cshtml": "razor",
|
||||
".razor": "razor",
|
||||
".rb": "ruby",
|
||||
".rake": "ruby",
|
||||
".gemspec": "ruby",
|
||||
".ru": "ruby",
|
||||
".erb": "erb",
|
||||
".html.erb": "erb",
|
||||
".js.erb": "erb",
|
||||
".css.erb": "erb",
|
||||
".json.erb": "erb",
|
||||
".rs": "rust",
|
||||
".scss": "scss",
|
||||
".sass": "sass",
|
||||
|
||||
@@ -19,78 +19,128 @@ export namespace LSPServer {
|
||||
spawn(app: App.Info): Promise<Handle | undefined>
|
||||
}
|
||||
|
||||
export const All: Info[] = [
|
||||
{
|
||||
id: "typescript",
|
||||
extensions: [
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".mts",
|
||||
".cts",
|
||||
],
|
||||
async spawn(app) {
|
||||
const tsserver = await Bun.resolve(
|
||||
"typescript/lib/tsserver.js",
|
||||
app.path.cwd,
|
||||
).catch(() => {})
|
||||
if (!tsserver) return
|
||||
const proc = spawn(
|
||||
BunProc.which(),
|
||||
["x", "typescript-language-server", "--stdio"],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
export const Typescript: Info = {
|
||||
id: "typescript",
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
||||
async spawn(app) {
|
||||
const tsserver = await Bun.resolve(
|
||||
"typescript/lib/tsserver.js",
|
||||
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,
|
||||
initialization: {
|
||||
tsserver: {
|
||||
path: tsserver,
|
||||
},
|
||||
},
|
||||
)
|
||||
return {
|
||||
process: proc,
|
||||
initialization: {
|
||||
tsserver: {
|
||||
path: tsserver,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "golang",
|
||||
extensions: [".go"],
|
||||
async spawn() {
|
||||
let bin = Bun.which("gopls", {
|
||||
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||
}
|
||||
|
||||
export const Gopls: Info = {
|
||||
id: "golang",
|
||||
extensions: [".go"],
|
||||
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) {
|
||||
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",
|
||||
})
|
||||
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,
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("Failed to install gopls")
|
||||
return
|
||||
}
|
||||
return {
|
||||
process: spawn(bin!),
|
||||
}
|
||||
},
|
||||
bin = path.join(
|
||||
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>>
|
||||
} = {}
|
||||
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
|
||||
if (mcp.enabled === false) {
|
||||
log.info("mcp server disabled", { key })
|
||||
continue
|
||||
}
|
||||
log.info("found", { key, type: mcp.type })
|
||||
if (mcp.type === "remote") {
|
||||
const client = await experimental_createMCPClient({
|
||||
|
||||
@@ -10,7 +10,9 @@ export namespace ModelsDev {
|
||||
|
||||
export const Model = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
release_date: z.string(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean(),
|
||||
temperature: z.boolean(),
|
||||
@@ -25,7 +27,6 @@ export namespace ModelsDev {
|
||||
context: z.number(),
|
||||
output: z.number(),
|
||||
}),
|
||||
id: z.string(),
|
||||
options: z.record(z.any()),
|
||||
})
|
||||
.openapi({
|
||||
|
||||
@@ -11,8 +11,6 @@ import { WebFetchTool } from "../tool/webfetch"
|
||||
import { GlobTool } from "../tool/glob"
|
||||
import { GrepTool } from "../tool/grep"
|
||||
import { ListTool } from "../tool/ls"
|
||||
import { LspDiagnosticTool } from "../tool/lsp-diagnostics"
|
||||
import { LspHoverTool } from "../tool/lsp-hover"
|
||||
import { PatchTool } from "../tool/patch"
|
||||
import { ReadTool } from "../tool/read"
|
||||
import type { Tool } from "../tool/tool"
|
||||
@@ -23,6 +21,7 @@ import { AuthCopilot } from "../auth/copilot"
|
||||
import { ModelsDev } from "./models"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Auth } from "../auth"
|
||||
// import { TaskTool } from "../tool/task"
|
||||
|
||||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
@@ -140,10 +139,54 @@ export namespace Provider {
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
},
|
||||
async getModel(sdk: any, modelID: string) {
|
||||
if (modelID.includes("claude")) {
|
||||
const prefix = region.split("-")[0]
|
||||
modelID = `${prefix}.${modelID}`
|
||||
let regionPrefix = region.split("-")[0]
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -185,6 +228,7 @@ export namespace Provider {
|
||||
source,
|
||||
info,
|
||||
options,
|
||||
getModel,
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -202,6 +246,7 @@ export namespace Provider {
|
||||
npm: provider.npm ?? existing?.npm,
|
||||
name: provider.name ?? existing?.name ?? providerID,
|
||||
env: provider.env ?? existing?.env ?? [],
|
||||
api: provider.api ?? existing?.api,
|
||||
models: existing?.models ?? {},
|
||||
}
|
||||
|
||||
@@ -210,6 +255,7 @@ export namespace Provider {
|
||||
const parsedModel: ModelsDev.Model = {
|
||||
id: modelID,
|
||||
name: model.name ?? existing?.name ?? modelID,
|
||||
release_date: model.release_date ?? existing?.release_date,
|
||||
attachment: model.attachment ?? existing?.attachment ?? false,
|
||||
reasoning: model.reasoning ?? existing?.reasoning ?? false,
|
||||
temperature: model.temperature ?? existing?.temperature ?? false,
|
||||
@@ -243,9 +289,14 @@ export namespace Provider {
|
||||
// load env
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
if (disabled.has(providerID)) continue
|
||||
if (provider.env.some((item) => process.env[item])) {
|
||||
mergeProvider(providerID, {}, "env")
|
||||
}
|
||||
const apiKey = provider.env.map((item) => process.env[item]).at(0)
|
||||
if (!apiKey) continue
|
||||
mergeProvider(
|
||||
providerID,
|
||||
// only include apiKey if there's only one potential option
|
||||
provider.env.length === 1 ? { apiKey } : {},
|
||||
"env",
|
||||
)
|
||||
}
|
||||
|
||||
// load apikeys
|
||||
@@ -402,16 +453,15 @@ export namespace Provider {
|
||||
GlobTool,
|
||||
GrepTool,
|
||||
ListTool,
|
||||
LspDiagnosticTool,
|
||||
LspHoverTool,
|
||||
// LspDiagnosticTool,
|
||||
// LspHoverTool,
|
||||
PatchTool,
|
||||
ReadTool,
|
||||
EditTool,
|
||||
// MultiEditTool,
|
||||
WriteTool,
|
||||
TodoWriteTool,
|
||||
// TaskTool,
|
||||
TodoReadTool,
|
||||
// TaskTool,
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { App } from "../app/app"
|
||||
import { mapValues } from "remeda"
|
||||
import { NamedError } from "../util/error"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { Ripgrep } from "../external/ripgrep"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Config } from "../config/config"
|
||||
|
||||
const ERRORS = {
|
||||
|
||||
@@ -502,15 +502,6 @@ export namespace Session {
|
||||
}
|
||||
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) {
|
||||
log.error("callback error", err)
|
||||
switch (true) {
|
||||
@@ -547,7 +538,7 @@ export namespace Session {
|
||||
// return step
|
||||
// },
|
||||
toolCallStreaming: true,
|
||||
maxTokens: model.info.limit.output || undefined,
|
||||
maxTokens: Math.max(0, model.info.limit.output) || undefined,
|
||||
abortSignal: abort.signal,
|
||||
maxSteps: 1000,
|
||||
providerOptions: model.info.options,
|
||||
@@ -681,7 +672,7 @@ export namespace Session {
|
||||
value.usage,
|
||||
value.providerMetadata,
|
||||
)
|
||||
assistant.cost = usage.cost
|
||||
assistant.cost += usage.cost
|
||||
await updateMessage(next)
|
||||
if (value.finishReason === "length")
|
||||
throw new Message.OutputLengthError({})
|
||||
@@ -830,7 +821,7 @@ export namespace Session {
|
||||
async onFinish(input) {
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(model.info, input.usage, input.providerMetadata)
|
||||
assistant.cost = usage.cost
|
||||
assistant.cost += usage.cost
|
||||
assistant.tokens = usage.tokens
|
||||
next.metadata!.time.completed = Date.now()
|
||||
await updateMessage(next)
|
||||
@@ -882,8 +873,12 @@ export namespace Session {
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||
// @ts-expect-error
|
||||
metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
|
||||
0) as number,
|
||||
read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ??
|
||||
// @ts-expect-error
|
||||
metadata?.["bedrock"]?.["usage"]?.["cacheReadInputTokens"] ??
|
||||
0) as number,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -188,6 +188,11 @@ export namespace Message {
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
user: z
|
||||
.object({
|
||||
snapshot: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.openapi({ ref: "MessageMetadata" }),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App } from "../app/app"
|
||||
import { Ripgrep } from "../external/ripgrep"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import path from "path"
|
||||
@@ -27,55 +27,6 @@ export namespace SystemPrompt {
|
||||
|
||||
export async function environment() {
|
||||
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 [
|
||||
[
|
||||
`Here is some useful information about the environment you are running in:`,
|
||||
@@ -85,9 +36,16 @@ export namespace SystemPrompt {
|
||||
` Platform: ${process.platform}`,
|
||||
` Today's date: ${new Date().toDateString()}`,
|
||||
`</env>`,
|
||||
// `<project>`,
|
||||
// ` ${app.git ? await tree() : ""}`,
|
||||
// `</project>`,
|
||||
`<project>`,
|
||||
` ${
|
||||
app.git
|
||||
? await Ripgrep.tree({
|
||||
cwd: app.path.cwd,
|
||||
limit: 200,
|
||||
})
|
||||
: ""
|
||||
}`,
|
||||
`</project>`,
|
||||
].join("\n"),
|
||||
]
|
||||
}
|
||||
|
||||
85
packages/opencode/src/snapshot/index.ts
Normal file
85
packages/opencode/src/snapshot/index.ts
Normal file
@@ -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 { Tool } from "./tool"
|
||||
import DESCRIPTION from "./bash.txt"
|
||||
import { App } from "../app/app"
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 30000
|
||||
const BANNED_COMMANDS = [
|
||||
@@ -49,6 +50,7 @@ export const BashTool = Tool.define({
|
||||
|
||||
const process = Bun.spawn({
|
||||
cmd: ["bash", "-c", params.command],
|
||||
cwd: App.info().path.cwd,
|
||||
maxBuffer: MAX_OUTPUT_LENGTH,
|
||||
signal: ctx.abort,
|
||||
timeout: timeout,
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { App } from "../app/app"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import { Ripgrep } from "../external/ripgrep"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
|
||||
export const GlobTool = Tool.define({
|
||||
id: "glob",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { App } from "../app/app"
|
||||
import { Ripgrep } from "../external/ripgrep"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export const ReadTool = Tool.define({
|
||||
output += "\n</file>"
|
||||
|
||||
// just warms the lsp client
|
||||
await LSP.touchFile(filePath, true)
|
||||
await LSP.touchFile(filePath, false)
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
|
||||
return {
|
||||
|
||||
14
packages/opencode/src/util/timeout.ts
Normal file
14
packages/opencode/src/util/timeout.ts
Normal file
@@ -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)
|
||||
}),
|
||||
])
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/sst/opencode-sdk-go/option"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/tui"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
@@ -78,15 +77,15 @@ func main() {
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
evts, err := client.Event(httpClient, url, ctx)
|
||||
if err != nil {
|
||||
slog.Error("Failed to subscribe to events", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for item := range evts {
|
||||
program.Send(item)
|
||||
stream := httpClient.Event.ListStreaming(ctx)
|
||||
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)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/oapi-codegen/runtime v1.1.1
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.5
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.7
|
||||
github.com/tidwall/gjson v1.14.4
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
@@ -25,7 +24,6 @@ require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
@@ -37,7 +35,6 @@ require (
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/goccy/go-yaml v1.17.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/invopop/yaml v0.3.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
|
||||
@@ -4,15 +4,12 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
|
||||
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
|
||||
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
@@ -23,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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
@@ -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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -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/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
|
||||
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
|
||||
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
@@ -190,12 +181,10 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.5 h1:iZjdSHLo6jOMjUbDH5JWi+44v76yNbEktsRqG/Qxrco=
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.5/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.7 h1:trfzTMn9o/h2fxE4z+BtJPZvCTdVHjwgXnAH/rTAx0I=
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.7/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
)
|
||||
|
||||
var RootPath string
|
||||
var CwdPath string
|
||||
|
||||
type App struct {
|
||||
Info opencode.App
|
||||
@@ -61,6 +62,7 @@ func New(
|
||||
httpClient *opencode.Client,
|
||||
) (*App, error) {
|
||||
RootPath = appInfo.Path.Root
|
||||
CwdPath = appInfo.Path.Cwd
|
||||
|
||||
configInfo, err := httpClient.Config.Get(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -21,9 +21,10 @@ import (
|
||||
|
||||
type EditorComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
layout.Sizeable
|
||||
Content() string
|
||||
// tea.ViewModel
|
||||
SetSize(width, height int) tea.Cmd
|
||||
View(width int, align lipgloss.Position) string
|
||||
Content(width int, align lipgloss.Position) string
|
||||
Lines() int
|
||||
Value() string
|
||||
Focused() bool
|
||||
@@ -105,7 +106,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Content() string {
|
||||
func (m *editorComponent) Content(width int, align lipgloss.Position) string {
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||
@@ -121,7 +122,7 @@ func (m *editorComponent) Content() string {
|
||||
)
|
||||
textarea = styles.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Width(m.width).
|
||||
Width(width).
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
@@ -156,11 +157,19 @@ func (m *editorComponent) Content() string {
|
||||
return content
|
||||
}
|
||||
|
||||
func (m *editorComponent) View() string {
|
||||
func (m *editorComponent) View(width int, align lipgloss.Position) string {
|
||||
if m.Lines() > 1 {
|
||||
return ""
|
||||
t := theme.CurrentTheme()
|
||||
return lipgloss.Place(
|
||||
width,
|
||||
m.height,
|
||||
align,
|
||||
lipgloss.Center,
|
||||
"",
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
}
|
||||
return m.Content()
|
||||
return m.Content(width, align)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Focused() bool {
|
||||
@@ -335,7 +344,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
||||
ta.SetHeight(existing.Height())
|
||||
}
|
||||
|
||||
// ta.Focus()
|
||||
return ta
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,13 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
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/components/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
@@ -22,6 +18,8 @@ import (
|
||||
type MessagesComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
// View(width int) string
|
||||
SetSize(width, height int) tea.Cmd
|
||||
PageUp() (tea.Model, tea.Cmd)
|
||||
PageDown() (tea.Model, tea.Cmd)
|
||||
HalfPageUp() (tea.Model, tea.Cmd)
|
||||
@@ -37,9 +35,7 @@ type messagesComponent struct {
|
||||
width, height int
|
||||
app *app.App
|
||||
viewport viewport.Model
|
||||
spinner spinner.Model
|
||||
attachments viewport.Model
|
||||
commands commands.CommandsComponent
|
||||
cache *MessageCache
|
||||
rendering bool
|
||||
showToolDetails bool
|
||||
@@ -49,7 +45,7 @@ type renderFinishedMsg struct{}
|
||||
type ToggleToolDetailsMsg struct{}
|
||||
|
||||
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) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -96,27 +92,9 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.tail = m.viewport.AtBottom()
|
||||
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...)
|
||||
}
|
||||
|
||||
type blockType int
|
||||
|
||||
const (
|
||||
none blockType = iota
|
||||
userTextBlock
|
||||
assistantTextBlock
|
||||
toolInvocationBlock
|
||||
errorBlock
|
||||
)
|
||||
|
||||
func (m *messagesComponent) renderView() {
|
||||
if m.width == 0 {
|
||||
return
|
||||
@@ -126,144 +104,157 @@ func (m *messagesComponent) renderView() {
|
||||
defer measure("messageCount", len(m.app.Messages))
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
blocks := make([]string, 0)
|
||||
previousBlockType := none
|
||||
|
||||
for _, message := range m.app.Messages {
|
||||
align := lipgloss.Center
|
||||
width := layout.Current.Container.Width
|
||||
|
||||
sb := strings.Builder{}
|
||||
util.WriteStringsPar(&sb, m.app.Messages, func(message opencode.Message) string {
|
||||
var content string
|
||||
var cached bool
|
||||
lastToolIndex := 0
|
||||
lastToolIndices := []int{}
|
||||
for i, p := range message.Parts {
|
||||
switch p.Type {
|
||||
case opencode.MessagePartTypeText:
|
||||
lastToolIndices = append(lastToolIndices, lastToolIndex)
|
||||
case opencode.MessagePartTypeToolInvocation:
|
||||
lastToolIndex = i
|
||||
}
|
||||
}
|
||||
blocks := make([]string, 0)
|
||||
|
||||
author := ""
|
||||
switch message.Role {
|
||||
case opencode.MessageRoleUser:
|
||||
author = m.app.Info.User
|
||||
case opencode.MessageRoleAssistant:
|
||||
author = message.Metadata.Assistant.ModelID
|
||||
}
|
||||
|
||||
for i, p := range message.Parts {
|
||||
switch part := p.AsUnion().(type) {
|
||||
// case client.MessagePartStepStart:
|
||||
// messages = append(messages, "")
|
||||
case opencode.TextPart:
|
||||
key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderText(message, p.Text, author)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
if previousBlockType != none {
|
||||
blocks = append(blocks, "")
|
||||
}
|
||||
blocks = append(blocks, content)
|
||||
if message.Role == opencode.MessageRoleUser {
|
||||
previousBlockType = userTextBlock
|
||||
} else if message.Role == opencode.MessageRoleAssistant {
|
||||
previousBlockType = assistantTextBlock
|
||||
}
|
||||
case opencode.ToolInvocationPart:
|
||||
isLastToolInvocation := slices.Contains(lastToolIndices, i)
|
||||
metadata := opencode.MessageMetadataTool{}
|
||||
|
||||
toolCallID := part.ToolInvocation.ToolCallID
|
||||
// var toolCallID string
|
||||
// var result *string
|
||||
// switch toolCall := part.ToolInvocation.AsUnion().(type) {
|
||||
// case opencode.ToolCall:
|
||||
// toolCallID = toolCall.ToolCallID
|
||||
// case opencode.ToolPartialCall:
|
||||
// toolCallID = toolCall.ToolCallID
|
||||
// case opencode.ToolResult:
|
||||
// toolCallID = toolCall.ToolCallID
|
||||
// result = &toolCall.Result
|
||||
// }
|
||||
|
||||
if _, ok := message.Metadata.Tool[toolCallID]; ok {
|
||||
metadata = message.Metadata.Tool[toolCallID]
|
||||
}
|
||||
|
||||
var result *string
|
||||
if part.ToolInvocation.Result != "" {
|
||||
result = &part.ToolInvocation.Result
|
||||
}
|
||||
|
||||
if part.ToolInvocation.State == "result" {
|
||||
key := m.cache.GenerateKey(message.ID,
|
||||
part.ToolInvocation.ToolCallID,
|
||||
m.showToolDetails,
|
||||
layout.Current.Viewport.Width,
|
||||
)
|
||||
for _, part := range message.Parts {
|
||||
switch part := part.AsUnion().(type) {
|
||||
case opencode.TextPart:
|
||||
key := m.cache.GenerateKey(message.ID, part.Text, layout.Current.Viewport.Width)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderToolInvocation(
|
||||
part,
|
||||
result,
|
||||
metadata,
|
||||
content = renderText(
|
||||
message,
|
||||
part.Text,
|
||||
m.app.Info.User,
|
||||
m.showToolDetails,
|
||||
isLastToolInvocation,
|
||||
false,
|
||||
message.Metadata,
|
||||
width,
|
||||
align,
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
} else {
|
||||
// if the tool call isn't finished, don't cache
|
||||
content = renderToolInvocation(
|
||||
part,
|
||||
result,
|
||||
metadata,
|
||||
m.showToolDetails,
|
||||
isLastToolInvocation,
|
||||
false,
|
||||
message.Metadata,
|
||||
)
|
||||
if content != "" {
|
||||
blocks = append(blocks, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if previousBlockType != toolInvocationBlock && m.showToolDetails {
|
||||
blocks = append(blocks, "")
|
||||
case opencode.MessageRoleAssistant:
|
||||
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, layout.Current.Viewport.Width, m.showToolDetails)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderText(
|
||||
message,
|
||||
p.Text,
|
||||
message.Metadata.Assistant.ModelID,
|
||||
m.showToolDetails,
|
||||
width,
|
||||
align,
|
||||
toolCallParts...,
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
} else {
|
||||
content = renderText(
|
||||
message,
|
||||
p.Text,
|
||||
message.Metadata.Assistant.ModelID,
|
||||
m.showToolDetails,
|
||||
width,
|
||||
align,
|
||||
toolCallParts...,
|
||||
)
|
||||
}
|
||||
if content != "" {
|
||||
blocks = append(blocks, content)
|
||||
}
|
||||
case opencode.ToolInvocationPart:
|
||||
if !m.showToolDetails {
|
||||
continue
|
||||
}
|
||||
|
||||
if part.ToolInvocation.State == "result" {
|
||||
key := m.cache.GenerateKey(message.ID,
|
||||
part.ToolInvocation.ToolCallID,
|
||||
m.showToolDetails,
|
||||
layout.Current.Viewport.Width,
|
||||
)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderToolDetails(
|
||||
part,
|
||||
message.Metadata,
|
||||
width,
|
||||
align,
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
} else {
|
||||
// if the tool call isn't finished, don't cache
|
||||
content = renderToolDetails(
|
||||
part,
|
||||
message.Metadata,
|
||||
width,
|
||||
align,
|
||||
)
|
||||
}
|
||||
if content != "" {
|
||||
blocks = append(blocks, content)
|
||||
}
|
||||
}
|
||||
blocks = append(blocks, content)
|
||||
previousBlockType = toolInvocationBlock
|
||||
}
|
||||
}
|
||||
|
||||
error := ""
|
||||
switch err := message.Metadata.Error.AsUnion().(type) {
|
||||
case nil:
|
||||
default:
|
||||
clientError := err.(opencode.UnknownError)
|
||||
error = clientError.Data.Message
|
||||
case opencode.MessageMetadataErrorMessageOutputLengthError:
|
||||
error = "Message output length exceeded"
|
||||
case opencode.ProviderAuthError:
|
||||
error = err.Data.Message
|
||||
case opencode.UnknownError:
|
||||
error = err.Data.Message
|
||||
}
|
||||
|
||||
if error != "" {
|
||||
error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginTop(1), WithMarginBottom(1))
|
||||
error = renderContentBlock(
|
||||
error,
|
||||
width,
|
||||
align,
|
||||
WithBorderColor(t.Error()),
|
||||
)
|
||||
blocks = append(blocks, error)
|
||||
previousBlockType = errorBlock
|
||||
}
|
||||
}
|
||||
|
||||
centered := []string{}
|
||||
for _, block := range blocks {
|
||||
centered = append(centered, lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
block,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
))
|
||||
}
|
||||
return strings.Join(blocks, "\n\n")
|
||||
})
|
||||
|
||||
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()))
|
||||
m.viewport.SetContent("\n" + strings.Join(centered, "\n") + "\n")
|
||||
content := sb.String()
|
||||
|
||||
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1)
|
||||
m.viewport.SetContent("\n" + content)
|
||||
}
|
||||
|
||||
func (m *messagesComponent) header() string {
|
||||
@@ -300,93 +291,26 @@ func (m *messagesComponent) header() string {
|
||||
}
|
||||
|
||||
func (m *messagesComponent) View() string {
|
||||
if len(m.app.Messages) == 0 {
|
||||
return m.home()
|
||||
}
|
||||
t := theme.CurrentTheme()
|
||||
if m.rendering {
|
||||
return lipgloss.Place(
|
||||
m.width,
|
||||
m.height,
|
||||
m.height+1,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
"Loading session...",
|
||||
styles.NewStyle().Background(t.Background()).Render("Loading session..."),
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
}
|
||||
t := theme.CurrentTheme()
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
m.header(),
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
),
|
||||
m.viewport.View(),
|
||||
header := lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
m.header(),
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesComponent) home() string {
|
||||
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()).
|
||||
return styles.NewStyle().
|
||||
Background(t.Background()).
|
||||
Width(lipgloss.Width(logo)).
|
||||
Align(lipgloss.Right)
|
||||
version := versionStyle.Render(m.app.Version)
|
||||
|
||||
logoAndVersion := strings.Join([]string{logo, version}, "\n")
|
||||
logoAndVersion = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
logoAndVersion,
|
||||
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()),
|
||||
)
|
||||
Render(header + "\n" + m.viewport.View())
|
||||
}
|
||||
|
||||
func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
|
||||
@@ -403,7 +327,6 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
|
||||
m.viewport.SetHeight(height - lipgloss.Height(m.header()))
|
||||
m.attachments.SetWidth(width + 40)
|
||||
m.attachments.SetHeight(3)
|
||||
m.commands.SetSize(width, height)
|
||||
m.renderView()
|
||||
return nil
|
||||
}
|
||||
@@ -457,29 +380,14 @@ func (m *messagesComponent) ToolDetailsVisible() bool {
|
||||
}
|
||||
|
||||
func NewMessagesComponent(app *app.App) MessagesComponent {
|
||||
customSpinner := spinner.Spinner{
|
||||
Frames: []string{" ", "┃", "┃"},
|
||||
FPS: time.Second / 3,
|
||||
}
|
||||
s := spinner.New(spinner.WithSpinner(customSpinner))
|
||||
|
||||
vp := viewport.New()
|
||||
attachments := viewport.New()
|
||||
vp.KeyMap = viewport.KeyMap{}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
commandsView := commands.New(
|
||||
app,
|
||||
commands.WithBackground(t.Background()),
|
||||
commands.WithLimit(6),
|
||||
)
|
||||
|
||||
return &messagesComponent{
|
||||
app: app,
|
||||
viewport: vp,
|
||||
spinner: s,
|
||||
attachments: attachments,
|
||||
commands: commandsView,
|
||||
showToolDetails: true,
|
||||
cache: NewMessageCache(),
|
||||
tail: true,
|
||||
|
||||
@@ -9,15 +9,13 @@ import (
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type CommandsComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
layout.Sizeable
|
||||
SetSize(width, height int) tea.Cmd
|
||||
SetBackgroundColor(color compat.AdaptiveColor)
|
||||
}
|
||||
|
||||
@@ -44,19 +42,6 @@ func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
|
||||
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 {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
|
||||
@@ -20,10 +20,7 @@ type helpDialog struct {
|
||||
}
|
||||
|
||||
func (h *helpDialog) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
h.commandsComponent.Init(),
|
||||
h.viewport.Init(),
|
||||
)
|
||||
return h.viewport.Init()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Update commands component first to get the latest content
|
||||
_, cmdCmd := h.commandsComponent.Update(msg)
|
||||
cmds = append(cmds, cmdCmd)
|
||||
|
||||
// Update viewport content
|
||||
h.viewport.SetContent(h.commandsComponent.View())
|
||||
|
||||
|
||||
@@ -3,13 +3,11 @@ package dialog
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
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/components/list"
|
||||
@@ -21,8 +19,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
numVisibleModels = 6
|
||||
maxDialogWidth = 40
|
||||
numVisibleModels = 10
|
||||
minDialogWidth = 40
|
||||
maxDialogWidth = 80
|
||||
)
|
||||
|
||||
// ModelDialog interface for the model selection dialog
|
||||
@@ -31,33 +30,61 @@ type ModelDialog interface {
|
||||
}
|
||||
|
||||
type modelDialog struct {
|
||||
app *app.App
|
||||
availableProviders []opencode.Provider
|
||||
provider opencode.Provider
|
||||
width int
|
||||
height int
|
||||
hScrollOffset int
|
||||
hScrollPossible bool
|
||||
modal *modal.Modal
|
||||
modelList list.List[list.StringItem]
|
||||
app *app.App
|
||||
allModels []ModelWithProvider
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
modelList list.List[ModelItem]
|
||||
dialogWidth int
|
||||
}
|
||||
|
||||
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 {
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
}
|
||||
|
||||
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(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select model"),
|
||||
@@ -69,7 +96,7 @@ var modelKeys = modelKeyMap{
|
||||
}
|
||||
|
||||
func (m *modelDialog) Init() tea.Cmd {
|
||||
m.setupModelsForProvider(m.provider.ID)
|
||||
m.setupAllModels()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -77,34 +104,20 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
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):
|
||||
selectedItem, _ := m.modelList.GetSelectedItem()
|
||||
models := m.models()
|
||||
var selectedModel opencode.Model
|
||||
for _, model := range models {
|
||||
if model.Name == string(selectedItem) {
|
||||
selectedModel = model
|
||||
break
|
||||
}
|
||||
_, selectedIndex := m.modelList.GetSelectedItem()
|
||||
if selectedIndex >= 0 && selectedIndex < len(m.allModels) {
|
||||
selectedModel := m.allModels[selectedIndex]
|
||||
return m, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(
|
||||
app.ModelSelectedMsg{
|
||||
Provider: selectedModel.Provider,
|
||||
Model: selectedModel.Model,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return m, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(
|
||||
app.ModelSelectedMsg{
|
||||
Provider: m.provider,
|
||||
Model: selectedModel,
|
||||
}),
|
||||
)
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
case key.Matches(msg, modelKeys.Escape):
|
||||
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
|
||||
updatedList, cmd := m.modelList.Update(msg)
|
||||
m.modelList = updatedList.(list.List[list.StringItem])
|
||||
m.modelList = updatedList.(list.List[ModelItem])
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *modelDialog) models() []opencode.Model {
|
||||
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b opencode.Model) 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 {
|
||||
listView := m.modelList.View()
|
||||
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
|
||||
return strings.Join([]string{listView, scrollIndicator}, "\n")
|
||||
return m.modelList.View()
|
||||
}
|
||||
|
||||
func (m *modelDialog) getScrollIndicators(maxWidth int) string {
|
||||
var indicator string
|
||||
if m.hScrollPossible {
|
||||
indicator = "← → (switch provider) "
|
||||
}
|
||||
if indicator == "" {
|
||||
return ""
|
||||
}
|
||||
func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
|
||||
maxWidth := minDialogWidth
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
return styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Width(maxWidth).
|
||||
Align(lipgloss.Right).
|
||||
Render(indicator)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
for _, item := range modelItems {
|
||||
// Calculate the width needed for this item: "ModelName (ProviderName)"
|
||||
// Add 4 for the parentheses, space, and some padding
|
||||
itemWidth := len(item.ModelName) + len(item.ProviderName) + 4
|
||||
if itemWidth > maxWidth {
|
||||
maxWidth = itemWidth
|
||||
}
|
||||
}
|
||||
|
||||
if maxWidth > maxDialogWidth {
|
||||
maxWidth = maxDialogWidth
|
||||
}
|
||||
|
||||
return maxWidth
|
||||
}
|
||||
|
||||
func (m *modelDialog) setupAllModels() {
|
||||
providers, _ := m.app.ListProviders(context.Background())
|
||||
|
||||
m.allModels = make([]ModelWithProvider, 0)
|
||||
for _, provider := range providers {
|
||||
for _, model := range provider.Models {
|
||||
m.allModels = append(m.allModels, ModelWithProvider{
|
||||
Model: model,
|
||||
Provider: provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
m.sortModels()
|
||||
|
||||
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 {
|
||||
@@ -194,32 +257,16 @@ func (s *modelDialog) Close() tea.Cmd {
|
||||
}
|
||||
|
||||
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{
|
||||
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),
|
||||
),
|
||||
app: app,
|
||||
}
|
||||
|
||||
dialog.setupModelsForProvider(currentProvider.ID)
|
||||
dialog.setupAllModels()
|
||||
|
||||
dialog.modal = modal.New(
|
||||
modal.WithTitle("Select Model"),
|
||||
modal.WithMaxWidth(dialog.dialogWidth+4),
|
||||
)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/color"
|
||||
@@ -148,101 +149,87 @@ func WithWidth(width int) UnifiedOption {
|
||||
func ParseUnifiedDiff(diff string) (DiffResult, error) {
|
||||
var result DiffResult
|
||||
var currentHunk *Hunk
|
||||
result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
|
||||
|
||||
hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
|
||||
lines := strings.Split(diff, "\n")
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(diff))
|
||||
var oldLine, newLine int
|
||||
inFileHeader := true
|
||||
|
||||
for _, line := range lines {
|
||||
// Parse file headers
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if inFileHeader {
|
||||
if strings.HasPrefix(line, "--- a/") {
|
||||
result.OldFile = strings.TrimPrefix(line, "--- a/")
|
||||
result.OldFile = line[6:]
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "+++ b/") {
|
||||
result.NewFile = strings.TrimPrefix(line, "+++ b/")
|
||||
result.NewFile = line[6:]
|
||||
inFileHeader = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Parse hunk headers
|
||||
if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
if currentHunk != nil {
|
||||
result.Hunks = append(result.Hunks, *currentHunk)
|
||||
}
|
||||
currentHunk = &Hunk{
|
||||
Header: line,
|
||||
Lines: []DiffLine{},
|
||||
Lines: make([]DiffLine, 0, 10), // Pre-allocate
|
||||
}
|
||||
|
||||
oldStart, _ := strconv.Atoi(matches[1])
|
||||
newStart, _ := strconv.Atoi(matches[3])
|
||||
oldLine = oldStart
|
||||
newLine = newStart
|
||||
// Manual parsing of hunk header is faster than regex
|
||||
parts := strings.Split(line, " ")
|
||||
if len(parts) > 2 {
|
||||
oldRange := strings.Split(parts[1][1:], ",")
|
||||
newRange := strings.Split(parts[2][1:], ",")
|
||||
oldLine, _ = strconv.Atoi(oldRange[0])
|
||||
newLine, _ = strconv.Atoi(newRange[0])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore "No newline at end of file" markers
|
||||
if strings.HasPrefix(line, "\\ No newline at end of file") {
|
||||
if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentHunk == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process the line based on its prefix
|
||||
var dl DiffLine
|
||||
dl.Content = line
|
||||
if len(line) > 0 {
|
||||
switch line[0] {
|
||||
case '+':
|
||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
||||
OldLineNo: 0,
|
||||
NewLineNo: newLine,
|
||||
Kind: LineAdded,
|
||||
Content: line[1:],
|
||||
})
|
||||
dl.Kind = LineAdded
|
||||
dl.NewLineNo = newLine
|
||||
dl.Content = line[1:]
|
||||
newLine++
|
||||
case '-':
|
||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
||||
OldLineNo: oldLine,
|
||||
NewLineNo: 0,
|
||||
Kind: LineRemoved,
|
||||
Content: line[1:],
|
||||
})
|
||||
dl.Kind = LineRemoved
|
||||
dl.OldLineNo = oldLine
|
||||
dl.Content = line[1:]
|
||||
oldLine++
|
||||
default:
|
||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
||||
OldLineNo: oldLine,
|
||||
NewLineNo: newLine,
|
||||
Kind: LineContext,
|
||||
Content: line,
|
||||
})
|
||||
default: // context line
|
||||
dl.Kind = LineContext
|
||||
dl.OldLineNo = oldLine
|
||||
dl.NewLineNo = newLine
|
||||
oldLine++
|
||||
newLine++
|
||||
}
|
||||
} else {
|
||||
// Handle empty lines
|
||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
||||
OldLineNo: oldLine,
|
||||
NewLineNo: newLine,
|
||||
Kind: LineContext,
|
||||
Content: "",
|
||||
})
|
||||
} else { // empty context line
|
||||
dl.Kind = LineContext
|
||||
dl.OldLineNo = oldLine
|
||||
dl.NewLineNo = newLine
|
||||
oldLine++
|
||||
newLine++
|
||||
}
|
||||
currentHunk.Lines = append(currentHunk.Lines, dl)
|
||||
}
|
||||
|
||||
// Add the last hunk if there is one
|
||||
if currentHunk != nil {
|
||||
result.Hunks = append(result.Hunks, *currentHunk)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return result, scanner.Err()
|
||||
}
|
||||
|
||||
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
|
||||
@@ -744,8 +731,6 @@ func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, high
|
||||
content,
|
||||
width,
|
||||
"...",
|
||||
// stylesi.NewStyleWithColors(t.TextMuted(), bgStyle.GetBackground()).Render("..."),
|
||||
// stylesi.WithForeground(stylesi.NewStyle().Background(bgStyle.GetBackground()), t.TextMuted()).Render("..."),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -912,10 +897,11 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
|
||||
HighlightIntralineChanges(&hunkCopy)
|
||||
|
||||
var sb strings.Builder
|
||||
for _, line := range hunkCopy.Lines {
|
||||
sb.WriteString(renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.Grow(len(hunkCopy.Lines) * config.Width)
|
||||
|
||||
util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
|
||||
return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
|
||||
})
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -969,32 +955,22 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, h := range diffResult.Hunks {
|
||||
unifiedDiff := RenderUnifiedHunk(filename, h, opts...)
|
||||
sb.WriteString(unifiedDiff)
|
||||
}
|
||||
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
|
||||
return RenderUnifiedHunk(filename, h, opts...)
|
||||
})
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// FormatDiff creates a side-by-side formatted view of a diff
|
||||
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
|
||||
// t := theme.CurrentTheme()
|
||||
diffResult, err := ParseUnifiedDiff(diffText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
// config := NewSideBySideConfig(opts...)
|
||||
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
|
||||
// sb.WriteString(
|
||||
// lipgloss.NewStyle().
|
||||
// Background(t.DiffHunkHeader()).
|
||||
// Foreground(t.Background()).
|
||||
// Width(config.TotalWidth).
|
||||
// Render(h.Header) + "\n",
|
||||
// )
|
||||
return RenderSideBySideHunk(filename, h, opts...)
|
||||
})
|
||||
|
||||
|
||||
@@ -100,16 +100,18 @@ func (m statusComponent) View() string {
|
||||
contextWindow := m.app.Model.Limit.Context
|
||||
|
||||
for _, message := range m.app.Messages {
|
||||
if message.Metadata.Assistant.Cost > 0 {
|
||||
cost += message.Metadata.Assistant.Cost
|
||||
usage := message.Metadata.Assistant.Tokens
|
||||
if usage.Output > 0 {
|
||||
tokens = (usage.Input +
|
||||
usage.Cache.Write +
|
||||
usage.Cache.Read +
|
||||
usage.Output +
|
||||
usage.Reasoning)
|
||||
cost += message.Metadata.Assistant.Cost
|
||||
usage := message.Metadata.Assistant.Tokens
|
||||
if usage.Output > 0 {
|
||||
if message.Metadata.Assistant.Summary {
|
||||
tokens = usage.Output
|
||||
continue
|
||||
}
|
||||
tokens = (usage.Input +
|
||||
usage.Cache.Write +
|
||||
usage.Cache.Read +
|
||||
usage.Output +
|
||||
usage.Reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,19 +5,56 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type ModelUsage struct {
|
||||
ProviderID string `toml:"provider_id"`
|
||||
ModelID string `toml:"model_id"`
|
||||
LastUsed time.Time `toml:"last_used"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Theme string `toml:"theme"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
Theme string `toml:"theme"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
|
||||
}
|
||||
|
||||
func NewState() *State {
|
||||
return &State{
|
||||
Theme: "opencode",
|
||||
Theme: "opencode",
|
||||
RecentlyUsedModels: make([]ModelUsage, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateModelUsage updates the recently used models list with the specified model
|
||||
func (s *State) UpdateModelUsage(providerID, modelID string) {
|
||||
now := time.Now()
|
||||
|
||||
// Check if this model is already in the list
|
||||
for i, usage := range s.RecentlyUsedModels {
|
||||
if usage.ProviderID == providerID && usage.ModelID == modelID {
|
||||
s.RecentlyUsedModels[i].LastUsed = now
|
||||
usage := s.RecentlyUsedModels[i]
|
||||
copy(s.RecentlyUsedModels[1:i+1], s.RecentlyUsedModels[0:i])
|
||||
s.RecentlyUsedModels[0] = usage
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newUsage := ModelUsage{
|
||||
ProviderID: providerID,
|
||||
ModelID: modelID,
|
||||
LastUsed: now,
|
||||
}
|
||||
|
||||
// Prepend to slice and limit to last 50 entries
|
||||
s.RecentlyUsedModels = append([]ModelUsage{newUsage}, s.RecentlyUsedModels...)
|
||||
if len(s.RecentlyUsedModels) > 50 {
|
||||
s.RecentlyUsedModels = s.RecentlyUsedModels[:50]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,254 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type FlexDirection int
|
||||
type Direction int
|
||||
|
||||
const (
|
||||
FlexDirectionHorizontal FlexDirection = iota
|
||||
FlexDirectionVertical
|
||||
Row Direction = iota
|
||||
Column
|
||||
)
|
||||
|
||||
type FlexChildSize struct {
|
||||
Fixed bool
|
||||
Size int
|
||||
type Justify int
|
||||
|
||||
const (
|
||||
JustifyStart Justify = iota
|
||||
JustifyEnd
|
||||
JustifyCenter
|
||||
JustifySpaceBetween
|
||||
JustifySpaceAround
|
||||
)
|
||||
|
||||
type Align int
|
||||
|
||||
const (
|
||||
AlignStart Align = iota
|
||||
AlignEnd
|
||||
AlignCenter
|
||||
AlignStretch // Only applicable in the cross-axis
|
||||
)
|
||||
|
||||
type FlexOptions struct {
|
||||
Direction Direction
|
||||
Justify Justify
|
||||
Align Align
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
var FlexChildSizeGrow = FlexChildSize{Fixed: false}
|
||||
|
||||
func FlexChildSizeFixed(size int) FlexChildSize {
|
||||
return FlexChildSize{Fixed: true, Size: size}
|
||||
type FlexItem struct {
|
||||
View string
|
||||
FixedSize int // Fixed size in the main axis (width for Row, height for Column)
|
||||
Grow bool // If true, the item will grow to fill available space
|
||||
}
|
||||
|
||||
type FlexLayout interface {
|
||||
tea.ViewModel
|
||||
Sizeable
|
||||
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 {
|
||||
// Render lays out a series of view strings based on flexbox-like rules.
|
||||
func Render(opts FlexOptions, items ...FlexItem) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
views := make([]string, 0, len(f.children))
|
||||
for i, child := range f.children {
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
// Calculate dimensions for each item
|
||||
mainAxisSize := opts.Width
|
||||
crossAxisSize := opts.Height
|
||||
if opts.Direction == Column {
|
||||
mainAxisSize = opts.Height
|
||||
crossAxisSize = opts.Width
|
||||
}
|
||||
|
||||
alignment := lipgloss.Center
|
||||
if alignable, ok := child.(Alignable); ok {
|
||||
alignment = alignable.Alignment()
|
||||
// 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)
|
||||
view := lipgloss.PlaceHorizontal(
|
||||
childWidth,
|
||||
alignment,
|
||||
child.View(),
|
||||
// TODO: make configurable WithBackgroundStyle
|
||||
lipgloss.WithWhitespaceStyle(styles.NewStyle().Background(t.Background()).Lipgloss()),
|
||||
)
|
||||
views = append(views, view)
|
||||
}
|
||||
|
||||
// Calculate available space for grow items
|
||||
availableSpace := max(mainAxisSize-totalFixedSize, 0)
|
||||
|
||||
// Calculate size for each grow item
|
||||
growItemSize := 0
|
||||
if growCount > 0 && availableSpace > 0 {
|
||||
growItemSize = availableSpace / growCount
|
||||
}
|
||||
|
||||
// Prepare sized views
|
||||
sizedViews := make([]string, len(items))
|
||||
actualSizes := make([]int, len(items))
|
||||
|
||||
for i, item := range items {
|
||||
view := item.View
|
||||
|
||||
// Determine the size for this item
|
||||
itemSize := 0
|
||||
if item.FixedSize > 0 {
|
||||
itemSize = item.FixedSize
|
||||
} else if item.Grow && growItemSize > 0 {
|
||||
itemSize = growItemSize
|
||||
} else {
|
||||
childWidth, childHeight = f.calculateChildSize(i)
|
||||
view := lipgloss.Place(
|
||||
f.width,
|
||||
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
|
||||
// No fixed size and not growing - use natural size
|
||||
if opts.Direction == Row {
|
||||
itemSize = lipgloss.Width(view)
|
||||
} else {
|
||||
totalFixed += f.sizes[i].Size
|
||||
itemSize = lipgloss.Height(view)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply size constraints
|
||||
if opts.Direction == Row {
|
||||
// For row direction, constrain width and handle height alignment
|
||||
if itemSize > 0 {
|
||||
view = styles.NewStyle().
|
||||
Width(itemSize).
|
||||
Height(crossAxisSize).
|
||||
Render(view)
|
||||
}
|
||||
|
||||
// Apply cross-axis alignment
|
||||
switch opts.Align {
|
||||
case AlignCenter:
|
||||
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view)
|
||||
case AlignEnd:
|
||||
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view)
|
||||
case AlignStart:
|
||||
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view)
|
||||
case AlignStretch:
|
||||
// Already stretched by Height setting above
|
||||
}
|
||||
} else {
|
||||
flexCount++
|
||||
// For column direction, constrain height and handle width alignment
|
||||
if itemSize > 0 {
|
||||
view = styles.NewStyle().
|
||||
Height(itemSize).
|
||||
Width(crossAxisSize).
|
||||
Render(view)
|
||||
}
|
||||
|
||||
// Apply cross-axis alignment
|
||||
switch opts.Align {
|
||||
case AlignCenter:
|
||||
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view)
|
||||
case AlignEnd:
|
||||
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view)
|
||||
case AlignStart:
|
||||
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
height = f.height
|
||||
if index < len(f.sizes) && f.sizes[index].Fixed {
|
||||
width = f.sizes[index].Size
|
||||
} else if flexCount > 0 {
|
||||
remainingSpace := f.width - totalFixed
|
||||
width = remainingSpace / flexCount
|
||||
// Calculate total actual size
|
||||
totalActualSize := 0
|
||||
for _, size := range actualSizes {
|
||||
totalActualSize += size
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Add space before if needed
|
||||
if spaceBefore > 0 {
|
||||
if opts.Direction == Row {
|
||||
parts = append(parts, strings.Repeat(" ", spaceBefore))
|
||||
} else {
|
||||
parts = append(parts, strings.Repeat("\n", spaceBefore))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 && spaceBetween > 0 {
|
||||
if opts.Direction == Row {
|
||||
parts = append(parts, strings.Repeat(" ", spaceBetween))
|
||||
} else {
|
||||
parts = append(parts, strings.Repeat("\n", spaceBetween))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add space after if needed
|
||||
if spaceAfter > 0 {
|
||||
if opts.Direction == Row {
|
||||
parts = append(parts, strings.Repeat(" ", spaceAfter))
|
||||
} else {
|
||||
parts = append(parts, strings.Repeat("\n", spaceAfter))
|
||||
}
|
||||
}
|
||||
|
||||
// Join the parts
|
||||
if opts.Direction == Row {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
|
||||
} else {
|
||||
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
|
||||
actualX, actualY := currentX, currentY
|
||||
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
// In horizontal layout, vertical alignment affects Y position
|
||||
// (lipgloss.Center is used for vertical alignment in JoinHorizontal)
|
||||
actualY = (f.height - paneHeight) / 2
|
||||
} else {
|
||||
// In vertical layout, horizontal alignment affects X position
|
||||
contentWidth := paneWidth
|
||||
if alignable, ok := child.(Alignable); ok {
|
||||
if alignable.MaxWidth() > 0 && contentWidth > alignable.MaxWidth() {
|
||||
contentWidth = alignable.MaxWidth()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return f.width, f.height
|
||||
}
|
||||
|
||||
func (f *flexLayout) SetChildren(children []tea.ViewModel) tea.Cmd {
|
||||
f.children = children
|
||||
if f.width > 0 && f.height > 0 {
|
||||
return f.SetSize(f.width, f.height)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *flexLayout) SetSizes(sizes []FlexChildSize) tea.Cmd {
|
||||
f.sizes = sizes
|
||||
if f.width > 0 && f.height > 0 {
|
||||
return f.SetSize(f.width, f.height)
|
||||
}
|
||||
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
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
}
|
||||
|
||||
func WithChildren(children ...tea.ViewModel) FlexLayoutOption {
|
||||
return func(f *flexLayout) {
|
||||
f.children = children
|
||||
}
|
||||
// Helper function to create a simple vertical layout
|
||||
func Vertical(width, height int, items ...FlexItem) string {
|
||||
return Render(FlexOptions{
|
||||
Direction: Column,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Justify: JustifyStart,
|
||||
Align: AlignStretch,
|
||||
}, items...)
|
||||
}
|
||||
|
||||
func WithSizes(sizes ...FlexChildSize) FlexLayoutOption {
|
||||
return func(f *flexLayout) {
|
||||
f.sizes = sizes
|
||||
}
|
||||
// Helper function to create a simple horizontal layout
|
||||
func Horizontal(width, height int, items ...FlexItem) string {
|
||||
return Render(FlexOptions{
|
||||
Direction: Row,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Justify: JustifyStart,
|
||||
Align: AlignStretch,
|
||||
}, items...)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
)
|
||||
|
||||
var Current *LayoutInfo
|
||||
@@ -34,33 +30,3 @@ type Modal interface {
|
||||
Render(background string) string
|
||||
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
|
||||
}
|
||||
|
||||
func (t *LoadedTheme) Name() string {
|
||||
return t.name
|
||||
}
|
||||
|
||||
type colorRef struct {
|
||||
value any
|
||||
resolved bool
|
||||
|
||||
@@ -27,6 +27,10 @@ func NewSystemTheme(terminalBg color.Color, isDark bool) *SystemTheme {
|
||||
return theme
|
||||
}
|
||||
|
||||
func (t *SystemTheme) Name() string {
|
||||
return "system"
|
||||
}
|
||||
|
||||
// initializeColors sets up all theme colors
|
||||
func (t *SystemTheme) initializeColors() {
|
||||
// Generate gray scale based on terminal background
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
// All colors must be defined as compat.AdaptiveColor to support
|
||||
// both light and dark terminal backgrounds.
|
||||
type Theme interface {
|
||||
Name() string
|
||||
|
||||
// Background colors
|
||||
Background() compat.AdaptiveColor // Radix 1
|
||||
BackgroundPanel() compat.AdaptiveColor // Radix 2
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/components/chat"
|
||||
cmdcomp "github.com/sst/opencode/internal/components/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/components/status"
|
||||
@@ -47,8 +48,6 @@ type appModel struct {
|
||||
status status.StatusComponent
|
||||
editor chat.EditorComponent
|
||||
messages chat.MessagesComponent
|
||||
editorContainer layout.Container
|
||||
layout layout.FlexLayout
|
||||
completions dialog.CompletionDialog
|
||||
completionManager *completions.CompletionManager
|
||||
showCompletionDialog bool
|
||||
@@ -283,6 +282,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return updated, cmd
|
||||
}
|
||||
}
|
||||
case error:
|
||||
return a, toast.NewErrorToast(msg.Error())
|
||||
case app.SendMsg:
|
||||
a.showCompletionDialog = false
|
||||
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
|
||||
@@ -360,7 +361,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
Width: min(a.width, 80),
|
||||
},
|
||||
}
|
||||
a.layout.SetSize(a.width, a.height)
|
||||
// Update child component sizes
|
||||
messagesHeight := a.height - 6 // Leave room for editor and status bar
|
||||
a.messages.SetSize(a.width, messagesHeight)
|
||||
a.editor.SetSize(min(a.width, 80), 5)
|
||||
case app.SessionSelectedMsg:
|
||||
messages, err := a.app.ListMessages(context.Background(), msg.ID)
|
||||
if err != nil {
|
||||
@@ -374,6 +378,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
a.app.Model = &msg.Model
|
||||
a.app.State.Provider = msg.Provider.ID
|
||||
a.app.State.Model = msg.Model.ID
|
||||
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
|
||||
a.app.SaveState()
|
||||
case dialog.ThemeSelectedMsg:
|
||||
a.app.State.Theme = msg.ThemeName
|
||||
@@ -424,47 +429,147 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (a appModel) View() string {
|
||||
layoutView := a.layout.View()
|
||||
editorWidth, _ := a.editorContainer.GetSize()
|
||||
editorX, editorY := a.editorContainer.GetPosition()
|
||||
mainLayout := a.chat(layout.Current.Container.Width, lipgloss.Center)
|
||||
if a.modal != nil {
|
||||
mainLayout = a.modal.Render(mainLayout)
|
||||
}
|
||||
mainLayout = a.toastManager.RenderOverlay(mainLayout)
|
||||
if theme.CurrentThemeUsesAnsiColors() {
|
||||
mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
|
||||
}
|
||||
return mainLayout + "\n" + a.status.View()
|
||||
}
|
||||
|
||||
if a.editor.Lines() > 1 {
|
||||
editorY = editorY - a.editor.Lines() + 1
|
||||
layoutView = layout.PlaceOverlay(
|
||||
func (a appModel) chat(width int, align lipgloss.Position) string {
|
||||
editorView := a.editor.View(width, align)
|
||||
lines := a.editor.Lines()
|
||||
messagesView := a.messages.View()
|
||||
if a.app.Session.ID == "" {
|
||||
messagesView = a.home()
|
||||
}
|
||||
editorHeight := max(lines, 5)
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
centeredEditorView := lipgloss.PlaceHorizontal(
|
||||
a.width,
|
||||
align,
|
||||
editorView,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
|
||||
mainLayout := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Direction: layout.Column,
|
||||
Width: a.width,
|
||||
Height: a.height,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: messagesView,
|
||||
Grow: true,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: centeredEditorView,
|
||||
FixedSize: 5,
|
||||
},
|
||||
)
|
||||
|
||||
if lines > 1 {
|
||||
editorWidth := min(a.width, 80)
|
||||
editorX := (a.width - editorWidth) / 2
|
||||
editorY := a.height - editorHeight
|
||||
mainLayout = layout.PlaceOverlay(
|
||||
editorX,
|
||||
editorY,
|
||||
a.editor.Content(),
|
||||
layoutView,
|
||||
a.editor.Content(width, align),
|
||||
mainLayout,
|
||||
)
|
||||
}
|
||||
|
||||
if a.showCompletionDialog {
|
||||
editorWidth := min(a.width, 80)
|
||||
editorX := (a.width - editorWidth) / 2
|
||||
a.completions.SetWidth(editorWidth)
|
||||
overlay := a.completions.View()
|
||||
layoutView = layout.PlaceOverlay(
|
||||
overlayHeight := lipgloss.Height(overlay)
|
||||
editorY := a.height - editorHeight + 1
|
||||
|
||||
mainLayout = layout.PlaceOverlay(
|
||||
editorX,
|
||||
editorY-lipgloss.Height(overlay)+2,
|
||||
editorY-overlayHeight,
|
||||
overlay,
|
||||
layoutView,
|
||||
mainLayout,
|
||||
)
|
||||
}
|
||||
|
||||
components := []string{
|
||||
layoutView,
|
||||
a.status.View(),
|
||||
}
|
||||
appView := strings.Join(components, "\n")
|
||||
return mainLayout
|
||||
}
|
||||
|
||||
if a.modal != nil {
|
||||
appView = a.modal.Render(appView)
|
||||
}
|
||||
func (a appModel) home() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Background(t.Background())
|
||||
base := baseStyle.Render
|
||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||
|
||||
appView = a.toastManager.RenderOverlay(appView)
|
||||
open := `
|
||||
█▀▀█ █▀▀█ █▀▀ █▀▀▄
|
||||
█░░█ █░░█ █▀▀ █░░█
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `
|
||||
code := `
|
||||
█▀▀ █▀▀█ █▀▀▄ █▀▀
|
||||
█░░ █░░█ █░░█ █▀▀
|
||||
▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`
|
||||
|
||||
if theme.CurrentThemeUsesAnsiColors() {
|
||||
appView = util.ConvertRGBToAnsi16Colors(appView)
|
||||
}
|
||||
return appView
|
||||
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()).
|
||||
Width(lipgloss.Width(logo)).
|
||||
Align(lipgloss.Right)
|
||||
version := versionStyle.Render(a.app.Version)
|
||||
|
||||
logoAndVersion := strings.Join([]string{logo, version}, "\n")
|
||||
logoAndVersion = lipgloss.PlaceHorizontal(
|
||||
a.width,
|
||||
lipgloss.Center,
|
||||
logoAndVersion,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
commandsView := cmdcomp.New(
|
||||
a.app,
|
||||
cmdcomp.WithBackground(t.Background()),
|
||||
cmdcomp.WithLimit(6),
|
||||
)
|
||||
cmds := lipgloss.PlaceHorizontal(
|
||||
a.width,
|
||||
lipgloss.Center,
|
||||
commandsView.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, cmds)
|
||||
|
||||
return lipgloss.Place(
|
||||
a.width,
|
||||
a.height-5,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
baseStyle.Render(strings.Join(lines, "\n")),
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
}
|
||||
|
||||
func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
|
||||
@@ -653,13 +758,6 @@ func NewModel(app *app.App) tea.Model {
|
||||
editor := chat.NewEditorComponent(app)
|
||||
completions := dialog.NewCompletionDialogComponent(initialProvider)
|
||||
|
||||
editorContainer := layout.NewContainer(
|
||||
editor,
|
||||
layout.WithMaxWidth(layout.Current.Container.Width),
|
||||
layout.WithAlignCenter(),
|
||||
)
|
||||
messagesContainer := layout.NewContainer(messages)
|
||||
|
||||
var leaderBinding *key.Binding
|
||||
if app.Config.Keybinds.Leader != "" {
|
||||
binding := key.NewBinding(key.WithKeys(app.Config.Keybinds.Leader))
|
||||
@@ -676,17 +774,8 @@ func NewModel(app *app.App) tea.Model {
|
||||
leaderBinding: leaderBinding,
|
||||
isLeaderSequence: false,
|
||||
showCompletionDialog: false,
|
||||
editorContainer: editorContainer,
|
||||
toastManager: toast.NewToastManager(),
|
||||
interruptKeyState: InterruptKeyIdle,
|
||||
layout: layout.NewFlexLayout(
|
||||
[]tea.ViewModel{messagesContainer, editorContainer},
|
||||
layout.WithDirection(layout.FlexDirectionVertical),
|
||||
layout.WithSizes(
|
||||
layout.FlexChildSizeGrow,
|
||||
layout.FlexChildSizeFixed(5),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
return model
|
||||
|
||||
@@ -42,6 +42,6 @@ func Measure(tag string) func(...any) {
|
||||
startTime := time.Now()
|
||||
return func(tags ...any) {
|
||||
args := append([]any{"timeTakenMs", time.Since(startTime).Milliseconds()}, tags...)
|
||||
slog.Info(tag, args...)
|
||||
slog.Debug(tag, args...)
|
||||
}
|
||||
}
|
||||
|
||||
0
packages/tui/pkg/client/.gitignore
vendored
0
packages/tui/pkg/client/.gitignore
vendored
@@ -1,53 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
)
|
||||
|
||||
func Event(c *opencode.Client, url string, ctx context.Context) (<-chan any, error) {
|
||||
events := make(chan any)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url+"event", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer resp.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var event opencode.EventListResponse
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
val := event.AsUnion()
|
||||
|
||||
select {
|
||||
case events <- val:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return events, nil
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
editLink: {
|
||||
baseUrl: `${github}/edit/master/www/`,
|
||||
baseUrl: `${github}/edit/dev/packages/web/`,
|
||||
},
|
||||
markdown: {
|
||||
headingLinks: false,
|
||||
|
||||
@@ -26,14 +26,16 @@ Add a local MCP servers under `mcp.localmcp`.
|
||||
"localmcp": {
|
||||
"type": "local",
|
||||
"command": ["bun", "x", "my-mcp-command"],
|
||||
"enabled": true,
|
||||
"environment": {
|
||||
"MY_ENV_VAR": "my_env_var_value"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also disable a server by setting `enabled` to `false`. This is useful if you want to temporarily disable a server without removing it from your config.
|
||||
|
||||
### Remote
|
||||
|
||||
Add a remote MCP servers under `mcp.remotemcp`.
|
||||
@@ -44,7 +46,8 @@ Add a remote MCP servers under `mcp.remotemcp`.
|
||||
"mcp": {
|
||||
"remotemcp": {
|
||||
"type": "remote",
|
||||
"url": "https://my-mcp-server.com"
|
||||
"url": "https://my-mcp-server.com",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
225
scripts/stats.ts
Executable file
225
scripts/stats.ts
Executable file
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
interface Asset {
|
||||
name: string
|
||||
download_count: number
|
||||
}
|
||||
|
||||
interface Release {
|
||||
tag_name: string
|
||||
name: string
|
||||
assets: Asset[]
|
||||
}
|
||||
|
||||
interface NpmDownloadsRange {
|
||||
start: string
|
||||
end: string
|
||||
package: string
|
||||
downloads: Array<{
|
||||
downloads: number
|
||||
day: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function fetchNpmDownloads(packageName: string): Promise<number> {
|
||||
try {
|
||||
// Use a range from 2020 to current year + 5 years to ensure it works forever
|
||||
const currentYear = new Date().getFullYear()
|
||||
const endYear = currentYear + 5
|
||||
const response = await fetch(
|
||||
`https://api.npmjs.org/downloads/range/2020-01-01:${endYear}-12-31/${packageName}`,
|
||||
)
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`Failed to fetch npm downloads for ${packageName}: ${response.status}`,
|
||||
)
|
||||
return 0
|
||||
}
|
||||
const data: NpmDownloadsRange = await response.json()
|
||||
return data.downloads.reduce((total, day) => total + day.downloads, 0)
|
||||
} catch (error) {
|
||||
console.warn(`Error fetching npm downloads for ${packageName}:`, error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchReleases(): Promise<Release[]> {
|
||||
const releases: Release[] = []
|
||||
let page = 1
|
||||
const per = 100
|
||||
|
||||
while (true) {
|
||||
const url = `https://api.github.com/repos/sst/opencode/releases?page=${page}&per_page=${per}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
)
|
||||
}
|
||||
|
||||
const batch: Release[] = await response.json()
|
||||
if (batch.length === 0) break
|
||||
|
||||
releases.push(...batch)
|
||||
console.log(`Fetched page ${page} with ${batch.length} releases`)
|
||||
|
||||
if (batch.length < per) break
|
||||
page++
|
||||
}
|
||||
|
||||
return releases
|
||||
}
|
||||
|
||||
function calculate(releases: Release[]) {
|
||||
let total = 0
|
||||
const stats = []
|
||||
|
||||
for (const release of releases) {
|
||||
let downloads = 0
|
||||
const assets = []
|
||||
|
||||
for (const asset of release.assets) {
|
||||
downloads += asset.download_count
|
||||
assets.push({
|
||||
name: asset.name,
|
||||
downloads: asset.download_count,
|
||||
})
|
||||
}
|
||||
|
||||
total += downloads
|
||||
stats.push({
|
||||
tag: release.tag_name,
|
||||
name: release.name,
|
||||
downloads,
|
||||
assets,
|
||||
})
|
||||
}
|
||||
|
||||
return { total, stats }
|
||||
}
|
||||
|
||||
async function save(githubTotal: number, npmDownloads: number) {
|
||||
const file = "STATS.md"
|
||||
const date = new Date().toISOString().split("T")[0]
|
||||
const total = githubTotal + npmDownloads
|
||||
|
||||
let previousGithub = 0
|
||||
let previousNpm = 0
|
||||
let previousTotal = 0
|
||||
let content = ""
|
||||
|
||||
try {
|
||||
content = await Bun.file(file).text()
|
||||
const lines = content.trim().split("\n")
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim()
|
||||
if (
|
||||
line.startsWith("|") &&
|
||||
!line.includes("Date") &&
|
||||
!line.includes("---")
|
||||
) {
|
||||
const match = line.match(
|
||||
/\|\s*[\d-]+\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|/,
|
||||
)
|
||||
if (match) {
|
||||
previousGithub = parseInt(match[1].replace(/,/g, ""))
|
||||
previousNpm = parseInt(match[2].replace(/,/g, ""))
|
||||
previousTotal = parseInt(match[3].replace(/,/g, ""))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
content =
|
||||
"# Download Stats\n\n| Date | GitHub Downloads | npm Downloads | Total |\n|------|------------------|---------------|-------|\n"
|
||||
}
|
||||
|
||||
const githubChange = githubTotal - previousGithub
|
||||
const npmChange = npmDownloads - previousNpm
|
||||
const totalChange = total - previousTotal
|
||||
|
||||
const githubChangeStr =
|
||||
githubChange > 0
|
||||
? ` (+${githubChange.toLocaleString()})`
|
||||
: githubChange < 0
|
||||
? ` (${githubChange.toLocaleString()})`
|
||||
: " (+0)"
|
||||
const npmChangeStr =
|
||||
npmChange > 0
|
||||
? ` (+${npmChange.toLocaleString()})`
|
||||
: npmChange < 0
|
||||
? ` (${npmChange.toLocaleString()})`
|
||||
: " (+0)"
|
||||
const totalChangeStr =
|
||||
totalChange > 0
|
||||
? ` (+${totalChange.toLocaleString()})`
|
||||
: totalChange < 0
|
||||
? ` (${totalChange.toLocaleString()})`
|
||||
: " (+0)"
|
||||
const line = `| ${date} | ${githubTotal.toLocaleString()}${githubChangeStr} | ${npmDownloads.toLocaleString()}${npmChangeStr} | ${total.toLocaleString()}${totalChangeStr} |\n`
|
||||
|
||||
if (!content.includes("# Download Stats")) {
|
||||
content =
|
||||
"# Download Stats\n\n| Date | GitHub Downloads | npm Downloads | Total |\n|------|------------------|---------------|-------|\n"
|
||||
}
|
||||
|
||||
await Bun.write(file, content + line)
|
||||
await Bun.spawn(["bunx", "prettier", "--write", file]).exited
|
||||
|
||||
console.log(
|
||||
`\nAppended stats to ${file}: GitHub ${githubTotal.toLocaleString()}${githubChangeStr}, npm ${npmDownloads.toLocaleString()}${npmChangeStr}, Total ${total.toLocaleString()}${totalChangeStr}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log("Fetching GitHub releases for sst/opencode...\n")
|
||||
|
||||
const releases = await fetchReleases()
|
||||
console.log(`\nFetched ${releases.length} releases total\n`)
|
||||
|
||||
const { total: githubTotal, stats } = calculate(releases)
|
||||
|
||||
console.log("Fetching npm all-time downloads for opencode-ai...\n")
|
||||
const npmDownloads = await fetchNpmDownloads("opencode-ai")
|
||||
console.log(
|
||||
`Fetched npm all-time downloads: ${npmDownloads.toLocaleString()}\n`,
|
||||
)
|
||||
|
||||
await save(githubTotal, npmDownloads)
|
||||
|
||||
const totalDownloads = githubTotal + npmDownloads
|
||||
|
||||
console.log("=".repeat(60))
|
||||
console.log(`TOTAL DOWNLOADS: ${totalDownloads.toLocaleString()}`)
|
||||
console.log(` GitHub: ${githubTotal.toLocaleString()}`)
|
||||
console.log(` npm: ${npmDownloads.toLocaleString()}`)
|
||||
console.log("=".repeat(60))
|
||||
|
||||
console.log("\nDownloads by release:")
|
||||
console.log("-".repeat(60))
|
||||
|
||||
stats
|
||||
.sort((a, b) => b.downloads - a.downloads)
|
||||
.forEach((release) => {
|
||||
console.log(
|
||||
`${release.tag.padEnd(15)} ${release.downloads.toLocaleString().padStart(10)} downloads`,
|
||||
)
|
||||
|
||||
if (release.assets.length > 1) {
|
||||
release.assets
|
||||
.sort((a, b) => b.downloads - a.downloads)
|
||||
.forEach((asset) => {
|
||||
console.log(
|
||||
` └─ ${asset.name.padEnd(25)} ${asset.downloads.toLocaleString().padStart(8)}`,
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
console.log("-".repeat(60))
|
||||
console.log(
|
||||
`GitHub Total: ${githubTotal.toLocaleString()} downloads across ${releases.length} releases`,
|
||||
)
|
||||
console.log(`npm Total: ${npmDownloads.toLocaleString()} downloads`)
|
||||
console.log(`Combined Total: ${totalDownloads.toLocaleString()} downloads`)
|
||||
@@ -1,4 +1,4 @@
|
||||
# yaml-language-server: $schema=https://app.stainless.com/config.schema.json
|
||||
# yaml-language-server: $schema=https://app.stainless.com/config-internal.schema.json
|
||||
|
||||
organization:
|
||||
name: opencode
|
||||
@@ -11,22 +11,24 @@ targets:
|
||||
production_repo: "sst/opencode-sdk-js"
|
||||
publish:
|
||||
npm: true
|
||||
skip: false
|
||||
go:
|
||||
package_name: opencode
|
||||
production_repo: sst/opencode-sdk-go
|
||||
skip: false
|
||||
python:
|
||||
project_name: opencode-ai
|
||||
package_name: opencode_ai
|
||||
production_repo: sst/opencode-sdk-python
|
||||
publish:
|
||||
pypi: true
|
||||
skip: false
|
||||
|
||||
environments:
|
||||
production: http://localhost:54321
|
||||
|
||||
streaming:
|
||||
on_event:
|
||||
- kind: fallthrough
|
||||
handle: yield
|
||||
|
||||
resources:
|
||||
$shared:
|
||||
models:
|
||||
@@ -35,7 +37,12 @@ resources:
|
||||
|
||||
event:
|
||||
methods:
|
||||
list: get /event
|
||||
list:
|
||||
endpoint: get /event
|
||||
paginated: false
|
||||
streaming:
|
||||
# This method is always streaming.
|
||||
param_discriminator: null
|
||||
|
||||
app:
|
||||
models:
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user