mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bbbc07aff | |||
| 75a21ba3ce | |||
| 0d6fb68a88 | |||
| 242b886434 | |||
| caf465a9da | |||
| bbf77c6139 | |||
| 53b7e04b86 | |||
| 9e75e3ed18 | |||
| 6389858d41 | |||
| 7e5941e14b | |||
| c68aeed8d9 | |||
| b199a609a8 | |||
| 4a5a93b3f8 | |||
| e99bdcefac | |||
| 26dcb85de1 | |||
| 11d042be25 | |||
| 33b5fe236a | |||
| d56991006c | |||
| 739a9f71c3 | |||
| aef81fce0b | |||
| 8f3d7b4038 |
@@ -4,3 +4,5 @@
|
||||
| ---------- | ---------------- | --------------- | --------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -35,8 +35,7 @@ export class SyncServer extends DurableObject<Env> {
|
||||
ws.close(code, "Durable Object is closing WebSocket")
|
||||
}
|
||||
|
||||
async publish(secret: string, key: string, content: any) {
|
||||
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
|
||||
async publish(key: string, content: any) {
|
||||
const sessionID = await this.getSessionID()
|
||||
if (
|
||||
!key.startsWith(`session/info/${sessionID}`) &&
|
||||
@@ -76,6 +75,10 @@ export class SyncServer extends DurableObject<Env> {
|
||||
.map(([key, content]) => ({ key, content }))
|
||||
}
|
||||
|
||||
public async assertSecret(secret: string) {
|
||||
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
|
||||
}
|
||||
|
||||
private async getSecret() {
|
||||
return this.ctx.storage.get<string>("secret")
|
||||
}
|
||||
@@ -84,15 +87,19 @@ export class SyncServer extends DurableObject<Env> {
|
||||
return this.ctx.storage.get<string>("sessionID")
|
||||
}
|
||||
|
||||
async clear(secret: string) {
|
||||
await this.assertSecret(secret)
|
||||
async clear() {
|
||||
const sessionID = await this.getSessionID()
|
||||
const list = await this.env.Bucket.list({
|
||||
prefix: `session/message/${sessionID}/`,
|
||||
limit: 1000,
|
||||
})
|
||||
for (const item of list.objects) {
|
||||
await this.env.Bucket.delete(item.key)
|
||||
}
|
||||
await this.env.Bucket.delete(`session/info/${sessionID}`)
|
||||
await this.ctx.storage.deleteAll()
|
||||
}
|
||||
|
||||
private async assertSecret(secret: string) {
|
||||
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
|
||||
}
|
||||
|
||||
static shortName(id: string) {
|
||||
return id.substring(id.length - 8)
|
||||
}
|
||||
@@ -134,7 +141,17 @@ export default {
|
||||
const secret = body.secret
|
||||
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
|
||||
const stub = env.SYNC_SERVER.get(id)
|
||||
await stub.clear(secret)
|
||||
await stub.assertSecret(secret)
|
||||
await stub.clear()
|
||||
return new Response(JSON.stringify({}), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
if (request.method === "POST" && method === "share_delete_admin") {
|
||||
const id = env.SYNC_SERVER.idFromName("oVF8Rsiv")
|
||||
const stub = env.SYNC_SERVER.get(id)
|
||||
await stub.clear()
|
||||
return new Response(JSON.stringify({}), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
@@ -150,7 +167,8 @@ export default {
|
||||
const name = SyncServer.shortName(body.sessionID)
|
||||
const id = env.SYNC_SERVER.idFromName(name)
|
||||
const stub = env.SYNC_SERVER.get(id)
|
||||
await stub.publish(body.secret, body.key, body.content)
|
||||
await stub.assertSecret(body.secret)
|
||||
await stub.publish(body.key, body.content)
|
||||
return new Response(JSON.stringify({}), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ else
|
||||
done
|
||||
|
||||
if [ -z "$resolved" ]; then
|
||||
printf "It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
|
||||
printf "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -48,9 +48,9 @@ set "current_dir=%parent_dir%"
|
||||
goto :search_loop
|
||||
|
||||
:not_found
|
||||
echo It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the "%name%" package >&2
|
||||
echo It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "%name%" package >&2
|
||||
exit /b 1
|
||||
|
||||
:execute
|
||||
rem Execute the binary with all arguments
|
||||
"%resolved%" %*
|
||||
"%resolved%" %*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -110,6 +110,7 @@ if (!snapshot) {
|
||||
return (
|
||||
!lower.includes("ignore:") &&
|
||||
!lower.includes("ci:") &&
|
||||
!lower.includes("wip:") &&
|
||||
!lower.includes("docs:") &&
|
||||
!lower.includes("doc:")
|
||||
)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
@@ -13,6 +14,7 @@ export async function bootstrap<T>(
|
||||
Format.init()
|
||||
ConfigHooks.init()
|
||||
LSP.init()
|
||||
FileWatcher.init()
|
||||
|
||||
return cb(app)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { File } from "../../../file"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
const FileReadCommand = cmd({
|
||||
command: "read <path>",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("path", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "File path to read",
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
const content = await File.read(args.path)
|
||||
console.log(content)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const FileStatusCommand = cmd({
|
||||
command: "status",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
const status = await File.status()
|
||||
console.log(JSON.stringify(status, null, 2))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const FileCommand = cmd({
|
||||
command: "file",
|
||||
builder: (yargs) =>
|
||||
yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { FileCommand } from "./file"
|
||||
import { LSPCommand } from "./lsp"
|
||||
import { RipgrepCommand } from "./ripgrep"
|
||||
import { SnapshotCommand } from "./snapshot"
|
||||
|
||||
export const DebugCommand = cmd({
|
||||
command: "debug",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(LSPCommand)
|
||||
.command(RipgrepCommand)
|
||||
.command(FileCommand)
|
||||
.command(SnapshotCommand)
|
||||
.command({
|
||||
command: "wait",
|
||||
async handler() {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1_000 * 60 * 60 * 24),
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { LSP } from "../../../lsp"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Log } from "../../../util/log"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
builder: (yargs) =>
|
||||
yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
const DiagnosticsCommand = cmd({
|
||||
command: "diagnostics <file>",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("file", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
console.log(await LSP.diagnostics())
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const SymbolsCommand = cmd({
|
||||
command: "symbols <query>",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("query", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
await LSP.touchFile("./src/index.ts", true)
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = await LSP.workspaceSymbol(args.query)
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
})
|
||||
},
|
||||
})
|
||||
+5
-64
@@ -1,52 +1,9 @@
|
||||
import { App } from "../../app/app"
|
||||
import { Ripgrep } from "../../file/ripgrep"
|
||||
import { File } from "../../file"
|
||||
import { LSP } from "../../lsp"
|
||||
import { Log } from "../../util/log"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { cmd } from "./cmd"
|
||||
import path from "path"
|
||||
import { App } from "../../../app/app"
|
||||
import { Ripgrep } from "../../../file/ripgrep"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const DebugCommand = cmd({
|
||||
command: "debug",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(DiagnosticsCommand)
|
||||
.command(RipgrepCommand)
|
||||
.command(SymbolsCommand)
|
||||
.command(FileReadCommand)
|
||||
.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)
|
||||
await LSP.touchFile(args.file, true)
|
||||
console.log(await LSP.diagnostics())
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const RipgrepCommand = cmd({
|
||||
export const RipgrepCommand = cmd({
|
||||
command: "rg",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
@@ -128,19 +85,3 @@ const SearchCommand = cmd({
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
const FileReadCommand = cmd({
|
||||
command: "file-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)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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,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"
|
||||
|
||||
@@ -3,8 +3,14 @@ import { Bus } from "../bus"
|
||||
import { $ } from "bun"
|
||||
import { createPatch } from "diff"
|
||||
import path from "path"
|
||||
import * as git from "isomorphic-git"
|
||||
import { App } from "../app/app"
|
||||
import fs from "fs"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace File {
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
export const Event = {
|
||||
Edited: Bus.event(
|
||||
"file.edited",
|
||||
@@ -14,25 +20,109 @@ 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))
|
||||
export async function status() {
|
||||
const app = App.info()
|
||||
if (!app.git) return []
|
||||
|
||||
const diffOutput = await $`git diff --numstat HEAD`
|
||||
.cwd(app.path.cwd)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
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
|
||||
|
||||
const changedFiles = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
const lines = diffOutput.trim().split("\n")
|
||||
for (const line of lines) {
|
||||
const [added, removed, filepath] = line.split("\t")
|
||||
changedFiles.push({
|
||||
file: filepath,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
})
|
||||
}
|
||||
}
|
||||
return content.trim()
|
||||
|
||||
const untrackedOutput = await $`git ls-files --others --exclude-standard`
|
||||
.cwd(app.path.cwd)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
const untrackedFiles = untrackedOutput.trim().split("\n")
|
||||
for (const filepath of untrackedFiles) {
|
||||
try {
|
||||
const content = await Bun.file(
|
||||
path.join(app.path.root, filepath),
|
||||
).text()
|
||||
const lines = content.split("\n").length
|
||||
changedFiles.push({
|
||||
file: filepath,
|
||||
added: lines,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get deleted files
|
||||
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
|
||||
.cwd(app.path.cwd)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
const deletedFiles = deletedOutput.trim().split("\n")
|
||||
for (const filepath of deletedFiles) {
|
||||
changedFiles.push({
|
||||
file: filepath,
|
||||
added: 0,
|
||||
removed: 0, // Could get original line count but would require another git command
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changedFiles.map((x) => ({
|
||||
...x,
|
||||
file: path.relative(app.path.cwd, path.join(app.path.root, x.file)),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function read(file: string) {
|
||||
using _ = log.time("read", { file })
|
||||
const app = App.info()
|
||||
const full = path.join(app.path.cwd, file)
|
||||
const content = await Bun.file(full)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
.then((x) => x.trim())
|
||||
if (app.git) {
|
||||
const rel = path.relative(app.path.root, full)
|
||||
const diff = await git.status({
|
||||
fs,
|
||||
dir: app.path.root,
|
||||
filepath: rel,
|
||||
})
|
||||
if (diff !== "unmodified") {
|
||||
const original = await $`git show HEAD:${rel}`
|
||||
.cwd(app.path.root)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const patch = createPatch(file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
})
|
||||
return { type: "patch", content: patch }
|
||||
}
|
||||
}
|
||||
return { type: "raw", content }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,14 @@ export namespace Ripgrep {
|
||||
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" },
|
||||
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
|
||||
"arm64-linux": {
|
||||
platform: "aarch64-unknown-linux-gnu",
|
||||
extension: "tar.gz",
|
||||
},
|
||||
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
|
||||
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
|
||||
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
|
||||
} as const
|
||||
|
||||
export const ExtractionFailedError = NamedError.create(
|
||||
@@ -124,15 +129,13 @@ export namespace Ripgrep {
|
||||
|
||||
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 platformKey =
|
||||
`${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||
const config = PLATFORM[platformKey]
|
||||
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
|
||||
|
||||
const version = "14.1.1"
|
||||
const filename = `ripgrep-${version}-${arch}-${config.platform}.${config.extension}`
|
||||
const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
|
||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
|
||||
|
||||
const response = await fetch(url)
|
||||
@@ -145,8 +148,8 @@ export namespace Ripgrep {
|
||||
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")
|
||||
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
|
||||
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
|
||||
|
||||
const proc = Bun.spawn(args, {
|
||||
cwd: Global.Path.bin,
|
||||
@@ -177,7 +180,7 @@ export namespace Ripgrep {
|
||||
})
|
||||
}
|
||||
await fs.unlink(archivePath)
|
||||
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
|
||||
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import fs from "fs"
|
||||
import { App } from "../app/app"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace FileWatcher {
|
||||
const log = Log.create({ service: "file.watcher" })
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.event(
|
||||
"file.watcher.updated",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
event: z.union([z.literal("rename"), z.literal("change")]),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export function init() {
|
||||
App.state(
|
||||
"file.watcher",
|
||||
() => {
|
||||
const app = App.use()
|
||||
try {
|
||||
const watcher = fs.watch(
|
||||
app.info.path.cwd,
|
||||
{ recursive: true },
|
||||
(event, file) => {
|
||||
log.info("change", { file, event })
|
||||
if (!file) return
|
||||
// for some reason async local storage is lost here
|
||||
// https://github.com/oven-sh/bun/issues/20754
|
||||
App.provideExisting(app, async () => {
|
||||
Bus.publish(Event.Updated, {
|
||||
file,
|
||||
event,
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
return { watcher }
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
state.watcher?.close()
|
||||
},
|
||||
)()
|
||||
}
|
||||
}
|
||||
@@ -163,10 +163,12 @@ export namespace LSPClient {
|
||||
}
|
||||
})
|
||||
}),
|
||||
5000,
|
||||
).finally(() => {
|
||||
unsub?.()
|
||||
})
|
||||
3000,
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
unsub?.()
|
||||
})
|
||||
},
|
||||
async shutdown() {
|
||||
log.info("shutting down", { serverID })
|
||||
|
||||
@@ -188,6 +188,11 @@ export namespace Message {
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
user: z
|
||||
.object({
|
||||
snapshot: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.openapi({ ref: "MessageMetadata" }),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ Usage:
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any lines longer than 2000 characters will be truncated
|
||||
- Results are returned using cat -n format, with line numbers starting at 1
|
||||
- This tool allows OpenCode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as OpenCode is a multimodal LLM.
|
||||
- This tool allows opencode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as opencode is a multimodal LLM.
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
||||
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
|
||||
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
||||
|
||||
@@ -21,9 +21,10 @@ import (
|
||||
|
||||
type EditorComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
// tea.ViewModel
|
||||
SetSize(width, height int) tea.Cmd
|
||||
Content() string
|
||||
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,19 +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 {
|
||||
t := theme.CurrentTheme()
|
||||
return lipgloss.Place(
|
||||
m.width,
|
||||
width,
|
||||
m.height,
|
||||
lipgloss.Center,
|
||||
align,
|
||||
lipgloss.Center,
|
||||
"",
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
}
|
||||
return m.Content()
|
||||
return m.Content(width, align)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Focused() bool {
|
||||
@@ -343,7 +344,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
||||
ta.SetHeight(existing.Height())
|
||||
}
|
||||
|
||||
// ta.Focus()
|
||||
return ta
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@ package chat
|
||||
|
||||
import (
|
||||
"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"
|
||||
@@ -20,6 +18,7 @@ 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)
|
||||
@@ -36,7 +35,6 @@ type messagesComponent struct {
|
||||
width, height int
|
||||
app *app.App
|
||||
viewport viewport.Model
|
||||
spinner spinner.Model
|
||||
attachments viewport.Model
|
||||
cache *MessageCache
|
||||
rendering bool
|
||||
@@ -47,7 +45,7 @@ type renderFinishedMsg struct{}
|
||||
type ToggleToolDetailsMsg struct{}
|
||||
|
||||
func (m *messagesComponent) Init() tea.Cmd {
|
||||
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
|
||||
return tea.Batch(m.viewport.Init())
|
||||
}
|
||||
|
||||
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -94,10 +92,6 @@ 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)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
@@ -110,14 +104,15 @@ func (m *messagesComponent) renderView() {
|
||||
defer measure("messageCount", len(m.app.Messages))
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
blocks := make([]string, 0)
|
||||
|
||||
align := lipgloss.Center
|
||||
width := layout.Current.Container.Width
|
||||
|
||||
for _, message := range m.app.Messages {
|
||||
sb := strings.Builder{}
|
||||
util.MapReducePar(m.app.Messages, &sb, func(message opencode.Message) func(*strings.Builder) *strings.Builder {
|
||||
var content string
|
||||
var cached bool
|
||||
blocks := make([]string, 0)
|
||||
|
||||
switch message.Role {
|
||||
case opencode.MessageRoleUser:
|
||||
@@ -230,7 +225,6 @@ func (m *messagesComponent) renderView() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
error := ""
|
||||
@@ -253,20 +247,21 @@ func (m *messagesComponent) renderView() {
|
||||
)
|
||||
blocks = append(blocks, error)
|
||||
}
|
||||
}
|
||||
|
||||
centered := []string{}
|
||||
for _, block := range blocks {
|
||||
centered = append(centered, lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
block+"\n",
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
))
|
||||
}
|
||||
str := strings.Join(blocks, "\n\n")
|
||||
return func(sbdr *strings.Builder) *strings.Builder {
|
||||
if sbdr.Len() > 0 && str != "" {
|
||||
sbdr.WriteString("\n\n")
|
||||
}
|
||||
sbdr.WriteString(str)
|
||||
return sbdr
|
||||
}
|
||||
})
|
||||
|
||||
content := sb.String()
|
||||
|
||||
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1)
|
||||
m.viewport.SetContent("\n" + strings.Join(centered, "\n"))
|
||||
m.viewport.SetContent("\n" + content)
|
||||
}
|
||||
|
||||
func (m *messagesComponent) header() string {
|
||||
@@ -307,10 +302,10 @@ func (m *messagesComponent) View() string {
|
||||
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()),
|
||||
)
|
||||
}
|
||||
@@ -392,12 +387,6 @@ 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{}
|
||||
@@ -405,7 +394,6 @@ func NewMessagesComponent(app *app.App) MessagesComponent {
|
||||
return &messagesComponent{
|
||||
app: app,
|
||||
viewport: vp,
|
||||
spinner: s,
|
||||
attachments: attachments,
|
||||
showToolDetails: true,
|
||||
cache: NewMessageCache(),
|
||||
|
||||
@@ -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...)
|
||||
})
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -378,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
|
||||
@@ -428,7 +429,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (a appModel) View() string {
|
||||
editorView := a.editor.View()
|
||||
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()
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
@@ -439,7 +452,7 @@ func (a appModel) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
centeredEditorView := lipgloss.PlaceHorizontal(
|
||||
a.width,
|
||||
lipgloss.Center,
|
||||
align,
|
||||
editorView,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
@@ -458,10 +471,6 @@ func (a appModel) View() string {
|
||||
View: centeredEditorView,
|
||||
FixedSize: 5,
|
||||
},
|
||||
// layout.FlexItem{
|
||||
// View: a.status.View(),
|
||||
// FixedSize: 1,
|
||||
// },
|
||||
)
|
||||
|
||||
if lines > 1 {
|
||||
@@ -471,7 +480,7 @@ func (a appModel) View() string {
|
||||
mainLayout = layout.PlaceOverlay(
|
||||
editorX,
|
||||
editorY,
|
||||
a.editor.Content(),
|
||||
a.editor.Content(width, align),
|
||||
mainLayout,
|
||||
)
|
||||
}
|
||||
@@ -492,14 +501,7 @@ func (a appModel) View() string {
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
return mainLayout
|
||||
}
|
||||
|
||||
func (a appModel) home() string {
|
||||
|
||||
@@ -42,7 +42,7 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
editLink: {
|
||||
baseUrl: `${github}/edit/master/www/`,
|
||||
baseUrl: `${github}/edit/dev/packages/web/`,
|
||||
},
|
||||
markdown: {
|
||||
headingLinks: false,
|
||||
|
||||
@@ -1616,7 +1616,7 @@ export default function Share(props: {
|
||||
>
|
||||
{(_part) => {
|
||||
const todos = createMemo(() =>
|
||||
sortTodosByStatus(toolData()?.args.todos),
|
||||
sortTodosByStatus(toolData()?.args?.todos ?? []),
|
||||
)
|
||||
const starting = () =>
|
||||
todos().every((t) => t.status === "pending")
|
||||
|
||||
@@ -41,7 +41,7 @@ opencode run Explain the use of context in Go
|
||||
| `--continue` | `-c` | Continue the last session |
|
||||
| `--session` | `-s` | Session ID to continue |
|
||||
| `--share` | | Share the session |
|
||||
| `--model` | `-m` | Mode to use in the form of provider/model |
|
||||
| `--model` | `-m` | Model to use in the form of provider/model |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user