Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2090bab537 | ||
|
|
64d5fff9a3 | ||
|
|
925f695503 | ||
|
|
f1c925795d | ||
|
|
c82a060eca | ||
|
|
63e783ef79 | ||
|
|
35d6273fb3 | ||
|
|
b89d4a16fd | ||
|
|
2799a96032 | ||
|
|
8f4b79227c | ||
|
|
c810b6d206 | ||
|
|
fa35407572 | ||
|
|
8bbbc07aff | ||
|
|
75a21ba3ce | ||
|
|
0d6fb68a88 | ||
|
|
242b886434 | ||
|
|
caf465a9da | ||
|
|
bbf77c6139 | ||
|
|
53b7e04b86 | ||
|
|
9e75e3ed18 | ||
|
|
6389858d41 | ||
|
|
7e5941e14b | ||
|
|
c68aeed8d9 | ||
|
|
b199a609a8 | ||
|
|
4a5a93b3f8 | ||
|
|
e99bdcefac | ||
|
|
26dcb85de1 | ||
|
|
11d042be25 | ||
|
|
33b5fe236a | ||
|
|
d56991006c | ||
|
|
739a9f71c3 | ||
|
|
aef81fce0b | ||
|
|
8f3d7b4038 | ||
|
|
de15e67834 |
@@ -4,3 +4,5 @@
|
|||||||
| ---------- | ---------------- | --------------- | --------------- |
|
| ---------- | ---------------- | --------------- | --------------- |
|
||||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
| 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-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",
|
"env-paths": "3.0.0",
|
||||||
"hono": "4.7.10",
|
"hono": "4.7.10",
|
||||||
"hono-openapi": "0.4.8",
|
"hono-openapi": "0.4.8",
|
||||||
|
"isomorphic-git": "1.32.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"remeda": "2.22.3",
|
"remeda": "2.22.3",
|
||||||
"ts-lsp-client": "1.0.3",
|
"ts-lsp-client": "1.0.3",
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"shiki": "3.4.2",
|
"shiki": "3.4.2",
|
||||||
"solid-js": "1.9.7",
|
"solid-js": "1.9.7",
|
||||||
"toolbeam-docs-theme": "0.3.0",
|
"toolbeam-docs-theme": "0.4.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
@@ -541,6 +542,8 @@
|
|||||||
|
|
||||||
"astro-expressive-code": ["astro-expressive-code@0.41.2", "", { "dependencies": { "rehype-expressive-code": "^0.41.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-HN0jWTnhr7mIV/2e6uu4PPRNNo/k4UEgTLZqbp3MrHU+caCARveG2yZxaZVBmxyiVdYqW5Pd3u3n2zjnshixbw=="],
|
"astro-expressive-code": ["astro-expressive-code@0.41.2", "", { "dependencies": { "rehype-expressive-code": "^0.41.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-HN0jWTnhr7mIV/2e6uu4PPRNNo/k4UEgTLZqbp3MrHU+caCARveG2yZxaZVBmxyiVdYqW5Pd3u3n2zjnshixbw=="],
|
||||||
|
|
||||||
|
"async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="],
|
||||||
|
|
||||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||||
|
|
||||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||||
@@ -633,6 +636,8 @@
|
|||||||
|
|
||||||
"ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="],
|
"ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="],
|
||||||
|
|
||||||
|
"clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="],
|
||||||
|
|
||||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||||
|
|
||||||
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||||
@@ -669,6 +674,8 @@
|
|||||||
|
|
||||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||||
|
|
||||||
|
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
||||||
|
|
||||||
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
||||||
|
|
||||||
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
|
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
|
||||||
@@ -725,6 +732,8 @@
|
|||||||
|
|
||||||
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
|
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
|
||||||
|
|
||||||
|
"diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="],
|
||||||
|
|
||||||
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
|
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
|
||||||
|
|
||||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||||
@@ -939,6 +948,8 @@
|
|||||||
|
|
||||||
"ieee754": ["ieee754@1.1.13", "", {}, "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="],
|
"ieee754": ["ieee754@1.1.13", "", {}, "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="],
|
||||||
|
|
||||||
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
|
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
|
||||||
|
|
||||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
@@ -987,6 +998,8 @@
|
|||||||
|
|
||||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||||
|
|
||||||
|
"isomorphic-git": ["isomorphic-git@1.32.1", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "path-browserify": "^1.0.1", "pify": "^4.0.1", "readable-stream": "^3.4.0", "sha.js": "^2.4.9", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-NZCS7qpLkCZ1M/IrujYBD31sM6pd/fMVArK4fz4I7h6m0rUW2AsYU7S7zXeABuHL6HIfW6l53b4UQ/K441CQjg=="],
|
||||||
|
|
||||||
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
|
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
|
||||||
|
|
||||||
"jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
"jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
||||||
@@ -1169,6 +1182,8 @@
|
|||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
|
"minimisted": ["minimisted@2.0.1", "", { "dependencies": { "minimist": "^1.2.5" } }, "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA=="],
|
||||||
|
|
||||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||||
|
|
||||||
"mri": ["mri@1.1.4", "", {}, "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w=="],
|
"mri": ["mri@1.1.4", "", {}, "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w=="],
|
||||||
@@ -1247,7 +1262,7 @@
|
|||||||
|
|
||||||
"pagefind": ["pagefind@1.3.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.3.0", "@pagefind/darwin-x64": "1.3.0", "@pagefind/linux-arm64": "1.3.0", "@pagefind/linux-x64": "1.3.0", "@pagefind/windows-x64": "1.3.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw=="],
|
"pagefind": ["pagefind@1.3.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.3.0", "@pagefind/darwin-x64": "1.3.0", "@pagefind/linux-arm64": "1.3.0", "@pagefind/linux-x64": "1.3.0", "@pagefind/windows-x64": "1.3.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw=="],
|
||||||
|
|
||||||
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||||
|
|
||||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||||
|
|
||||||
@@ -1257,6 +1272,8 @@
|
|||||||
|
|
||||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
|
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
@@ -1267,6 +1284,8 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||||
|
|
||||||
|
"pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
|
||||||
|
|
||||||
"pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="],
|
"pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="],
|
||||||
|
|
||||||
"pino-abstract-transport": ["pino-abstract-transport@0.5.0", "", { "dependencies": { "duplexify": "^4.1.2", "split2": "^4.0.0" } }, "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ=="],
|
"pino-abstract-transport": ["pino-abstract-transport@0.5.0", "", { "dependencies": { "duplexify": "^4.1.2", "split2": "^4.0.0" } }, "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ=="],
|
||||||
@@ -1417,6 +1436,8 @@
|
|||||||
|
|
||||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||||
|
|
||||||
|
"sha.js": ["sha.js@2.4.11", "", { "dependencies": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" }, "bin": { "sha.js": "./bin.js" } }, "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ=="],
|
||||||
|
|
||||||
"sharp": ["sharp@0.32.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ=="],
|
"sharp": ["sharp@0.32.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ=="],
|
||||||
|
|
||||||
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
|
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
|
||||||
@@ -1525,7 +1546,7 @@
|
|||||||
|
|
||||||
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
|
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
|
||||||
|
|
||||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="],
|
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.1", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-lTI4dHZaVNQky29m7sb36Oy4tWPwxsCuFxFjF8hgGW0vpV+S6qPvI9SwsJFvdE/OHO5DoI7VMbryV1pxZHkkHQ=="],
|
||||||
|
|
||||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
@@ -1793,6 +1814,8 @@
|
|||||||
|
|
||||||
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
|
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||||
|
|
||||||
"unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
"unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="],
|
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="],
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ export class SyncServer extends DurableObject<Env> {
|
|||||||
ws.close(code, "Durable Object is closing WebSocket")
|
ws.close(code, "Durable Object is closing WebSocket")
|
||||||
}
|
}
|
||||||
|
|
||||||
async publish(secret: string, key: string, content: any) {
|
async publish(key: string, content: any) {
|
||||||
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
|
|
||||||
const sessionID = await this.getSessionID()
|
const sessionID = await this.getSessionID()
|
||||||
if (
|
if (
|
||||||
!key.startsWith(`session/info/${sessionID}`) &&
|
!key.startsWith(`session/info/${sessionID}`) &&
|
||||||
@@ -76,6 +75,10 @@ export class SyncServer extends DurableObject<Env> {
|
|||||||
.map(([key, content]) => ({ key, content }))
|
.map(([key, content]) => ({ key, content }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async assertSecret(secret: string) {
|
||||||
|
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
|
||||||
|
}
|
||||||
|
|
||||||
private async getSecret() {
|
private async getSecret() {
|
||||||
return this.ctx.storage.get<string>("secret")
|
return this.ctx.storage.get<string>("secret")
|
||||||
}
|
}
|
||||||
@@ -84,15 +87,19 @@ export class SyncServer extends DurableObject<Env> {
|
|||||||
return this.ctx.storage.get<string>("sessionID")
|
return this.ctx.storage.get<string>("sessionID")
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(secret: string) {
|
async clear() {
|
||||||
await this.assertSecret(secret)
|
const sessionID = await this.getSessionID()
|
||||||
|
const list = await this.env.Bucket.list({
|
||||||
|
prefix: `session/message/${sessionID}/`,
|
||||||
|
limit: 1000,
|
||||||
|
})
|
||||||
|
for (const item of list.objects) {
|
||||||
|
await this.env.Bucket.delete(item.key)
|
||||||
|
}
|
||||||
|
await this.env.Bucket.delete(`session/info/${sessionID}`)
|
||||||
await this.ctx.storage.deleteAll()
|
await this.ctx.storage.deleteAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertSecret(secret: string) {
|
|
||||||
if (secret !== (await this.getSecret())) throw new Error("Invalid secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
static shortName(id: string) {
|
static shortName(id: string) {
|
||||||
return id.substring(id.length - 8)
|
return id.substring(id.length - 8)
|
||||||
}
|
}
|
||||||
@@ -134,7 +141,17 @@ export default {
|
|||||||
const secret = body.secret
|
const secret = body.secret
|
||||||
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
|
const id = env.SYNC_SERVER.idFromName(SyncServer.shortName(sessionID))
|
||||||
const stub = env.SYNC_SERVER.get(id)
|
const stub = env.SYNC_SERVER.get(id)
|
||||||
await stub.clear(secret)
|
await stub.assertSecret(secret)
|
||||||
|
await stub.clear()
|
||||||
|
return new Response(JSON.stringify({}), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && method === "share_delete_admin") {
|
||||||
|
const id = env.SYNC_SERVER.idFromName("oVF8Rsiv")
|
||||||
|
const stub = env.SYNC_SERVER.get(id)
|
||||||
|
await stub.clear()
|
||||||
return new Response(JSON.stringify({}), {
|
return new Response(JSON.stringify({}), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
})
|
||||||
@@ -150,7 +167,8 @@ export default {
|
|||||||
const name = SyncServer.shortName(body.sessionID)
|
const name = SyncServer.shortName(body.sessionID)
|
||||||
const id = env.SYNC_SERVER.idFromName(name)
|
const id = env.SYNC_SERVER.idFromName(name)
|
||||||
const stub = env.SYNC_SERVER.get(id)
|
const stub = env.SYNC_SERVER.get(id)
|
||||||
await stub.publish(body.secret, body.key, body.content)
|
await stub.assertSecret(body.secret)
|
||||||
|
await stub.publish(body.key, body.content)
|
||||||
return new Response(JSON.stringify({}), {
|
return new Response(JSON.stringify({}), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ else
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ -z "$resolved" ]; then
|
if [ -z "$resolved" ]; then
|
||||||
printf "It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
|
printf "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ set "current_dir=%parent_dir%"
|
|||||||
goto :search_loop
|
goto :search_loop
|
||||||
|
|
||||||
:not_found
|
:not_found
|
||||||
echo It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the "%name%" package >&2
|
echo It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "%name%" package >&2
|
||||||
exit /b 1
|
exit /b 1
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
rem Execute the binary with all arguments
|
rem Execute the binary with all arguments
|
||||||
"%resolved%" %*
|
"%resolved%" %*
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"env-paths": "3.0.0",
|
"env-paths": "3.0.0",
|
||||||
"hono": "4.7.10",
|
"hono": "4.7.10",
|
||||||
"hono-openapi": "0.4.8",
|
"hono-openapi": "0.4.8",
|
||||||
|
"isomorphic-git": "1.32.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"remeda": "2.22.3",
|
"remeda": "2.22.3",
|
||||||
"ts-lsp-client": "1.0.3",
|
"ts-lsp-client": "1.0.3",
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ if (!snapshot) {
|
|||||||
return (
|
return (
|
||||||
!lower.includes("ignore:") &&
|
!lower.includes("ignore:") &&
|
||||||
!lower.includes("ci:") &&
|
!lower.includes("ci:") &&
|
||||||
|
!lower.includes("wip:") &&
|
||||||
!lower.includes("docs:") &&
|
!lower.includes("docs:") &&
|
||||||
!lower.includes("doc:")
|
!lower.includes("doc:")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,12 +36,15 @@ export namespace App {
|
|||||||
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
|
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
|
||||||
}>("app")
|
}>("app")
|
||||||
|
|
||||||
|
export const use = ctx.use
|
||||||
|
|
||||||
const APP_JSON = "app.json"
|
const APP_JSON = "app.json"
|
||||||
|
|
||||||
export type Input = {
|
export type Input = {
|
||||||
cwd: string
|
cwd: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const provideExisting = ctx.provide
|
||||||
export async function provide<T>(
|
export async function provide<T>(
|
||||||
input: Input,
|
input: Input,
|
||||||
cb: (app: App.Info) => Promise<T>,
|
cb: (app: App.Info) => Promise<T>,
|
||||||
@@ -96,13 +99,16 @@ export namespace App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ctx.provide(app, async () => {
|
return ctx.provide(app, async () => {
|
||||||
const result = await cb(app.info)
|
try {
|
||||||
for (const [key, entry] of app.services.entries()) {
|
const result = await cb(app.info)
|
||||||
if (!entry.shutdown) continue
|
return result
|
||||||
log.info("shutdown", { name: key })
|
} finally {
|
||||||
await entry.shutdown?.(await entry.state)
|
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,5 +1,6 @@
|
|||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
import { ConfigHooks } from "../config/hooks"
|
import { ConfigHooks } from "../config/hooks"
|
||||||
|
import { FileWatcher } from "../file/watch"
|
||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { Share } from "../share/share"
|
import { Share } from "../share/share"
|
||||||
@@ -13,6 +14,7 @@ export async function bootstrap<T>(
|
|||||||
Format.init()
|
Format.init()
|
||||||
ConfigHooks.init()
|
ConfigHooks.init()
|
||||||
LSP.init()
|
LSP.init()
|
||||||
|
FileWatcher.init()
|
||||||
|
|
||||||
return cb(app)
|
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))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
+35
-36
@@ -1,34 +1,19 @@
|
|||||||
import { App } from "../../app/app"
|
import { App } from "../../../app/app"
|
||||||
import { Ripgrep } from "../../file/ripgrep"
|
import { Ripgrep } from "../../../file/ripgrep"
|
||||||
import { LSP } from "../../lsp"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { Log } from "../../util/log"
|
import { cmd } from "../cmd"
|
||||||
import { bootstrap } from "../bootstrap"
|
|
||||||
import { cmd } from "./cmd"
|
|
||||||
|
|
||||||
export const DebugCommand = cmd({
|
export const RipgrepCommand = cmd({
|
||||||
command: "debug",
|
command: "rg",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs
|
yargs
|
||||||
.command(DiagnosticsCommand)
|
|
||||||
.command(TreeCommand)
|
.command(TreeCommand)
|
||||||
.command(SymbolsCommand)
|
|
||||||
.command(FilesCommand)
|
.command(FilesCommand)
|
||||||
|
.command(SearchCommand)
|
||||||
.demandCommand(),
|
.demandCommand(),
|
||||||
async handler() {},
|
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())
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const TreeCommand = cmd({
|
const TreeCommand = cmd({
|
||||||
command: "tree",
|
command: "tree",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
@@ -43,20 +28,6 @@ const TreeCommand = cmd({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
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 FilesCommand = cmd({
|
const FilesCommand = cmd({
|
||||||
command: "files",
|
command: "files",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
@@ -86,3 +57,31 @@ const FilesCommand = cmd({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const SearchCommand = cmd({
|
||||||
|
command: "search <pattern>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.positional("pattern", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
description: "Search pattern",
|
||||||
|
})
|
||||||
|
.option("glob", {
|
||||||
|
type: "array",
|
||||||
|
description: "File glob patterns",
|
||||||
|
})
|
||||||
|
.option("limit", {
|
||||||
|
type: "number",
|
||||||
|
description: "Limit number of results",
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
const results = await Ripgrep.search({
|
||||||
|
cwd: process.cwd(),
|
||||||
|
pattern: args.pattern,
|
||||||
|
glob: args.glob as string[] | undefined,
|
||||||
|
limit: args.limit,
|
||||||
|
})
|
||||||
|
console.log(JSON.stringify(results, null, 2))
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Snapshot } from "../../../snapshot"
|
||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
|
export const SnapshotCommand = cmd({
|
||||||
|
command: "snapshot",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.command(SnapshotCreateCommand)
|
||||||
|
.command(SnapshotRestoreCommand)
|
||||||
|
.demandCommand(),
|
||||||
|
async handler() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SnapshotCreateCommand = cmd({
|
||||||
|
command: "create",
|
||||||
|
async handler() {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
const result = await Snapshot.create("test")
|
||||||
|
console.log(result)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SnapshotRestoreCommand = cmd({
|
||||||
|
command: "restore <commit>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("commit", {
|
||||||
|
type: "string",
|
||||||
|
description: "commit",
|
||||||
|
demandOption: true,
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
await Snapshot.restore("test", args.commit)
|
||||||
|
console.log("restored")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { App } from "../../app/app"
|
|
||||||
import { Provider } from "../../provider/provider"
|
import { Provider } from "../../provider/provider"
|
||||||
import { Server } from "../../server/server"
|
import { Server } from "../../server/server"
|
||||||
import { Share } from "../../share/share"
|
import { Share } from "../../share/share"
|
||||||
|
import { bootstrap } from "../bootstrap"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
|
|
||||||
export const ServeCommand = cmd({
|
export const ServeCommand = cmd({
|
||||||
@@ -23,7 +23,7 @@ export const ServeCommand = cmd({
|
|||||||
describe: "starts a headless opencode server",
|
describe: "starts a headless opencode server",
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
await App.provide({ cwd }, async () => {
|
await bootstrap({ cwd }, async () => {
|
||||||
const providers = await Provider.list()
|
const providers = await Provider.list()
|
||||||
if (Object.keys(providers).length === 0) {
|
if (Object.keys(providers).length === 0) {
|
||||||
return "needs_provider"
|
return "needs_provider"
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Bus } from "../bus"
|
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 {
|
export namespace File {
|
||||||
|
const log = Log.create({ service: "file" })
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Edited: Bus.event(
|
Edited: Bus.event(
|
||||||
"file.edited",
|
"file.edited",
|
||||||
@@ -10,4 +19,110 @@ export namespace 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()
|
||||||
|
|
||||||
|
const changedFiles = []
|
||||||
|
|
||||||
|
if (diffOutput.trim()) {
|
||||||
|
const lines = diffOutput.trim().split("\n")
|
||||||
|
for (const line of lines) {
|
||||||
|
const [added, removed, filepath] = line.split("\t")
|
||||||
|
changedFiles.push({
|
||||||
|
file: filepath,
|
||||||
|
added: added === "-" ? 0 : parseInt(added, 10),
|
||||||
|
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||||
|
status: "modified",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const untrackedOutput = await $`git ls-files --others --exclude-standard`
|
||||||
|
.cwd(app.path.cwd)
|
||||||
|
.quiet()
|
||||||
|
.nothrow()
|
||||||
|
.text()
|
||||||
|
|
||||||
|
if (untrackedOutput.trim()) {
|
||||||
|
const untrackedFiles = untrackedOutput.trim().split("\n")
|
||||||
|
for (const filepath of untrackedFiles) {
|
||||||
|
try {
|
||||||
|
const content = await Bun.file(
|
||||||
|
path.join(app.path.root, filepath),
|
||||||
|
).text()
|
||||||
|
const lines = content.split("\n").length
|
||||||
|
changedFiles.push({
|
||||||
|
file: filepath,
|
||||||
|
added: lines,
|
||||||
|
removed: 0,
|
||||||
|
status: "added",
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get deleted files
|
||||||
|
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
|
||||||
|
.cwd(app.path.cwd)
|
||||||
|
.quiet()
|
||||||
|
.nothrow()
|
||||||
|
.text()
|
||||||
|
|
||||||
|
if (deletedOutput.trim()) {
|
||||||
|
const deletedFiles = deletedOutput.trim().split("\n")
|
||||||
|
for (const filepath of deletedFiles) {
|
||||||
|
changedFiles.push({
|
||||||
|
file: filepath,
|
||||||
|
added: 0,
|
||||||
|
removed: 0, // Could get original line count but would require another git command
|
||||||
|
status: "deleted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changedFiles.map((x) => ({
|
||||||
|
...x,
|
||||||
|
file: path.relative(app.path.cwd, path.join(app.path.root, x.file)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function read(file: string) {
|
||||||
|
using _ = log.time("read", { file })
|
||||||
|
const app = App.info()
|
||||||
|
const full = path.join(app.path.cwd, file)
|
||||||
|
const content = await Bun.file(full)
|
||||||
|
.text()
|
||||||
|
.catch(() => "")
|
||||||
|
.then((x) => x.trim())
|
||||||
|
if (app.git) {
|
||||||
|
const rel = path.relative(app.path.root, full)
|
||||||
|
const diff = await git.status({
|
||||||
|
fs,
|
||||||
|
dir: app.path.root,
|
||||||
|
filepath: rel,
|
||||||
|
})
|
||||||
|
if (diff !== "unmodified") {
|
||||||
|
const original = await $`git show HEAD:${rel}`
|
||||||
|
.cwd(app.path.root)
|
||||||
|
.quiet()
|
||||||
|
.nothrow()
|
||||||
|
.text()
|
||||||
|
const patch = createPatch(file, original, content, "old", "new", {
|
||||||
|
context: Infinity,
|
||||||
|
})
|
||||||
|
return { type: "patch", content: patch }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: "raw", content }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Ripgrep utility functions
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
@@ -8,10 +9,91 @@ import { $ } from "bun"
|
|||||||
import { Fzf } from "./fzf"
|
import { Fzf } from "./fzf"
|
||||||
|
|
||||||
export namespace Ripgrep {
|
export namespace Ripgrep {
|
||||||
|
const Stats = z.object({
|
||||||
|
elapsed: z.object({
|
||||||
|
secs: z.number(),
|
||||||
|
nanos: z.number(),
|
||||||
|
human: z.string(),
|
||||||
|
}),
|
||||||
|
searches: z.number(),
|
||||||
|
searches_with_match: z.number(),
|
||||||
|
bytes_searched: z.number(),
|
||||||
|
bytes_printed: z.number(),
|
||||||
|
matched_lines: z.number(),
|
||||||
|
matches: z.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Begin = z.object({
|
||||||
|
type: z.literal("begin"),
|
||||||
|
data: z.object({
|
||||||
|
path: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Match = z.object({
|
||||||
|
type: z.literal("match"),
|
||||||
|
data: z.object({
|
||||||
|
path: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
lines: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
line_number: z.number(),
|
||||||
|
absolute_offset: z.number(),
|
||||||
|
submatches: z.array(
|
||||||
|
z.object({
|
||||||
|
match: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
start: z.number(),
|
||||||
|
end: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const End = z.object({
|
||||||
|
type: z.literal("end"),
|
||||||
|
data: z.object({
|
||||||
|
path: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
binary_offset: z.number().nullable(),
|
||||||
|
stats: Stats,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Summary = z.object({
|
||||||
|
type: z.literal("summary"),
|
||||||
|
data: z.object({
|
||||||
|
elapsed_total: z.object({
|
||||||
|
human: z.string(),
|
||||||
|
nanos: z.number(),
|
||||||
|
secs: z.number(),
|
||||||
|
}),
|
||||||
|
stats: Stats,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Result = z.union([Begin, Match, End, Summary])
|
||||||
|
|
||||||
|
export type Result = z.infer<typeof Result>
|
||||||
|
export type Match = z.infer<typeof Match>
|
||||||
|
export type Begin = z.infer<typeof Begin>
|
||||||
|
export type End = z.infer<typeof End>
|
||||||
|
export type Summary = z.infer<typeof Summary>
|
||||||
const PLATFORM = {
|
const PLATFORM = {
|
||||||
darwin: { platform: "apple-darwin", extension: "tar.gz" },
|
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
|
||||||
linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
|
"arm64-linux": {
|
||||||
win32: { platform: "pc-windows-msvc", extension: "zip" },
|
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
|
} as const
|
||||||
|
|
||||||
export const ExtractionFailedError = NamedError.create(
|
export const ExtractionFailedError = NamedError.create(
|
||||||
@@ -47,15 +129,13 @@ export namespace Ripgrep {
|
|||||||
|
|
||||||
const file = Bun.file(filepath)
|
const file = Bun.file(filepath)
|
||||||
if (!(await file.exists())) {
|
if (!(await file.exists())) {
|
||||||
const archMap = { x64: "x86_64", arm64: "aarch64" } as const
|
const platformKey =
|
||||||
const arch = archMap[process.arch as keyof typeof archMap] ?? process.arch
|
`${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||||
|
const config = PLATFORM[platformKey]
|
||||||
const config = PLATFORM[process.platform as keyof typeof PLATFORM]
|
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
|
||||||
if (!config)
|
|
||||||
throw new UnsupportedPlatformError({ platform: process.platform })
|
|
||||||
|
|
||||||
const version = "14.1.1"
|
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 url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
|
||||||
|
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
@@ -68,8 +148,8 @@ export namespace Ripgrep {
|
|||||||
if (config.extension === "tar.gz") {
|
if (config.extension === "tar.gz") {
|
||||||
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
|
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
|
||||||
|
|
||||||
if (process.platform === "darwin") args.push("--include=*/rg")
|
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
|
||||||
if (process.platform === "linux") args.push("--wildcards", "*/rg")
|
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
|
||||||
|
|
||||||
const proc = Bun.spawn(args, {
|
const proc = Bun.spawn(args, {
|
||||||
cwd: Global.Path.bin,
|
cwd: Global.Path.bin,
|
||||||
@@ -100,7 +180,7 @@ export namespace Ripgrep {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
await fs.unlink(archivePath)
|
await fs.unlink(archivePath)
|
||||||
if (process.platform !== "win32") await fs.chmod(filepath, 0o755)
|
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -229,4 +309,45 @@ export namespace Ripgrep {
|
|||||||
|
|
||||||
return lines.join("\n")
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export namespace Format {
|
|||||||
const result = []
|
const result = []
|
||||||
for (const item of Object.values(Formatter)) {
|
for (const item of Object.values(Formatter)) {
|
||||||
if (!item.extensions.includes(ext)) continue
|
if (!item.extensions.includes(ext)) continue
|
||||||
if (!isEnabled(item)) continue
|
if (!(await isEnabled(item))) continue
|
||||||
result.push(item)
|
result.push(item)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Bus } from "../bus"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { LSPServer } from "./server"
|
import type { LSPServer } from "./server"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
|
import { withTimeout } from "../util/timeout"
|
||||||
|
|
||||||
export namespace LSPClient {
|
export namespace LSPClient {
|
||||||
const log = Log.create({ service: "lsp.client" })
|
const log = Log.create({ service: "lsp.client" })
|
||||||
@@ -52,7 +53,9 @@ export namespace LSPClient {
|
|||||||
log.info("textDocument/publishDiagnostics", {
|
log.info("textDocument/publishDiagnostics", {
|
||||||
path,
|
path,
|
||||||
})
|
})
|
||||||
|
const exists = diagnostics.has(path)
|
||||||
diagnostics.set(path, params.diagnostics)
|
diagnostics.set(path, params.diagnostics)
|
||||||
|
if (!exists && serverID === "typescript") return
|
||||||
Bus.publish(Event.Diagnostics, { path, serverID })
|
Bus.publish(Event.Diagnostics, { path, serverID })
|
||||||
})
|
})
|
||||||
connection.onRequest("workspace/configuration", async () => {
|
connection.onRequest("workspace/configuration", async () => {
|
||||||
@@ -61,7 +64,7 @@ export namespace LSPClient {
|
|||||||
connection.listen()
|
connection.listen()
|
||||||
|
|
||||||
log.info("sending initialize", { id: serverID })
|
log.info("sending initialize", { id: serverID })
|
||||||
await Promise.race([
|
await withTimeout(
|
||||||
connection.sendRequest("initialize", {
|
connection.sendRequest("initialize", {
|
||||||
processId: server.process.pid,
|
processId: server.process.pid,
|
||||||
workspaceFolders: [
|
workspaceFolders: [
|
||||||
@@ -88,12 +91,10 @@ export namespace LSPClient {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
new Promise((_, reject) => {
|
5_000,
|
||||||
setTimeout(() => {
|
).catch(() => {
|
||||||
reject(new InitializeError({ serverID }))
|
throw new InitializeError({ serverID })
|
||||||
}, 5_000)
|
})
|
||||||
}),
|
|
||||||
])
|
|
||||||
await connection.sendNotification("initialized", {})
|
await connection.sendNotification("initialized", {})
|
||||||
log.info("initialized")
|
log.info("initialized")
|
||||||
|
|
||||||
@@ -116,36 +117,28 @@ export namespace LSPClient {
|
|||||||
const file = Bun.file(input.path)
|
const file = Bun.file(input.path)
|
||||||
const text = await file.text()
|
const text = await file.text()
|
||||||
const version = files[input.path]
|
const version = files[input.path]
|
||||||
if (version === undefined) {
|
if (version !== undefined) {
|
||||||
log.info("textDocument/didOpen", input)
|
|
||||||
diagnostics.delete(input.path)
|
diagnostics.delete(input.path)
|
||||||
const extension = path.extname(input.path)
|
await connection.sendNotification("textDocument/didClose", {
|
||||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
|
||||||
await connection.sendNotification("textDocument/didOpen", {
|
|
||||||
textDocument: {
|
textDocument: {
|
||||||
uri: `file://` + input.path,
|
uri: `file://` + input.path,
|
||||||
languageId,
|
|
||||||
version: 0,
|
|
||||||
text,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
files[input.path] = 0
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
log.info("textDocument/didOpen", input)
|
||||||
log.info("textDocument/didChange", input)
|
|
||||||
diagnostics.delete(input.path)
|
diagnostics.delete(input.path)
|
||||||
await connection.sendNotification("textDocument/didChange", {
|
const extension = path.extname(input.path)
|
||||||
|
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||||
|
await connection.sendNotification("textDocument/didOpen", {
|
||||||
textDocument: {
|
textDocument: {
|
||||||
uri: `file://` + input.path,
|
uri: `file://` + input.path,
|
||||||
version: ++files[input.path],
|
languageId,
|
||||||
|
version: 0,
|
||||||
|
text,
|
||||||
},
|
},
|
||||||
contentChanges: [
|
|
||||||
{
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
files[input.path] = 0
|
||||||
|
return
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
get diagnostics() {
|
get diagnostics() {
|
||||||
@@ -157,35 +150,32 @@ export namespace LSPClient {
|
|||||||
: path.resolve(app.path.cwd, input.path)
|
: path.resolve(app.path.cwd, input.path)
|
||||||
log.info("waiting for diagnostics", input)
|
log.info("waiting for diagnostics", input)
|
||||||
let unsub: () => void
|
let unsub: () => void
|
||||||
let timeout: NodeJS.Timeout
|
return await withTimeout(
|
||||||
return await Promise.race([
|
new Promise<void>((resolve) => {
|
||||||
new Promise<void>(async (resolve) => {
|
|
||||||
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
|
||||||
if (
|
if (
|
||||||
event.properties.path === input.path &&
|
event.properties.path === input.path &&
|
||||||
event.properties.serverID === result.serverID
|
event.properties.serverID === result.serverID
|
||||||
) {
|
) {
|
||||||
log.info("got diagnostics", input)
|
log.info("got diagnostics", input)
|
||||||
clearTimeout(timeout)
|
|
||||||
unsub?.()
|
unsub?.()
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
new Promise<void>((resolve) => {
|
3000,
|
||||||
timeout = setTimeout(() => {
|
)
|
||||||
log.info("timed out refreshing diagnostics", input)
|
.catch(() => {})
|
||||||
unsub?.()
|
.finally(() => {
|
||||||
resolve()
|
unsub?.()
|
||||||
}, 5000)
|
})
|
||||||
}),
|
|
||||||
])
|
|
||||||
},
|
},
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
log.info("shutting down")
|
log.info("shutting down", { serverID })
|
||||||
connection.end()
|
connection.end()
|
||||||
connection.dispose()
|
connection.dispose()
|
||||||
server.process.kill("SIGTERM")
|
server.process.kill("SIGTERM")
|
||||||
|
log.info("shutdown", { serverID })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,59 @@ import { LSPClient } from "./client"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { LSPServer } from "./server"
|
import { LSPServer } from "./server"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
export namespace LSP {
|
export namespace LSP {
|
||||||
const log = Log.create({ service: "lsp" })
|
const log = Log.create({ service: "lsp" })
|
||||||
|
|
||||||
|
export const Symbol = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
kind: z.number(),
|
||||||
|
location: z.object({
|
||||||
|
uri: z.string(),
|
||||||
|
range: z.object({
|
||||||
|
start: z.object({
|
||||||
|
line: z.number(),
|
||||||
|
character: z.number(),
|
||||||
|
}),
|
||||||
|
end: z.object({
|
||||||
|
line: z.number(),
|
||||||
|
character: z.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.openapi({
|
||||||
|
ref: "LSP.Symbol",
|
||||||
|
})
|
||||||
|
export type Symbol = z.infer<typeof Symbol>
|
||||||
|
|
||||||
const state = App.state(
|
const state = App.state(
|
||||||
"lsp",
|
"lsp",
|
||||||
async () => {
|
async (app) => {
|
||||||
log.info("initializing")
|
log.info("initializing")
|
||||||
const clients = new Map<string, LSPClient.Info>()
|
const clients = new Map<string, LSPClient.Info>()
|
||||||
const skip = new Set<string>()
|
for (const server of Object.values(LSPServer)) {
|
||||||
|
for (const extension of server.extensions) {
|
||||||
|
const [file] = await Ripgrep.files({
|
||||||
|
cwd: app.path.cwd,
|
||||||
|
glob: "*" + extension,
|
||||||
|
})
|
||||||
|
if (!file) continue
|
||||||
|
const handle = await server.spawn(App.info())
|
||||||
|
if (!handle) break
|
||||||
|
const client = await LSPClient.create(server.id, handle).catch(
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
if (!client) break
|
||||||
|
clients.set(server.id, client)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("initialized")
|
||||||
return {
|
return {
|
||||||
clients,
|
clients,
|
||||||
skip,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async (state) => {
|
async (state) => {
|
||||||
@@ -27,49 +67,22 @@ export namespace LSP {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
log.info("init")
|
return state()
|
||||||
const app = App.info()
|
|
||||||
const result = Object.values(LSPServer).map(async (x) => {
|
|
||||||
for (const extension of x.extensions) {
|
|
||||||
const [file] = await Ripgrep.files({
|
|
||||||
cwd: app.path.cwd,
|
|
||||||
glob: "*" + extension,
|
|
||||||
})
|
|
||||||
if (!file) continue
|
|
||||||
await LSP.touchFile(file, true)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return Promise.all(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
|
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
|
||||||
const extension = path.parse(input).ext
|
const extension = path.parse(input).ext
|
||||||
const s = await state()
|
const matches = Object.values(LSPServer)
|
||||||
const matches = Object.values(LSPServer).filter((x) =>
|
.filter((x) => x.extensions.includes(extension))
|
||||||
x.extensions.includes(extension),
|
.map((x) => x.id)
|
||||||
)
|
await run(async (client) => {
|
||||||
for (const match of matches) {
|
if (!matches.includes(client.serverID)) return
|
||||||
const existing = s.clients.get(match.id)
|
const wait = waitForDiagnostics
|
||||||
if (existing) continue
|
? client.waitForDiagnostics({ path: input })
|
||||||
if (s.skip.has(match.id)) continue
|
: Promise.resolve()
|
||||||
s.skip.add(match.id)
|
await client.notify.open({ path: input })
|
||||||
const handle = await match.spawn(App.info())
|
return wait
|
||||||
if (!handle) continue
|
})
|
||||||
const client = await LSPClient.create(match.id, handle).catch(() => {})
|
|
||||||
if (!client) {
|
|
||||||
s.skip.add(match.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s.clients.set(match.id, client)
|
|
||||||
}
|
|
||||||
if (waitForDiagnostics) {
|
|
||||||
await run(async (client) => {
|
|
||||||
const wait = client.waitForDiagnostics({ path: input })
|
|
||||||
await client.notify.open({ path: input })
|
|
||||||
return wait
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function diagnostics() {
|
export async function diagnostics() {
|
||||||
@@ -107,7 +120,7 @@ export namespace LSP {
|
|||||||
client.connection.sendRequest("workspace/symbol", {
|
client.connection.sendRequest("workspace/symbol", {
|
||||||
query,
|
query,
|
||||||
}),
|
}),
|
||||||
)
|
).then((result) => result.flat() as LSP.Symbol[])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run<T>(
|
async function run<T>(
|
||||||
|
|||||||
@@ -99,11 +99,25 @@ export namespace Provider {
|
|||||||
})
|
})
|
||||||
info.access = tokens.access
|
info.access = tokens.access
|
||||||
}
|
}
|
||||||
|
let isAgentCall = false
|
||||||
|
try {
|
||||||
|
const body =
|
||||||
|
typeof init.body === "string"
|
||||||
|
? JSON.parse(init.body)
|
||||||
|
: init.body
|
||||||
|
if (body?.messages) {
|
||||||
|
isAgentCall = body.messages.some(
|
||||||
|
(msg: any) =>
|
||||||
|
msg.role && ["tool", "assistant"].includes(msg.role),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
const headers = {
|
const headers = {
|
||||||
...init.headers,
|
...init.headers,
|
||||||
...copilot.HEADERS,
|
...copilot.HEADERS,
|
||||||
Authorization: `Bearer ${info.access}`,
|
Authorization: `Bearer ${info.access}`,
|
||||||
"Openai-Intent": "conversation-edits",
|
"Openai-Intent": "conversation-edits",
|
||||||
|
"X-Initiator": isAgentCall ? "agent" : "user",
|
||||||
}
|
}
|
||||||
delete headers["x-api-key"]
|
delete headers["x-api-key"]
|
||||||
return fetch(input, {
|
return fetch(input, {
|
||||||
@@ -191,6 +205,17 @@ export namespace Provider {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openrouter: async () => {
|
||||||
|
return {
|
||||||
|
autoload: false,
|
||||||
|
options: {
|
||||||
|
headers: {
|
||||||
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
|
"X-Title": "opencode",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = App.state("provider", async () => {
|
const state = App.state("provider", async () => {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { NamedError } from "../util/error"
|
|||||||
import { ModelsDev } from "../provider/models"
|
import { ModelsDev } from "../provider/models"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
|
import { File } from "../file"
|
||||||
|
import { LSP } from "../lsp"
|
||||||
|
|
||||||
const ERRORS = {
|
const ERRORS = {
|
||||||
400: {
|
400: {
|
||||||
@@ -73,7 +75,7 @@ export namespace Server {
|
|||||||
documentation: {
|
documentation: {
|
||||||
info: {
|
info: {
|
||||||
title: "opencode",
|
title: "opencode",
|
||||||
version: "0.0.2",
|
version: "0.0.3",
|
||||||
description: "opencode api",
|
description: "opencode api",
|
||||||
},
|
},
|
||||||
openapi: "3.0.0",
|
openapi: "3.0.0",
|
||||||
@@ -492,12 +494,44 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
"/file",
|
"/find",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
description: "Search for files",
|
description: "Find text in files",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "Search for files",
|
description: "Matches",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(Ripgrep.Match.shape.data.array()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
pattern: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const app = App.info()
|
||||||
|
const pattern = c.req.valid("query").pattern
|
||||||
|
const result = await Ripgrep.search({
|
||||||
|
cwd: app.path.cwd,
|
||||||
|
pattern,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
return c.json(result)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/find/file",
|
||||||
|
describeRoute({
|
||||||
|
description: "Find files",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "File paths",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(z.string().array()),
|
schema: resolver(z.string().array()),
|
||||||
@@ -523,6 +557,98 @@ export namespace Server {
|
|||||||
return c.json(result)
|
return c.json(result)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.get(
|
||||||
|
"/find/symbol",
|
||||||
|
describeRoute({
|
||||||
|
description: "Find workspace symbols",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Symbols",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.unknown().array()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
query: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const query = c.req.valid("query").query
|
||||||
|
const result = await LSP.workspaceSymbol(query)
|
||||||
|
return c.json(result)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/file",
|
||||||
|
describeRoute({
|
||||||
|
description: "Read a file",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "File content",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
z.object({
|
||||||
|
type: z.enum(["raw", "patch"]),
|
||||||
|
content: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
path: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const path = c.req.valid("query").path
|
||||||
|
const content = await File.read(path)
|
||||||
|
log.info("read file", {
|
||||||
|
path,
|
||||||
|
content: content.content,
|
||||||
|
})
|
||||||
|
return c.json(content)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/file/status",
|
||||||
|
describeRoute({
|
||||||
|
description: "Get file status",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "File status",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
file: z.string(),
|
||||||
|
added: z.number().int(),
|
||||||
|
removed: z.number().int(),
|
||||||
|
status: z.enum(["added", "deleted", "modified"]),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const content = await File.status()
|
||||||
|
return c.json(content)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type { ModelsDev } from "../provider/models"
|
|||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { ProviderTransform } from "../provider/transform"
|
import { ProviderTransform } from "../provider/transform"
|
||||||
|
import { Snapshot } from "../snapshot"
|
||||||
|
|
||||||
export namespace Session {
|
export namespace Session {
|
||||||
const log = Log.create({ service: "session" })
|
const log = Log.create({ service: "session" })
|
||||||
@@ -53,6 +54,13 @@ export namespace Session {
|
|||||||
created: z.number(),
|
created: z.number(),
|
||||||
updated: z.number(),
|
updated: z.number(),
|
||||||
}),
|
}),
|
||||||
|
revert: z
|
||||||
|
.object({
|
||||||
|
messageID: z.string(),
|
||||||
|
part: z.number(),
|
||||||
|
snapshot: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Session",
|
ref: "Session",
|
||||||
@@ -285,6 +293,37 @@ export namespace Session {
|
|||||||
l.info("chatting")
|
l.info("chatting")
|
||||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||||
let msgs = await messages(input.sessionID)
|
let msgs = await messages(input.sessionID)
|
||||||
|
const session = await get(input.sessionID)
|
||||||
|
|
||||||
|
if (session.revert) {
|
||||||
|
const trimmed = []
|
||||||
|
for (const msg of msgs) {
|
||||||
|
if (
|
||||||
|
msg.id > session.revert.messageID ||
|
||||||
|
(msg.id === session.revert.messageID && session.revert.part === 0)
|
||||||
|
) {
|
||||||
|
await Storage.remove(
|
||||||
|
"session/message/" + input.sessionID + "/" + msg.id,
|
||||||
|
)
|
||||||
|
await Bus.publish(Message.Event.Removed, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: msg.id,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.id === session.revert.messageID) {
|
||||||
|
if (session.revert.part === 0) break
|
||||||
|
msg.parts = msg.parts.slice(0, session.revert.part)
|
||||||
|
}
|
||||||
|
trimmed.push(msg)
|
||||||
|
}
|
||||||
|
msgs = trimmed
|
||||||
|
await update(input.sessionID, (draft) => {
|
||||||
|
draft.revert = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const previous = msgs.at(-1)
|
const previous = msgs.at(-1)
|
||||||
|
|
||||||
// auto summarize if too long
|
// auto summarize if too long
|
||||||
@@ -319,7 +358,6 @@ export namespace Session {
|
|||||||
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
|
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
|
||||||
|
|
||||||
const app = App.info()
|
const app = App.info()
|
||||||
const session = await get(input.sessionID)
|
|
||||||
if (msgs.length === 0 && !session.parentID) {
|
if (msgs.length === 0 && !session.parentID) {
|
||||||
generateText({
|
generateText({
|
||||||
maxTokens: input.providerID === "google" ? 1024 : 20,
|
maxTokens: input.providerID === "google" ? 1024 : 20,
|
||||||
@@ -349,6 +387,7 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
const snapshot = await Snapshot.create(input.sessionID)
|
||||||
const msg: Message.Info = {
|
const msg: Message.Info = {
|
||||||
role: "user",
|
role: "user",
|
||||||
id: Identifier.ascending("message"),
|
id: Identifier.ascending("message"),
|
||||||
@@ -359,6 +398,7 @@ export namespace Session {
|
|||||||
},
|
},
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
tool: {},
|
tool: {},
|
||||||
|
snapshot,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
await updateMessage(msg)
|
await updateMessage(msg)
|
||||||
@@ -373,6 +413,7 @@ export namespace Session {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [],
|
parts: [],
|
||||||
metadata: {
|
metadata: {
|
||||||
|
snapshot,
|
||||||
assistant: {
|
assistant: {
|
||||||
system,
|
system,
|
||||||
path: {
|
path: {
|
||||||
@@ -424,6 +465,7 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
next.metadata!.tool![opts.toolCallId] = {
|
next.metadata!.tool![opts.toolCallId] = {
|
||||||
...result.metadata,
|
...result.metadata,
|
||||||
|
snapshot: await Snapshot.create(input.sessionID),
|
||||||
time: {
|
time: {
|
||||||
start,
|
start,
|
||||||
end: Date.now(),
|
end: Date.now(),
|
||||||
@@ -436,6 +478,7 @@ export namespace Session {
|
|||||||
error: true,
|
error: true,
|
||||||
message: e.toString(),
|
message: e.toString(),
|
||||||
title: e.toString(),
|
title: e.toString(),
|
||||||
|
snapshot: await Snapshot.create(input.sessionID),
|
||||||
time: {
|
time: {
|
||||||
start,
|
start,
|
||||||
end: Date.now(),
|
end: Date.now(),
|
||||||
@@ -457,6 +500,7 @@ export namespace Session {
|
|||||||
const result = await execute(args, opts)
|
const result = await execute(args, opts)
|
||||||
next.metadata!.tool![opts.toolCallId] = {
|
next.metadata!.tool![opts.toolCallId] = {
|
||||||
...result.metadata,
|
...result.metadata,
|
||||||
|
snapshot: await Snapshot.create(input.sessionID),
|
||||||
time: {
|
time: {
|
||||||
start,
|
start,
|
||||||
end: Date.now(),
|
end: Date.now(),
|
||||||
@@ -471,6 +515,7 @@ export namespace Session {
|
|||||||
next.metadata!.tool![opts.toolCallId] = {
|
next.metadata!.tool![opts.toolCallId] = {
|
||||||
error: true,
|
error: true,
|
||||||
message: e.toString(),
|
message: e.toString(),
|
||||||
|
snapshot: await Snapshot.create(input.sessionID),
|
||||||
title: "mcp",
|
title: "mcp",
|
||||||
time: {
|
time: {
|
||||||
start,
|
start,
|
||||||
@@ -735,6 +780,51 @@ export namespace Session {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function revert(input: {
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
part: number
|
||||||
|
}) {
|
||||||
|
const message = await getMessage(input.sessionID, input.messageID)
|
||||||
|
if (!message) return
|
||||||
|
const part = message.parts[input.part]
|
||||||
|
if (!part) return
|
||||||
|
const session = await get(input.sessionID)
|
||||||
|
const snapshot =
|
||||||
|
session.revert?.snapshot ?? (await Snapshot.create(input.sessionID))
|
||||||
|
const old = (() => {
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
const lastTool = message.parts.findLast(
|
||||||
|
(part, index) =>
|
||||||
|
part.type === "tool-invocation" && index < input.part,
|
||||||
|
)
|
||||||
|
if (lastTool && lastTool.type === "tool-invocation")
|
||||||
|
return message.metadata.tool[lastTool.toolInvocation.toolCallId]
|
||||||
|
.snapshot
|
||||||
|
}
|
||||||
|
return message.metadata.snapshot
|
||||||
|
})()
|
||||||
|
if (old) await Snapshot.restore(input.sessionID, old)
|
||||||
|
await update(input.sessionID, (draft) => {
|
||||||
|
draft.revert = {
|
||||||
|
messageID: input.messageID,
|
||||||
|
part: input.part,
|
||||||
|
snapshot,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unrevert(sessionID: string) {
|
||||||
|
const session = await get(sessionID)
|
||||||
|
if (!session) return
|
||||||
|
if (!session.revert) return
|
||||||
|
if (session.revert.snapshot)
|
||||||
|
await Snapshot.restore(sessionID, session.revert.snapshot)
|
||||||
|
update(sessionID, (draft) => {
|
||||||
|
draft.revert = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function summarize(input: {
|
export async function summarize(input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
providerID: string
|
providerID: string
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export namespace Message {
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
|
snapshot: z.string().optional(),
|
||||||
time: z.object({
|
time: z.object({
|
||||||
start: z.number(),
|
start: z.number(),
|
||||||
end: z.number(),
|
end: z.number(),
|
||||||
@@ -188,6 +189,7 @@ export namespace Message {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
snapshot: z.string().optional(),
|
||||||
})
|
})
|
||||||
.openapi({ ref: "MessageMetadata" }),
|
.openapi({ ref: "MessageMetadata" }),
|
||||||
})
|
})
|
||||||
@@ -203,6 +205,13 @@ export namespace Message {
|
|||||||
info: Info,
|
info: Info,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
Removed: Bus.event(
|
||||||
|
"message.removed",
|
||||||
|
z.object({
|
||||||
|
sessionID: z.string(),
|
||||||
|
messageID: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
PartUpdated: Bus.event(
|
PartUpdated: Bus.event(
|
||||||
"message.part.updated",
|
"message.part.updated",
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ export const ReadTool = Tool.define({
|
|||||||
output += "\n</file>"
|
output += "\n</file>"
|
||||||
|
|
||||||
// just warms the lsp client
|
// just warms the lsp client
|
||||||
await LSP.touchFile(filePath, true)
|
await LSP.touchFile(filePath, false)
|
||||||
FileTime.read(ctx.sessionID, filePath)
|
FileTime.read(ctx.sessionID, filePath)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Usage:
|
|||||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||||
- Any lines longer than 2000 characters will be truncated
|
- Any lines longer than 2000 characters will be truncated
|
||||||
- Results are returned using cat -n format, with line numbers starting at 1
|
- Results are returned using cat -n format, with line numbers starting at 1
|
||||||
- This tool allows OpenCode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as OpenCode is a multimodal LLM.
|
- This tool allows opencode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as opencode is a multimodal LLM.
|
||||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
|
||||||
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
|
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png
|
||||||
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export function lazy<T>(fn: () => T) {
|
|||||||
|
|
||||||
return (): T => {
|
return (): T => {
|
||||||
if (loaded) return value as T
|
if (loaded) return value as T
|
||||||
|
loaded = true
|
||||||
value = fn()
|
value = fn()
|
||||||
return value as T
|
return value as T
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
+1
-1
@@ -15,7 +15,7 @@ require (
|
|||||||
github.com/muesli/reflow v0.3.0
|
github.com/muesli/reflow v0.3.0
|
||||||
github.com/muesli/termenv v0.16.0
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
||||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.7
|
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
|
||||||
github.com/tidwall/gjson v1.14.4
|
github.com/tidwall/gjson v1.14.4
|
||||||
rsc.io/qr v0.2.0
|
rsc.io/qr v0.2.0
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -181,8 +181,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
|||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.7 h1:trfzTMn9o/h2fxE4z+BtJPZvCTdVHjwgXnAH/rTAx0I=
|
github.com/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw=
|
||||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.7/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
|
github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ import (
|
|||||||
"github.com/sst/opencode/internal/util"
|
"github.com/sst/opencode/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
var RootPath string
|
|
||||||
var CwdPath string
|
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
Info opencode.App
|
Info opencode.App
|
||||||
Version string
|
Version string
|
||||||
@@ -38,6 +35,7 @@ type App struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SessionSelectedMsg = *opencode.Session
|
type SessionSelectedMsg = *opencode.Session
|
||||||
|
type SessionLoadedMsg struct{}
|
||||||
type ModelSelectedMsg struct {
|
type ModelSelectedMsg struct {
|
||||||
Provider opencode.Provider
|
Provider opencode.Provider
|
||||||
Model opencode.Model
|
Model opencode.Model
|
||||||
@@ -54,6 +52,9 @@ type CompletionDialogTriggeredMsg struct {
|
|||||||
type OptimisticMessageAddedMsg struct {
|
type OptimisticMessageAddedMsg struct {
|
||||||
Message opencode.Message
|
Message opencode.Message
|
||||||
}
|
}
|
||||||
|
type FileRenderedMsg struct {
|
||||||
|
FilePath string
|
||||||
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -61,8 +62,8 @@ func New(
|
|||||||
appInfo opencode.App,
|
appInfo opencode.App,
|
||||||
httpClient *opencode.Client,
|
httpClient *opencode.Client,
|
||||||
) (*App, error) {
|
) (*App, error) {
|
||||||
RootPath = appInfo.Path.Root
|
util.RootPath = appInfo.Path.Root
|
||||||
CwdPath = appInfo.Path.Cwd
|
util.CwdPath = appInfo.Path.Cwd
|
||||||
|
|
||||||
configInfo, err := httpClient.Config.Get(ctx)
|
configInfo, err := httpClient.Config.Get(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,6 +126,19 @@ func New(
|
|||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) Key(commandName commands.CommandName) string {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
|
||||||
|
muted := styles.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Faint(true).Render
|
||||||
|
command := a.Commands[commandName]
|
||||||
|
kb := command.Keybindings[0]
|
||||||
|
key := kb.Key
|
||||||
|
if kb.RequiresLeader {
|
||||||
|
key = a.Config.Keybinds.Leader + " " + kb.Key
|
||||||
|
}
|
||||||
|
return base(key) + muted(" "+command.Description)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) InitializeProvider() tea.Cmd {
|
func (a *App) InitializeProvider() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
providersResponse, err := a.Client.Config.Providers(context.Background())
|
providersResponse, err := a.Client.Config.Providers(context.Background())
|
||||||
|
|||||||
@@ -80,13 +80,15 @@ const (
|
|||||||
ToolDetailsCommand CommandName = "tool_details"
|
ToolDetailsCommand CommandName = "tool_details"
|
||||||
ModelListCommand CommandName = "model_list"
|
ModelListCommand CommandName = "model_list"
|
||||||
ThemeListCommand CommandName = "theme_list"
|
ThemeListCommand CommandName = "theme_list"
|
||||||
|
FileListCommand CommandName = "file_list"
|
||||||
|
FileCloseCommand CommandName = "file_close"
|
||||||
|
FileSearchCommand CommandName = "file_search"
|
||||||
|
FileDiffToggleCommand CommandName = "file_diff_toggle"
|
||||||
ProjectInitCommand CommandName = "project_init"
|
ProjectInitCommand CommandName = "project_init"
|
||||||
InputClearCommand CommandName = "input_clear"
|
InputClearCommand CommandName = "input_clear"
|
||||||
InputPasteCommand CommandName = "input_paste"
|
InputPasteCommand CommandName = "input_paste"
|
||||||
InputSubmitCommand CommandName = "input_submit"
|
InputSubmitCommand CommandName = "input_submit"
|
||||||
InputNewlineCommand CommandName = "input_newline"
|
InputNewlineCommand CommandName = "input_newline"
|
||||||
HistoryPreviousCommand CommandName = "history_previous"
|
|
||||||
HistoryNextCommand CommandName = "history_next"
|
|
||||||
MessagesPageUpCommand CommandName = "messages_page_up"
|
MessagesPageUpCommand CommandName = "messages_page_up"
|
||||||
MessagesPageDownCommand CommandName = "messages_page_down"
|
MessagesPageDownCommand CommandName = "messages_page_down"
|
||||||
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
|
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
|
||||||
@@ -95,6 +97,9 @@ const (
|
|||||||
MessagesNextCommand CommandName = "messages_next"
|
MessagesNextCommand CommandName = "messages_next"
|
||||||
MessagesFirstCommand CommandName = "messages_first"
|
MessagesFirstCommand CommandName = "messages_first"
|
||||||
MessagesLastCommand CommandName = "messages_last"
|
MessagesLastCommand CommandName = "messages_last"
|
||||||
|
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
|
||||||
|
MessagesCopyCommand CommandName = "messages_copy"
|
||||||
|
MessagesRevertCommand CommandName = "messages_revert"
|
||||||
AppExitCommand CommandName = "app_exit"
|
AppExitCommand CommandName = "app_exit"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -184,6 +189,27 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
|||||||
Keybindings: parseBindings("<leader>t"),
|
Keybindings: parseBindings("<leader>t"),
|
||||||
Trigger: "themes",
|
Trigger: "themes",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: FileListCommand,
|
||||||
|
Description: "list files",
|
||||||
|
Keybindings: parseBindings("<leader>f"),
|
||||||
|
Trigger: "files",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: FileCloseCommand,
|
||||||
|
Description: "close file",
|
||||||
|
Keybindings: parseBindings("esc"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: FileSearchCommand,
|
||||||
|
Description: "search file",
|
||||||
|
Keybindings: parseBindings("<leader>/"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: FileDiffToggleCommand,
|
||||||
|
Description: "split/unified diff",
|
||||||
|
Keybindings: parseBindings("<leader>v"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: ProjectInitCommand,
|
Name: ProjectInitCommand,
|
||||||
Description: "create/update AGENTS.md",
|
Description: "create/update AGENTS.md",
|
||||||
@@ -210,16 +236,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
|||||||
Description: "insert newline",
|
Description: "insert newline",
|
||||||
Keybindings: parseBindings("shift+enter", "ctrl+j"),
|
Keybindings: parseBindings("shift+enter", "ctrl+j"),
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// Name: HistoryPreviousCommand,
|
|
||||||
// Description: "previous prompt",
|
|
||||||
// Keybindings: parseBindings("up"),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// Name: HistoryNextCommand,
|
|
||||||
// Description: "next prompt",
|
|
||||||
// Keybindings: parseBindings("down"),
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
Name: MessagesPageUpCommand,
|
Name: MessagesPageUpCommand,
|
||||||
Description: "page up",
|
Description: "page up",
|
||||||
@@ -243,12 +259,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
|||||||
{
|
{
|
||||||
Name: MessagesPreviousCommand,
|
Name: MessagesPreviousCommand,
|
||||||
Description: "previous message",
|
Description: "previous message",
|
||||||
Keybindings: parseBindings("ctrl+alt+k"),
|
Keybindings: parseBindings("ctrl+up"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: MessagesNextCommand,
|
Name: MessagesNextCommand,
|
||||||
Description: "next message",
|
Description: "next message",
|
||||||
Keybindings: parseBindings("ctrl+alt+j"),
|
Keybindings: parseBindings("ctrl+down"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: MessagesFirstCommand,
|
Name: MessagesFirstCommand,
|
||||||
@@ -260,6 +276,21 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
|||||||
Description: "last message",
|
Description: "last message",
|
||||||
Keybindings: parseBindings("ctrl+alt+g"),
|
Keybindings: parseBindings("ctrl+alt+g"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: MessagesLayoutToggleCommand,
|
||||||
|
Description: "toggle layout",
|
||||||
|
Keybindings: parseBindings("<leader>p"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: MessagesCopyCommand,
|
||||||
|
Description: "copy message",
|
||||||
|
Keybindings: parseBindings("<leader>y"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: MessagesRevertCommand,
|
||||||
|
Description: "revert message",
|
||||||
|
Keybindings: parseBindings("<leader>u"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: AppExitCommand,
|
Name: AppExitCommand,
|
||||||
Description: "exit the app",
|
Description: "exit the app",
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ func (c *CommandCompletionProvider) GetId() string {
|
|||||||
return "commands"
|
return "commands"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
|
|
||||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
|
||||||
Title: "Commands",
|
|
||||||
Value: "commands",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
||||||
return "no matching commands"
|
return "no matching commands"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,64 +2,108 @@ package completions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/sst/opencode-sdk-go"
|
"github.com/sst/opencode-sdk-go"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/components/dialog"
|
"github.com/sst/opencode/internal/components/dialog"
|
||||||
|
"github.com/sst/opencode/internal/styles"
|
||||||
|
"github.com/sst/opencode/internal/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
type filesAndFoldersContextGroup struct {
|
type filesAndFoldersContextGroup struct {
|
||||||
app *app.App
|
app *app.App
|
||||||
prefix string
|
prefix string
|
||||||
|
gitFiles []dialog.CompletionItemI
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) GetId() string {
|
func (cg *filesAndFoldersContextGroup) GetId() string {
|
||||||
return cg.prefix
|
return cg.prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
|
|
||||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
|
||||||
Title: "Files & Folders",
|
|
||||||
Value: "files",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
|
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
|
||||||
return "no matching files"
|
return "no matching files"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
|
func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
|
||||||
files, err := cg.app.Client.File.Search(
|
t := theme.CurrentTheme()
|
||||||
context.Background(),
|
items := make([]dialog.CompletionItemI, 0)
|
||||||
opencode.FileSearchParams{Query: opencode.F(query)},
|
base := styles.NewStyle().Background(t.BackgroundElement())
|
||||||
)
|
green := base.Foreground(t.Success()).Render
|
||||||
if err != nil {
|
red := base.Foreground(t.Error()).Render
|
||||||
return []string{}, err
|
|
||||||
|
status, _ := cg.app.Client.File.Status(context.Background())
|
||||||
|
if status != nil {
|
||||||
|
files := *status
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
return files[i].Added+files[i].Removed > files[j].Added+files[j].Removed
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
title := file.File
|
||||||
|
if file.Added > 0 {
|
||||||
|
title += green(" +" + strconv.Itoa(int(file.Added)))
|
||||||
|
}
|
||||||
|
if file.Removed > 0 {
|
||||||
|
title += red(" -" + strconv.Itoa(int(file.Removed)))
|
||||||
|
}
|
||||||
|
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
||||||
|
Title: title,
|
||||||
|
Value: file.File,
|
||||||
|
})
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return *files, nil
|
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
||||||
matches, err := cg.getFiles(query)
|
items := make([]dialog.CompletionItemI, 0)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
query = strings.TrimSpace(query)
|
||||||
|
if query == "" {
|
||||||
|
items = append(items, cg.gitFiles...)
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]dialog.CompletionItemI, 0, len(matches))
|
files, err := cg.app.Client.Find.Files(
|
||||||
for _, file := range matches {
|
context.Background(),
|
||||||
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
opencode.FindFilesParams{Query: opencode.F(query)},
|
||||||
Title: file,
|
)
|
||||||
Value: file,
|
if err != nil {
|
||||||
})
|
slog.Error("Failed to get completion items", "error", err)
|
||||||
items = append(items, item)
|
}
|
||||||
|
|
||||||
|
for _, file := range *files {
|
||||||
|
exists := false
|
||||||
|
for _, existing := range cg.gitFiles {
|
||||||
|
if existing.GetValue() == file {
|
||||||
|
if query != "" {
|
||||||
|
items = append(items, existing)
|
||||||
|
}
|
||||||
|
exists = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
||||||
|
Title: file,
|
||||||
|
Value: file,
|
||||||
|
})
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
|
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
|
||||||
return &filesAndFoldersContextGroup{
|
cg := &filesAndFoldersContextGroup{
|
||||||
app: app,
|
app: app,
|
||||||
prefix: "file",
|
prefix: "file",
|
||||||
}
|
}
|
||||||
|
cg.gitFiles = cg.getGitFiles()
|
||||||
|
return cg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"github.com/sst/opencode/internal/components/dialog"
|
"github.com/sst/opencode/internal/components/dialog"
|
||||||
"github.com/sst/opencode/internal/components/textarea"
|
"github.com/sst/opencode/internal/components/textarea"
|
||||||
"github.com/sst/opencode/internal/image"
|
"github.com/sst/opencode/internal/image"
|
||||||
"github.com/sst/opencode/internal/layout"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/internal/util"
|
"github.com/sst/opencode/internal/util"
|
||||||
@@ -21,9 +20,8 @@ import (
|
|||||||
|
|
||||||
type EditorComponent interface {
|
type EditorComponent interface {
|
||||||
tea.Model
|
tea.Model
|
||||||
tea.ViewModel
|
View(width int) string
|
||||||
SetSize(width, height int) tea.Cmd
|
Content(width int) string
|
||||||
Content() string
|
|
||||||
Lines() int
|
Lines() int
|
||||||
Value() string
|
Value() string
|
||||||
Focused() bool
|
Focused() bool
|
||||||
@@ -33,19 +31,13 @@ type EditorComponent interface {
|
|||||||
Clear() (tea.Model, tea.Cmd)
|
Clear() (tea.Model, tea.Cmd)
|
||||||
Paste() (tea.Model, tea.Cmd)
|
Paste() (tea.Model, tea.Cmd)
|
||||||
Newline() (tea.Model, tea.Cmd)
|
Newline() (tea.Model, tea.Cmd)
|
||||||
Previous() (tea.Model, tea.Cmd)
|
|
||||||
Next() (tea.Model, tea.Cmd)
|
|
||||||
SetInterruptKeyInDebounce(inDebounce bool)
|
SetInterruptKeyInDebounce(inDebounce bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
type editorComponent struct {
|
type editorComponent struct {
|
||||||
app *app.App
|
app *app.App
|
||||||
width, height int
|
|
||||||
textarea textarea.Model
|
textarea textarea.Model
|
||||||
attachments []app.Attachment
|
attachments []app.Attachment
|
||||||
history []string
|
|
||||||
historyIndex int
|
|
||||||
currentMessage string
|
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
interruptKeyInDebounce bool
|
interruptKeyInDebounce bool
|
||||||
}
|
}
|
||||||
@@ -105,7 +97,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Content() string {
|
func (m *editorComponent) Content(width int) string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||||
@@ -114,6 +106,7 @@ func (m *editorComponent) Content() string {
|
|||||||
Bold(true)
|
Bold(true)
|
||||||
prompt := promptStyle.Render(">")
|
prompt := promptStyle.Render(">")
|
||||||
|
|
||||||
|
m.textarea.SetWidth(width - 6)
|
||||||
textarea := lipgloss.JoinHorizontal(
|
textarea := lipgloss.JoinHorizontal(
|
||||||
lipgloss.Top,
|
lipgloss.Top,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -121,7 +114,7 @@ func (m *editorComponent) Content() string {
|
|||||||
)
|
)
|
||||||
textarea = styles.NewStyle().
|
textarea = styles.NewStyle().
|
||||||
Background(t.BackgroundElement()).
|
Background(t.BackgroundElement()).
|
||||||
Width(m.width).
|
Width(width).
|
||||||
PaddingTop(1).
|
PaddingTop(1).
|
||||||
PaddingBottom(1).
|
PaddingBottom(1).
|
||||||
BorderStyle(lipgloss.ThickBorder()).
|
BorderStyle(lipgloss.ThickBorder()).
|
||||||
@@ -146,7 +139,7 @@ func (m *editorComponent) Content() string {
|
|||||||
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
|
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
|
space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
|
||||||
spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
|
spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
|
||||||
|
|
||||||
info := hint + spacer + model
|
info := hint + spacer + model
|
||||||
@@ -156,19 +149,18 @@ func (m *editorComponent) Content() string {
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) View() string {
|
func (m *editorComponent) View(width int) string {
|
||||||
if m.Lines() > 1 {
|
if m.Lines() > 1 {
|
||||||
t := theme.CurrentTheme()
|
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.width,
|
width,
|
||||||
m.height,
|
5,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
"",
|
"",
|
||||||
styles.WhitespaceStyle(t.Background()),
|
styles.WhitespaceStyle(theme.CurrentTheme().Background()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return m.Content()
|
return m.Content(width)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Focused() bool {
|
func (m *editorComponent) Focused() bool {
|
||||||
@@ -183,16 +175,6 @@ func (m *editorComponent) Blur() {
|
|||||||
m.textarea.Blur()
|
m.textarea.Blur()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) GetSize() (width, height int) {
|
|
||||||
return m.width, m.height
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
|
|
||||||
m.width = width
|
|
||||||
m.height = height
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *editorComponent) Lines() int {
|
func (m *editorComponent) Lines() int {
|
||||||
return m.textarea.LineCount()
|
return m.textarea.LineCount()
|
||||||
}
|
}
|
||||||
@@ -218,16 +200,6 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
|||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
attachments := m.attachments
|
attachments := m.attachments
|
||||||
|
|
||||||
// Save to history if not empty and not a duplicate of the last entry
|
|
||||||
if value != "" {
|
|
||||||
if len(m.history) == 0 || m.history[len(m.history)-1] != value {
|
|
||||||
m.history = append(m.history, value)
|
|
||||||
}
|
|
||||||
m.historyIndex = len(m.history)
|
|
||||||
m.currentMessage = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
m.attachments = nil
|
m.attachments = nil
|
||||||
|
|
||||||
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
|
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
|
||||||
@@ -260,48 +232,6 @@ func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
|
|
||||||
currentLine := m.textarea.Line()
|
|
||||||
|
|
||||||
// Only navigate history if we're at the first line
|
|
||||||
if currentLine == 0 && len(m.history) > 0 {
|
|
||||||
// Save current message if we're just starting to navigate
|
|
||||||
if m.historyIndex == len(m.history) {
|
|
||||||
m.currentMessage = m.textarea.Value()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go to previous message in history
|
|
||||||
if m.historyIndex > 0 {
|
|
||||||
m.historyIndex--
|
|
||||||
m.textarea.SetValue(m.history[m.historyIndex])
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
|
|
||||||
currentLine := m.textarea.Line()
|
|
||||||
value := m.textarea.Value()
|
|
||||||
lines := strings.Split(value, "\n")
|
|
||||||
totalLines := len(lines)
|
|
||||||
|
|
||||||
// Only navigate history if we're at the last line
|
|
||||||
if currentLine == totalLines-1 {
|
|
||||||
if m.historyIndex < len(m.history)-1 {
|
|
||||||
// Go to next message in history
|
|
||||||
m.historyIndex++
|
|
||||||
m.textarea.SetValue(m.history[m.historyIndex])
|
|
||||||
} else if m.historyIndex == len(m.history)-1 {
|
|
||||||
// Return to the current message being composed
|
|
||||||
m.historyIndex = len(m.history)
|
|
||||||
m.textarea.SetValue(m.currentMessage)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
|
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
|
||||||
m.interruptKeyInDebounce = inDebounce
|
m.interruptKeyInDebounce = inDebounce
|
||||||
}
|
}
|
||||||
@@ -335,7 +265,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
|||||||
ta.Prompt = " "
|
ta.Prompt = " "
|
||||||
ta.ShowLineNumbers = false
|
ta.ShowLineNumbers = false
|
||||||
ta.CharLimit = -1
|
ta.CharLimit = -1
|
||||||
ta.SetWidth(layout.Current.Container.Width - 6)
|
|
||||||
|
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
ta.SetValue(existing.Value())
|
ta.SetValue(existing.Value())
|
||||||
@@ -343,7 +272,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
|||||||
ta.SetHeight(existing.Height())
|
ta.SetHeight(existing.Height())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ta.Focus()
|
|
||||||
return ta
|
return ta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,9 +296,6 @@ func NewEditorComponent(app *app.App) EditorComponent {
|
|||||||
return &editorComponent{
|
return &editorComponent{
|
||||||
app: app,
|
app: app,
|
||||||
textarea: ta,
|
textarea: ta,
|
||||||
history: []string{},
|
|
||||||
historyIndex: 0,
|
|
||||||
currentMessage: "",
|
|
||||||
spinner: s,
|
spinner: s,
|
||||||
interruptKeyInDebounce: false,
|
interruptKeyInDebounce: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,65 +3,46 @@ package chat
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||||
"github.com/charmbracelet/x/ansi"
|
|
||||||
"github.com/sst/opencode-sdk-go"
|
"github.com/sst/opencode-sdk-go"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
|
"github.com/sst/opencode/internal/commands"
|
||||||
"github.com/sst/opencode/internal/components/diff"
|
"github.com/sst/opencode/internal/components/diff"
|
||||||
"github.com/sst/opencode/internal/layout"
|
"github.com/sst/opencode/internal/layout"
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
|
"github.com/sst/opencode/internal/util"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"golang.org/x/text/cases"
|
"golang.org/x/text/cases"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
||||||
func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
|
|
||||||
r := styles.GetMarkdownRenderer(width-7, backgroundColor)
|
|
||||||
content = strings.ReplaceAll(content, app.RootPath+"/", "")
|
|
||||||
rendered, _ := r.Render(content)
|
|
||||||
lines := strings.Split(rendered, "\n")
|
|
||||||
|
|
||||||
if len(lines) > 0 {
|
|
||||||
firstLine := lines[0]
|
|
||||||
cleaned := ansi.Strip(firstLine)
|
|
||||||
nospace := strings.ReplaceAll(cleaned, " ", "")
|
|
||||||
if nospace == "" {
|
|
||||||
lines = lines[1:]
|
|
||||||
}
|
|
||||||
if len(lines) > 0 {
|
|
||||||
lastLine := lines[len(lines)-1]
|
|
||||||
cleaned = ansi.Strip(lastLine)
|
|
||||||
nospace = strings.ReplaceAll(cleaned, " ", "")
|
|
||||||
if nospace == "" {
|
|
||||||
lines = lines[:len(lines)-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content = strings.Join(lines, "\n")
|
|
||||||
return strings.TrimSuffix(content, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
type blockRenderer struct {
|
type blockRenderer struct {
|
||||||
border bool
|
textColor compat.AdaptiveColor
|
||||||
borderColor *compat.AdaptiveColor
|
border bool
|
||||||
paddingTop int
|
borderColor *compat.AdaptiveColor
|
||||||
paddingBottom int
|
borderColorRight bool
|
||||||
paddingLeft int
|
paddingTop int
|
||||||
paddingRight int
|
paddingBottom int
|
||||||
marginTop int
|
paddingLeft int
|
||||||
marginBottom int
|
paddingRight int
|
||||||
|
marginTop int
|
||||||
|
marginBottom int
|
||||||
}
|
}
|
||||||
|
|
||||||
type renderingOption func(*blockRenderer)
|
type renderingOption func(*blockRenderer)
|
||||||
|
|
||||||
|
func WithTextColor(color compat.AdaptiveColor) renderingOption {
|
||||||
|
return func(c *blockRenderer) {
|
||||||
|
c.textColor = color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func WithNoBorder() renderingOption {
|
func WithNoBorder() renderingOption {
|
||||||
return func(c *blockRenderer) {
|
return func(c *blockRenderer) {
|
||||||
c.border = false
|
c.border = false
|
||||||
@@ -74,6 +55,13 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithBorderColorRight(color compat.AdaptiveColor) renderingOption {
|
||||||
|
return func(c *blockRenderer) {
|
||||||
|
c.borderColorRight = true
|
||||||
|
c.borderColor = &color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func WithMarginTop(padding int) renderingOption {
|
func WithMarginTop(padding int) renderingOption {
|
||||||
return func(c *blockRenderer) {
|
return func(c *blockRenderer) {
|
||||||
c.marginTop = padding
|
c.marginTop = padding
|
||||||
@@ -120,13 +108,15 @@ func WithPaddingBottom(padding int) renderingOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderContentBlock(
|
func renderContentBlock(
|
||||||
|
app *app.App,
|
||||||
content string,
|
content string,
|
||||||
|
highlight bool,
|
||||||
width int,
|
width int,
|
||||||
align lipgloss.Position,
|
|
||||||
options ...renderingOption,
|
options ...renderingOption,
|
||||||
) string {
|
) string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
renderer := &blockRenderer{
|
renderer := &blockRenderer{
|
||||||
|
textColor: t.TextMuted(),
|
||||||
border: true,
|
border: true,
|
||||||
paddingTop: 1,
|
paddingTop: 1,
|
||||||
paddingBottom: 1,
|
paddingBottom: 1,
|
||||||
@@ -143,7 +133,7 @@ func renderContentBlock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
style := styles.NewStyle().
|
style := styles.NewStyle().
|
||||||
Foreground(t.TextMuted()).
|
Foreground(renderer.textColor).
|
||||||
Background(t.BackgroundPanel()).
|
Background(t.BackgroundPanel()).
|
||||||
Width(width).
|
Width(width).
|
||||||
PaddingTop(renderer.paddingTop).
|
PaddingTop(renderer.paddingTop).
|
||||||
@@ -161,21 +151,32 @@ func renderContentBlock(
|
|||||||
BorderLeftBackground(t.Background()).
|
BorderLeftBackground(t.Background()).
|
||||||
BorderRightForeground(t.BackgroundPanel()).
|
BorderRightForeground(t.BackgroundPanel()).
|
||||||
BorderRightBackground(t.Background())
|
BorderRightBackground(t.Background())
|
||||||
|
|
||||||
|
if renderer.borderColorRight {
|
||||||
|
style = style.
|
||||||
|
BorderLeftBackground(t.Background()).
|
||||||
|
BorderLeftForeground(t.BackgroundPanel()).
|
||||||
|
BorderRightForeground(borderColor).
|
||||||
|
BorderRightBackground(t.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
if highlight {
|
||||||
|
style = style.
|
||||||
|
BorderLeftBackground(t.Primary()).
|
||||||
|
BorderLeftForeground(t.Primary()).
|
||||||
|
BorderRightForeground(t.Primary()).
|
||||||
|
BorderRightBackground(t.Primary())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if highlight {
|
||||||
|
style = style.
|
||||||
|
Foreground(t.Text()).
|
||||||
|
Bold(true).
|
||||||
|
Background(t.BackgroundElement())
|
||||||
}
|
}
|
||||||
|
|
||||||
content = style.Render(content)
|
content = style.Render(content)
|
||||||
content = lipgloss.PlaceHorizontal(
|
|
||||||
width,
|
|
||||||
lipgloss.Left,
|
|
||||||
content,
|
|
||||||
styles.WhitespaceStyle(t.Background()),
|
|
||||||
)
|
|
||||||
content = lipgloss.PlaceHorizontal(
|
|
||||||
layout.Current.Viewport.Width,
|
|
||||||
align,
|
|
||||||
content,
|
|
||||||
styles.WhitespaceStyle(t.Background()),
|
|
||||||
)
|
|
||||||
if renderer.marginTop > 0 {
|
if renderer.marginTop > 0 {
|
||||||
for range renderer.marginTop {
|
for range renderer.marginTop {
|
||||||
content = "\n" + content
|
content = "\n" + content
|
||||||
@@ -186,16 +187,44 @@ func renderContentBlock(
|
|||||||
content = content + "\n"
|
content = content + "\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if highlight {
|
||||||
|
copy := app.Key(commands.MessagesCopyCommand)
|
||||||
|
// revert := app.Key(commands.MessagesRevertCommand)
|
||||||
|
|
||||||
|
background := t.Background()
|
||||||
|
header := layout.Render(
|
||||||
|
layout.FlexOptions{
|
||||||
|
Background: &background,
|
||||||
|
Direction: layout.Row,
|
||||||
|
Justify: layout.JustifyCenter,
|
||||||
|
Align: layout.AlignStretch,
|
||||||
|
Width: width - 2,
|
||||||
|
Gap: 5,
|
||||||
|
},
|
||||||
|
layout.FlexItem{
|
||||||
|
View: copy,
|
||||||
|
},
|
||||||
|
// layout.FlexItem{
|
||||||
|
// View: revert,
|
||||||
|
// },
|
||||||
|
)
|
||||||
|
header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header)
|
||||||
|
|
||||||
|
content = "\n\n\n" + header + "\n\n" + content + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderText(
|
func renderText(
|
||||||
|
app *app.App,
|
||||||
message opencode.Message,
|
message opencode.Message,
|
||||||
text string,
|
text string,
|
||||||
author string,
|
author string,
|
||||||
showToolDetails bool,
|
showToolDetails bool,
|
||||||
|
highlight bool,
|
||||||
width int,
|
width int,
|
||||||
align lipgloss.Position,
|
|
||||||
toolCalls ...opencode.ToolInvocationPart,
|
toolCalls ...opencode.ToolInvocationPart,
|
||||||
) string {
|
) string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
@@ -206,17 +235,20 @@ func renderText(
|
|||||||
timestamp = timestamp[12:]
|
timestamp = timestamp[12:]
|
||||||
}
|
}
|
||||||
info := fmt.Sprintf("%s (%s)", author, timestamp)
|
info := fmt.Sprintf("%s (%s)", author, timestamp)
|
||||||
|
info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
|
||||||
|
|
||||||
messageStyle := styles.NewStyle().
|
backgroundColor := t.BackgroundPanel()
|
||||||
Background(t.BackgroundPanel()).
|
if highlight {
|
||||||
Foreground(t.Text())
|
backgroundColor = t.BackgroundElement()
|
||||||
|
}
|
||||||
|
messageStyle := styles.NewStyle().Background(backgroundColor)
|
||||||
if message.Role == opencode.MessageRoleUser {
|
if message.Role == opencode.MessageRoleUser {
|
||||||
messageStyle = messageStyle.Width(width - 6)
|
messageStyle = messageStyle.Width(width - 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := messageStyle.Render(text)
|
content := messageStyle.Render(text)
|
||||||
if message.Role == opencode.MessageRoleAssistant {
|
if message.Role == opencode.MessageRoleAssistant {
|
||||||
content = toMarkdown(text, width, t.BackgroundPanel())
|
content = util.ToMarkdown(text, width, backgroundColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
|
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
|
||||||
@@ -242,16 +274,19 @@ func renderText(
|
|||||||
switch message.Role {
|
switch message.Role {
|
||||||
case opencode.MessageRoleUser:
|
case opencode.MessageRoleUser:
|
||||||
return renderContentBlock(
|
return renderContentBlock(
|
||||||
|
app,
|
||||||
content,
|
content,
|
||||||
|
highlight,
|
||||||
width,
|
width,
|
||||||
align,
|
WithTextColor(t.Text()),
|
||||||
WithBorderColor(t.Secondary()),
|
WithBorderColorRight(t.Secondary()),
|
||||||
)
|
)
|
||||||
case opencode.MessageRoleAssistant:
|
case opencode.MessageRoleAssistant:
|
||||||
return renderContentBlock(
|
return renderContentBlock(
|
||||||
|
app,
|
||||||
content,
|
content,
|
||||||
|
highlight,
|
||||||
width,
|
width,
|
||||||
align,
|
|
||||||
WithBorderColor(t.Accent()),
|
WithBorderColor(t.Accent()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -259,10 +294,11 @@ func renderText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderToolDetails(
|
func renderToolDetails(
|
||||||
|
app *app.App,
|
||||||
toolCall opencode.ToolInvocationPart,
|
toolCall opencode.ToolInvocationPart,
|
||||||
messageMetadata opencode.MessageMetadata,
|
messageMetadata opencode.MessageMetadata,
|
||||||
|
highlight bool,
|
||||||
width int,
|
width int,
|
||||||
align lipgloss.Position,
|
|
||||||
) string {
|
) string {
|
||||||
ignoredTools := []string{"todoread"}
|
ignoredTools := []string{"todoread"}
|
||||||
if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
|
if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
|
||||||
@@ -282,7 +318,7 @@ func renderToolDetails(
|
|||||||
|
|
||||||
if toolCall.ToolInvocation.State == "partial-call" {
|
if toolCall.ToolInvocation.State == "partial-call" {
|
||||||
title := renderToolTitle(toolCall, messageMetadata, width)
|
title := renderToolTitle(toolCall, messageMetadata, width)
|
||||||
return renderContentBlock(title, width, align)
|
return renderContentBlock(app, title, highlight, width)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolArgsMap := make(map[string]any)
|
toolArgsMap := make(map[string]any)
|
||||||
@@ -301,6 +337,10 @@ func renderToolDetails(
|
|||||||
body := ""
|
body := ""
|
||||||
finished := result != nil && *result != ""
|
finished := result != nil && *result != ""
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
|
backgroundColor := t.BackgroundPanel()
|
||||||
|
if highlight {
|
||||||
|
backgroundColor = t.BackgroundElement()
|
||||||
|
}
|
||||||
|
|
||||||
switch toolCall.ToolInvocation.ToolName {
|
switch toolCall.ToolInvocation.ToolName {
|
||||||
case "read":
|
case "read":
|
||||||
@@ -308,7 +348,7 @@ func renderToolDetails(
|
|||||||
if preview != nil && toolArgsMap["filePath"] != nil {
|
if preview != nil && toolArgsMap["filePath"] != nil {
|
||||||
filename := toolArgsMap["filePath"].(string)
|
filename := toolArgsMap["filePath"].(string)
|
||||||
body = preview.(string)
|
body = preview.(string)
|
||||||
body = renderFile(filename, body, width, WithTruncate(6))
|
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
|
||||||
}
|
}
|
||||||
case "edit":
|
case "edit":
|
||||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||||
@@ -321,38 +361,28 @@ func renderToolDetails(
|
|||||||
patch,
|
patch,
|
||||||
diff.WithWidth(width-2),
|
diff.WithWidth(width-2),
|
||||||
)
|
)
|
||||||
formattedDiff = strings.TrimSpace(formattedDiff)
|
|
||||||
formattedDiff = styles.NewStyle().
|
|
||||||
BorderStyle(lipgloss.ThickBorder()).
|
|
||||||
BorderBackground(t.Background()).
|
|
||||||
BorderForeground(t.BackgroundPanel()).
|
|
||||||
BorderLeft(true).
|
|
||||||
BorderRight(true).
|
|
||||||
Render(formattedDiff)
|
|
||||||
|
|
||||||
body = strings.TrimSpace(formattedDiff)
|
body = strings.TrimSpace(formattedDiff)
|
||||||
body = renderContentBlock(
|
style := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Padding(1, 2).Width(width - 4)
|
||||||
body,
|
if highlight {
|
||||||
width,
|
style = style.Foreground(t.Text()).Bold(true)
|
||||||
align,
|
}
|
||||||
WithNoBorder(),
|
|
||||||
WithPadding(0),
|
|
||||||
)
|
|
||||||
|
|
||||||
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
|
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
|
||||||
body += "\n" + renderContentBlock(diagnostics, width, align)
|
diagnostics = style.Render(diagnostics)
|
||||||
|
body += "\n" + diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
title := renderToolTitle(toolCall, messageMetadata, width)
|
title := renderToolTitle(toolCall, messageMetadata, width)
|
||||||
title = renderContentBlock(title, width, align)
|
title = style.Render(title)
|
||||||
content := title + "\n" + body
|
content := title + "\n" + body
|
||||||
|
content = renderContentBlock(app, content, highlight, width, WithPadding(0))
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "write":
|
case "write":
|
||||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||||
if content, ok := toolArgsMap["content"].(string); ok {
|
if content, ok := toolArgsMap["content"].(string); ok {
|
||||||
body = renderFile(filename, content, width)
|
body = util.RenderFile(filename, content, width)
|
||||||
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
|
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
|
||||||
body += "\n\n" + diagnostics
|
body += "\n\n" + diagnostics
|
||||||
}
|
}
|
||||||
@@ -363,14 +393,14 @@ func renderToolDetails(
|
|||||||
if stdout != nil {
|
if stdout != nil {
|
||||||
command := toolArgsMap["command"].(string)
|
command := toolArgsMap["command"].(string)
|
||||||
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
|
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
|
||||||
body = toMarkdown(body, width, t.BackgroundPanel())
|
body = util.ToMarkdown(body, width, backgroundColor)
|
||||||
}
|
}
|
||||||
case "webfetch":
|
case "webfetch":
|
||||||
if format, ok := toolArgsMap["format"].(string); ok && result != nil {
|
if format, ok := toolArgsMap["format"].(string); ok && result != nil {
|
||||||
body = *result
|
body = *result
|
||||||
body = truncateHeight(body, 10)
|
body = util.TruncateHeight(body, 10)
|
||||||
if format == "html" || format == "markdown" {
|
if format == "html" || format == "markdown" {
|
||||||
body = toMarkdown(body, width, t.BackgroundPanel())
|
body = util.ToMarkdown(body, width, backgroundColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "todowrite":
|
case "todowrite":
|
||||||
@@ -389,7 +419,7 @@ func renderToolDetails(
|
|||||||
body += fmt.Sprintf("- [ ] %s\n", content)
|
body += fmt.Sprintf("- [ ] %s\n", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body = toMarkdown(body, width, t.BackgroundPanel())
|
body = util.ToMarkdown(body, width, backgroundColor)
|
||||||
}
|
}
|
||||||
case "task":
|
case "task":
|
||||||
summary := metadata.JSON.ExtraFields["summary"]
|
summary := metadata.JSON.ExtraFields["summary"]
|
||||||
@@ -424,7 +454,7 @@ func renderToolDetails(
|
|||||||
result = &empty
|
result = &empty
|
||||||
}
|
}
|
||||||
body = *result
|
body = *result
|
||||||
body = truncateHeight(body, 10)
|
body = util.TruncateHeight(body, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
error := ""
|
error := ""
|
||||||
@@ -437,18 +467,18 @@ func renderToolDetails(
|
|||||||
if error != "" {
|
if error != "" {
|
||||||
body = styles.NewStyle().
|
body = styles.NewStyle().
|
||||||
Foreground(t.Error()).
|
Foreground(t.Error()).
|
||||||
Background(t.BackgroundPanel()).
|
Background(backgroundColor).
|
||||||
Render(error)
|
Render(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if body == "" && error == "" && result != nil {
|
if body == "" && error == "" && result != nil {
|
||||||
body = *result
|
body = *result
|
||||||
body = truncateHeight(body, 10)
|
body = util.TruncateHeight(body, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
title := renderToolTitle(toolCall, messageMetadata, width)
|
title := renderToolTitle(toolCall, messageMetadata, width)
|
||||||
content := title + "\n\n" + body
|
content := title + "\n\n" + body
|
||||||
return renderContentBlock(content, width, align)
|
return renderContentBlock(app, content, highlight, width)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderToolName(name string) string {
|
func renderToolName(name string) string {
|
||||||
@@ -505,7 +535,7 @@ func renderToolTitle(
|
|||||||
title = fmt.Sprintf("%s %s", title, toolArgs)
|
title = fmt.Sprintf("%s %s", title, toolArgs)
|
||||||
case "edit", "write":
|
case "edit", "write":
|
||||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||||
title = fmt.Sprintf("%s %s", title, relative(filename))
|
title = fmt.Sprintf("%s %s", title, util.Relative(filename))
|
||||||
}
|
}
|
||||||
case "bash", "task":
|
case "bash", "task":
|
||||||
if description, ok := toolArgsMap["description"].(string); ok {
|
if description, ok := toolArgsMap["description"].(string); ok {
|
||||||
@@ -551,50 +581,6 @@ func renderToolAction(name string) string {
|
|||||||
return "Working..."
|
return "Working..."
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileRenderer struct {
|
|
||||||
filename string
|
|
||||||
content string
|
|
||||||
height int
|
|
||||||
}
|
|
||||||
|
|
||||||
type fileRenderingOption func(*fileRenderer)
|
|
||||||
|
|
||||||
func WithTruncate(height int) fileRenderingOption {
|
|
||||||
return func(c *fileRenderer) {
|
|
||||||
c.height = height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderFile(
|
|
||||||
filename string,
|
|
||||||
content string,
|
|
||||||
width int,
|
|
||||||
options ...fileRenderingOption) string {
|
|
||||||
t := theme.CurrentTheme()
|
|
||||||
renderer := &fileRenderer{
|
|
||||||
filename: filename,
|
|
||||||
content: content,
|
|
||||||
}
|
|
||||||
for _, option := range options {
|
|
||||||
option(renderer)
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := []string{}
|
|
||||||
for line := range strings.SplitSeq(content, "\n") {
|
|
||||||
line = strings.TrimRightFunc(line, unicode.IsSpace)
|
|
||||||
line = strings.ReplaceAll(line, "\t", " ")
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
content = strings.Join(lines, "\n")
|
|
||||||
|
|
||||||
if renderer.height > 0 {
|
|
||||||
content = truncateHeight(content, renderer.height)
|
|
||||||
}
|
|
||||||
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
|
|
||||||
content = toMarkdown(content, width, t.BackgroundPanel())
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderArgs(args *map[string]any, titleKey string) string {
|
func renderArgs(args *map[string]any, titleKey string) string {
|
||||||
if args == nil || len(*args) == 0 {
|
if args == nil || len(*args) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -614,7 +600,7 @@ func renderArgs(args *map[string]any, titleKey string) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if key == "filePath" || key == "path" {
|
if key == "filePath" || key == "path" {
|
||||||
value = relative(value.(string))
|
value = util.Relative(value.(string))
|
||||||
}
|
}
|
||||||
if key == titleKey {
|
if key == titleKey {
|
||||||
title = fmt.Sprintf("%s", value)
|
title = fmt.Sprintf("%s", value)
|
||||||
@@ -628,29 +614,6 @@ func renderArgs(args *map[string]any, titleKey string) string {
|
|||||||
return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
|
return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateHeight(content string, height int) string {
|
|
||||||
lines := strings.Split(content, "\n")
|
|
||||||
if len(lines) > height {
|
|
||||||
return strings.Join(lines[:height], "\n")
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
func relative(path string) string {
|
|
||||||
path = strings.TrimPrefix(path, app.CwdPath+"/")
|
|
||||||
return strings.TrimPrefix(path, app.RootPath+"/")
|
|
||||||
}
|
|
||||||
|
|
||||||
func extension(path string) string {
|
|
||||||
ext := filepath.Ext(path)
|
|
||||||
if ext == "" {
|
|
||||||
ext = ""
|
|
||||||
} else {
|
|
||||||
ext = strings.ToLower(ext[1:])
|
|
||||||
}
|
|
||||||
return ext
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diagnostic represents an LSP diagnostic
|
// Diagnostic represents an LSP diagnostic
|
||||||
type Diagnostic struct {
|
type Diagnostic struct {
|
||||||
Range struct {
|
Range struct {
|
||||||
|
|||||||
@@ -2,16 +2,13 @@ package chat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
|
||||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
"github.com/sst/opencode-sdk-go"
|
"github.com/sst/opencode-sdk-go"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/components/dialog"
|
"github.com/sst/opencode/internal/components/dialog"
|
||||||
"github.com/sst/opencode/internal/layout"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/internal/util"
|
"github.com/sst/opencode/internal/util"
|
||||||
@@ -19,73 +16,99 @@ import (
|
|||||||
|
|
||||||
type MessagesComponent interface {
|
type MessagesComponent interface {
|
||||||
tea.Model
|
tea.Model
|
||||||
tea.ViewModel
|
View(width, height int) string
|
||||||
SetSize(width, height int) tea.Cmd
|
SetWidth(width int) tea.Cmd
|
||||||
PageUp() (tea.Model, tea.Cmd)
|
PageUp() (tea.Model, tea.Cmd)
|
||||||
PageDown() (tea.Model, tea.Cmd)
|
PageDown() (tea.Model, tea.Cmd)
|
||||||
HalfPageUp() (tea.Model, tea.Cmd)
|
HalfPageUp() (tea.Model, tea.Cmd)
|
||||||
HalfPageDown() (tea.Model, tea.Cmd)
|
HalfPageDown() (tea.Model, tea.Cmd)
|
||||||
First() (tea.Model, tea.Cmd)
|
First() (tea.Model, tea.Cmd)
|
||||||
Last() (tea.Model, tea.Cmd)
|
Last() (tea.Model, tea.Cmd)
|
||||||
// Previous() (tea.Model, tea.Cmd)
|
Previous() (tea.Model, tea.Cmd)
|
||||||
// Next() (tea.Model, tea.Cmd)
|
Next() (tea.Model, tea.Cmd)
|
||||||
ToolDetailsVisible() bool
|
ToolDetailsVisible() bool
|
||||||
|
Selected() string
|
||||||
}
|
}
|
||||||
|
|
||||||
type messagesComponent struct {
|
type messagesComponent struct {
|
||||||
width, height int
|
width int
|
||||||
app *app.App
|
app *app.App
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
spinner spinner.Model
|
|
||||||
attachments viewport.Model
|
|
||||||
cache *MessageCache
|
cache *MessageCache
|
||||||
rendering bool
|
rendering bool
|
||||||
showToolDetails bool
|
showToolDetails bool
|
||||||
tail bool
|
tail bool
|
||||||
|
partCount int
|
||||||
|
lineCount int
|
||||||
|
selectedPart int
|
||||||
|
selectedText string
|
||||||
}
|
}
|
||||||
type renderFinishedMsg struct{}
|
type renderFinishedMsg struct{}
|
||||||
|
type selectedMessagePartChangedMsg struct {
|
||||||
|
part int
|
||||||
|
}
|
||||||
|
|
||||||
type ToggleToolDetailsMsg struct{}
|
type ToggleToolDetailsMsg struct{}
|
||||||
|
|
||||||
func (m *messagesComponent) Init() tea.Cmd {
|
func (m *messagesComponent) Init() tea.Cmd {
|
||||||
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
|
return tea.Batch(m.viewport.Init())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *messagesComponent) Selected() string {
|
||||||
|
return m.selectedText
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
switch msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case app.SendMsg:
|
case app.SendMsg:
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
m.tail = true
|
m.tail = true
|
||||||
|
m.selectedPart = -1
|
||||||
return m, nil
|
return m, nil
|
||||||
case app.OptimisticMessageAddedMsg:
|
case app.OptimisticMessageAddedMsg:
|
||||||
m.renderView()
|
m.renderView(m.width)
|
||||||
if m.tail {
|
if m.tail {
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case dialog.ThemeSelectedMsg:
|
case dialog.ThemeSelectedMsg:
|
||||||
m.cache.Clear()
|
m.cache.Clear()
|
||||||
|
m.rendering = true
|
||||||
return m, m.Reload()
|
return m, m.Reload()
|
||||||
case ToggleToolDetailsMsg:
|
case ToggleToolDetailsMsg:
|
||||||
m.showToolDetails = !m.showToolDetails
|
m.showToolDetails = !m.showToolDetails
|
||||||
|
m.rendering = true
|
||||||
return m, m.Reload()
|
return m, m.Reload()
|
||||||
case app.SessionSelectedMsg:
|
case app.SessionLoadedMsg:
|
||||||
m.cache.Clear()
|
m.cache.Clear()
|
||||||
m.tail = true
|
m.tail = true
|
||||||
|
m.rendering = true
|
||||||
return m, m.Reload()
|
return m, m.Reload()
|
||||||
case app.SessionClearedMsg:
|
case app.SessionClearedMsg:
|
||||||
m.cache.Clear()
|
m.cache.Clear()
|
||||||
cmd := m.Reload()
|
m.rendering = true
|
||||||
return m, cmd
|
return m, m.Reload()
|
||||||
case renderFinishedMsg:
|
case renderFinishedMsg:
|
||||||
m.rendering = false
|
m.rendering = false
|
||||||
if m.tail {
|
if m.tail {
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
}
|
}
|
||||||
case opencode.EventListResponseEventSessionUpdated, opencode.EventListResponseEventMessageUpdated:
|
case selectedMessagePartChangedMsg:
|
||||||
m.renderView()
|
return m, m.Reload()
|
||||||
if m.tail {
|
case opencode.EventListResponseEventSessionUpdated:
|
||||||
m.viewport.GotoBottom()
|
if msg.Properties.Info.ID == m.app.Session.ID {
|
||||||
|
m.renderView(m.width)
|
||||||
|
if m.tail {
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case opencode.EventListResponseEventMessageUpdated:
|
||||||
|
if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID {
|
||||||
|
m.renderView(m.width)
|
||||||
|
if m.tail {
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,26 +117,17 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.tail = m.viewport.AtBottom()
|
m.tail = m.viewport.AtBottom()
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
spinner, cmd := m.spinner.Update(msg)
|
|
||||||
m.spinner = spinner
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
|
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) renderView() {
|
func (m *messagesComponent) renderView(width int) {
|
||||||
if m.width == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
measure := util.Measure("messages.renderView")
|
measure := util.Measure("messages.renderView")
|
||||||
defer measure("messageCount", len(m.app.Messages))
|
defer measure("messageCount", len(m.app.Messages))
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
blocks := make([]string, 0)
|
blocks := make([]string, 0)
|
||||||
|
m.partCount = 0
|
||||||
align := lipgloss.Center
|
m.lineCount = 0
|
||||||
width := layout.Current.Container.Width
|
|
||||||
|
|
||||||
for _, message := range m.app.Messages {
|
for _, message := range m.app.Messages {
|
||||||
var content string
|
var content string
|
||||||
@@ -124,21 +138,28 @@ func (m *messagesComponent) renderView() {
|
|||||||
for _, part := range message.Parts {
|
for _, part := range message.Parts {
|
||||||
switch part := part.AsUnion().(type) {
|
switch part := part.AsUnion().(type) {
|
||||||
case opencode.TextPart:
|
case opencode.TextPart:
|
||||||
key := m.cache.GenerateKey(message.ID, part.Text, layout.Current.Viewport.Width)
|
key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount)
|
||||||
content, cached = m.cache.Get(key)
|
content, cached = m.cache.Get(key)
|
||||||
if !cached {
|
if !cached {
|
||||||
content = renderText(
|
content = renderText(
|
||||||
|
m.app,
|
||||||
message,
|
message,
|
||||||
part.Text,
|
part.Text,
|
||||||
m.app.Info.User,
|
m.app.Info.User,
|
||||||
m.showToolDetails,
|
m.showToolDetails,
|
||||||
|
m.partCount == m.selectedPart,
|
||||||
width,
|
width,
|
||||||
align,
|
|
||||||
)
|
)
|
||||||
m.cache.Set(key, content)
|
m.cache.Set(key, content)
|
||||||
}
|
}
|
||||||
if content != "" {
|
if content != "" {
|
||||||
|
if m.selectedPart == m.partCount {
|
||||||
|
m.viewport.SetYOffset(m.lineCount - 4)
|
||||||
|
m.selectedText = part.Text
|
||||||
|
}
|
||||||
blocks = append(blocks, content)
|
blocks = append(blocks, content)
|
||||||
|
m.partCount++
|
||||||
|
m.lineCount += lipgloss.Height(content) + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,33 +188,41 @@ func (m *messagesComponent) renderView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if finished {
|
if finished {
|
||||||
key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width, m.showToolDetails)
|
key := m.cache.GenerateKey(message.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount)
|
||||||
content, cached = m.cache.Get(key)
|
content, cached = m.cache.Get(key)
|
||||||
if !cached {
|
if !cached {
|
||||||
content = renderText(
|
content = renderText(
|
||||||
|
m.app,
|
||||||
message,
|
message,
|
||||||
p.Text,
|
p.Text,
|
||||||
message.Metadata.Assistant.ModelID,
|
message.Metadata.Assistant.ModelID,
|
||||||
m.showToolDetails,
|
m.showToolDetails,
|
||||||
|
m.partCount == m.selectedPart,
|
||||||
width,
|
width,
|
||||||
align,
|
|
||||||
toolCallParts...,
|
toolCallParts...,
|
||||||
)
|
)
|
||||||
m.cache.Set(key, content)
|
m.cache.Set(key, content)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
content = renderText(
|
content = renderText(
|
||||||
|
m.app,
|
||||||
message,
|
message,
|
||||||
p.Text,
|
p.Text,
|
||||||
message.Metadata.Assistant.ModelID,
|
message.Metadata.Assistant.ModelID,
|
||||||
m.showToolDetails,
|
m.showToolDetails,
|
||||||
|
m.partCount == m.selectedPart,
|
||||||
width,
|
width,
|
||||||
align,
|
|
||||||
toolCallParts...,
|
toolCallParts...,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if content != "" {
|
if content != "" {
|
||||||
|
if m.selectedPart == m.partCount {
|
||||||
|
m.viewport.SetYOffset(m.lineCount - 4)
|
||||||
|
m.selectedText = p.Text
|
||||||
|
}
|
||||||
blocks = append(blocks, content)
|
blocks = append(blocks, content)
|
||||||
|
m.partCount++
|
||||||
|
m.lineCount += lipgloss.Height(content) + 1
|
||||||
}
|
}
|
||||||
case opencode.ToolInvocationPart:
|
case opencode.ToolInvocationPart:
|
||||||
if !m.showToolDetails {
|
if !m.showToolDetails {
|
||||||
@@ -204,33 +233,41 @@ func (m *messagesComponent) renderView() {
|
|||||||
key := m.cache.GenerateKey(message.ID,
|
key := m.cache.GenerateKey(message.ID,
|
||||||
part.ToolInvocation.ToolCallID,
|
part.ToolInvocation.ToolCallID,
|
||||||
m.showToolDetails,
|
m.showToolDetails,
|
||||||
layout.Current.Viewport.Width,
|
width,
|
||||||
|
m.partCount == m.selectedPart,
|
||||||
)
|
)
|
||||||
content, cached = m.cache.Get(key)
|
content, cached = m.cache.Get(key)
|
||||||
if !cached {
|
if !cached {
|
||||||
content = renderToolDetails(
|
content = renderToolDetails(
|
||||||
|
m.app,
|
||||||
part,
|
part,
|
||||||
message.Metadata,
|
message.Metadata,
|
||||||
|
m.partCount == m.selectedPart,
|
||||||
width,
|
width,
|
||||||
align,
|
|
||||||
)
|
)
|
||||||
m.cache.Set(key, content)
|
m.cache.Set(key, content)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// if the tool call isn't finished, don't cache
|
// if the tool call isn't finished, don't cache
|
||||||
content = renderToolDetails(
|
content = renderToolDetails(
|
||||||
|
m.app,
|
||||||
part,
|
part,
|
||||||
message.Metadata,
|
message.Metadata,
|
||||||
|
m.partCount == m.selectedPart,
|
||||||
width,
|
width,
|
||||||
align,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if content != "" {
|
if content != "" {
|
||||||
|
if m.selectedPart == m.partCount {
|
||||||
|
m.viewport.SetYOffset(m.lineCount - 4)
|
||||||
|
m.selectedText = ""
|
||||||
|
}
|
||||||
blocks = append(blocks, content)
|
blocks = append(blocks, content)
|
||||||
|
m.partCount++
|
||||||
|
m.lineCount += lipgloss.Height(content) + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
error := ""
|
error := ""
|
||||||
@@ -246,40 +283,33 @@ func (m *messagesComponent) renderView() {
|
|||||||
|
|
||||||
if error != "" {
|
if error != "" {
|
||||||
error = renderContentBlock(
|
error = renderContentBlock(
|
||||||
|
m.app,
|
||||||
error,
|
error,
|
||||||
|
false,
|
||||||
width,
|
width,
|
||||||
align,
|
|
||||||
WithBorderColor(t.Error()),
|
WithBorderColor(t.Error()),
|
||||||
)
|
)
|
||||||
blocks = append(blocks, error)
|
blocks = append(blocks, error)
|
||||||
|
m.lineCount += lipgloss.Height(error) + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
centered := []string{}
|
m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n"))
|
||||||
for _, block := range blocks {
|
if m.selectedPart == m.partCount-1 {
|
||||||
centered = append(centered, lipgloss.PlaceHorizontal(
|
m.viewport.GotoBottom()
|
||||||
m.width,
|
|
||||||
lipgloss.Center,
|
|
||||||
block+"\n",
|
|
||||||
styles.WhitespaceStyle(t.Background()),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1)
|
|
||||||
m.viewport.SetContent("\n" + strings.Join(centered, "\n"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) header() string {
|
func (m *messagesComponent) header(width int) string {
|
||||||
if m.app.Session.ID == "" {
|
if m.app.Session.ID == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
width := layout.Current.Container.Width
|
|
||||||
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||||
headerLines := []string{}
|
headerLines := []string{}
|
||||||
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
|
headerLines = append(headerLines, util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
|
||||||
if m.app.Session.Share.URL != "" {
|
if m.app.Session.Share.URL != "" {
|
||||||
headerLines = append(headerLines, muted(m.app.Session.Share.URL))
|
headerLines = append(headerLines, muted(m.app.Session.Share.URL))
|
||||||
} else {
|
} else {
|
||||||
@@ -302,31 +332,29 @@ func (m *messagesComponent) header() string {
|
|||||||
return "\n" + header + "\n"
|
return "\n" + header + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) View() string {
|
func (m *messagesComponent) View(width, height int) string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
if m.rendering {
|
if m.rendering {
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.width,
|
width,
|
||||||
m.height,
|
height,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
"Loading session...",
|
styles.NewStyle().Background(t.Background()).Render("Loading session..."),
|
||||||
styles.WhitespaceStyle(t.Background()),
|
styles.WhitespaceStyle(t.Background()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
header := lipgloss.PlaceHorizontal(
|
header := m.header(width)
|
||||||
m.width,
|
m.viewport.SetWidth(width)
|
||||||
lipgloss.Center,
|
m.viewport.SetHeight(height - lipgloss.Height(header))
|
||||||
m.header(),
|
|
||||||
styles.WhitespaceStyle(t.Background()),
|
|
||||||
)
|
|
||||||
return styles.NewStyle().
|
return styles.NewStyle().
|
||||||
Background(t.Background()).
|
Background(t.Background()).
|
||||||
Render(header + "\n" + m.viewport.View())
|
Render(header + "\n" + m.viewport.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
|
func (m *messagesComponent) SetWidth(width int) tea.Cmd {
|
||||||
if m.width == width && m.height == height {
|
if m.width == width {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Clear cache on resize since width affects rendering
|
// Clear cache on resize since width affects rendering
|
||||||
@@ -334,23 +362,14 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
|
|||||||
m.cache.Clear()
|
m.cache.Clear()
|
||||||
}
|
}
|
||||||
m.width = width
|
m.width = width
|
||||||
m.height = height
|
|
||||||
m.viewport.SetWidth(width)
|
m.viewport.SetWidth(width)
|
||||||
m.viewport.SetHeight(height - lipgloss.Height(m.header()))
|
m.renderView(width)
|
||||||
m.attachments.SetWidth(width + 40)
|
|
||||||
m.attachments.SetHeight(3)
|
|
||||||
m.renderView()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) GetSize() (int, int) {
|
|
||||||
return m.width, m.height
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *messagesComponent) Reload() tea.Cmd {
|
func (m *messagesComponent) Reload() tea.Cmd {
|
||||||
m.rendering = true
|
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
m.renderView()
|
m.renderView(m.width)
|
||||||
return renderFinishedMsg{}
|
return renderFinishedMsg{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,16 +394,45 @@ func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
|
func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) {
|
||||||
m.viewport.GotoTop()
|
|
||||||
m.tail = false
|
m.tail = false
|
||||||
return m, nil
|
if m.selectedPart < 0 {
|
||||||
|
m.selectedPart = m.partCount
|
||||||
|
}
|
||||||
|
m.selectedPart--
|
||||||
|
if m.selectedPart < 0 {
|
||||||
|
m.selectedPart = 0
|
||||||
|
}
|
||||||
|
return m, util.CmdHandler(selectedMessagePartChangedMsg{
|
||||||
|
part: m.selectedPart,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *messagesComponent) Next() (tea.Model, tea.Cmd) {
|
||||||
|
m.tail = false
|
||||||
|
m.selectedPart++
|
||||||
|
if m.selectedPart >= m.partCount {
|
||||||
|
m.selectedPart = m.partCount
|
||||||
|
}
|
||||||
|
return m, util.CmdHandler(selectedMessagePartChangedMsg{
|
||||||
|
part: m.selectedPart,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
|
||||||
|
m.selectedPart = 0
|
||||||
|
m.tail = false
|
||||||
|
return m, util.CmdHandler(selectedMessagePartChangedMsg{
|
||||||
|
part: m.selectedPart,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
|
func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
|
||||||
m.viewport.GotoBottom()
|
m.selectedPart = m.partCount - 1
|
||||||
m.tail = true
|
m.tail = true
|
||||||
return m, nil
|
return m, util.CmdHandler(selectedMessagePartChangedMsg{
|
||||||
|
part: m.selectedPart,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *messagesComponent) ToolDetailsVisible() bool {
|
func (m *messagesComponent) ToolDetailsVisible() bool {
|
||||||
@@ -392,23 +440,15 @@ func (m *messagesComponent) ToolDetailsVisible() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewMessagesComponent(app *app.App) MessagesComponent {
|
func NewMessagesComponent(app *app.App) MessagesComponent {
|
||||||
customSpinner := spinner.Spinner{
|
|
||||||
Frames: []string{" ", "┃", "┃"},
|
|
||||||
FPS: time.Second / 3,
|
|
||||||
}
|
|
||||||
s := spinner.New(spinner.WithSpinner(customSpinner))
|
|
||||||
|
|
||||||
vp := viewport.New()
|
vp := viewport.New()
|
||||||
attachments := viewport.New()
|
|
||||||
vp.KeyMap = viewport.KeyMap{}
|
vp.KeyMap = viewport.KeyMap{}
|
||||||
|
|
||||||
return &messagesComponent{
|
return &messagesComponent{
|
||||||
app: app,
|
app: app,
|
||||||
viewport: vp,
|
viewport: vp,
|
||||||
spinner: s,
|
|
||||||
attachments: attachments,
|
|
||||||
showToolDetails: true,
|
showToolDetails: true,
|
||||||
cache: NewMessageCache(),
|
cache: NewMessageCache(),
|
||||||
tail: true,
|
tail: true,
|
||||||
|
selectedPart: -1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *commandsComponent) GetSize() (int, int) {
|
|
||||||
return c.width, c.height
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
|
func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
|
||||||
c.background = &color
|
c.background = &color
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
|
|||||||
title := itemStyle.Render(
|
title := itemStyle.Render(
|
||||||
ci.DisplayValue(),
|
ci.DisplayValue(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return title
|
return title
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +58,6 @@ func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
|
|||||||
|
|
||||||
type CompletionProvider interface {
|
type CompletionProvider interface {
|
||||||
GetId() string
|
GetId() string
|
||||||
GetEntry() CompletionItemI
|
|
||||||
GetChildEntries(query string) ([]CompletionItemI, error)
|
GetChildEntries(query string) ([]CompletionItemI, error)
|
||||||
GetEmptyMessage() string
|
GetEmptyMessage() string
|
||||||
}
|
}
|
||||||
@@ -175,9 +173,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
|
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
|
||||||
return c, tea.Batch(cmds...)
|
return c, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
c.width = msg.Width
|
|
||||||
c.height = msg.Height
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c, tea.Batch(cmds...)
|
return c, tea.Batch(cmds...)
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package dialog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/v2/key"
|
||||||
|
"github.com/charmbracelet/bubbles/v2/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
|
"github.com/sst/opencode/internal/components/list"
|
||||||
|
"github.com/sst/opencode/internal/components/modal"
|
||||||
|
"github.com/sst/opencode/internal/layout"
|
||||||
|
"github.com/sst/opencode/internal/styles"
|
||||||
|
"github.com/sst/opencode/internal/theme"
|
||||||
|
"github.com/sst/opencode/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FindSelectedMsg struct {
|
||||||
|
FilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindDialogCloseMsg struct{}
|
||||||
|
|
||||||
|
type FindDialog interface {
|
||||||
|
layout.Modal
|
||||||
|
tea.Model
|
||||||
|
tea.ViewModel
|
||||||
|
SetWidth(width int)
|
||||||
|
SetHeight(height int)
|
||||||
|
IsEmpty() bool
|
||||||
|
SetProvider(provider CompletionProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
type findDialogComponent struct {
|
||||||
|
query string
|
||||||
|
completionProvider CompletionProvider
|
||||||
|
width, height int
|
||||||
|
modal *modal.Modal
|
||||||
|
textInput textinput.Model
|
||||||
|
list list.List[CompletionItemI]
|
||||||
|
}
|
||||||
|
|
||||||
|
type findDialogKeyMap struct {
|
||||||
|
Select key.Binding
|
||||||
|
Cancel key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
var findDialogKeys = findDialogKeyMap{
|
||||||
|
Select: key.NewBinding(
|
||||||
|
key.WithKeys("enter"),
|
||||||
|
),
|
||||||
|
Cancel: key.NewBinding(
|
||||||
|
key.WithKeys("esc"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) Init() tea.Cmd {
|
||||||
|
return textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case []CompletionItemI:
|
||||||
|
f.list.SetItems(msg)
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c":
|
||||||
|
if f.textInput.Value() == "" {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
f.textInput.SetValue("")
|
||||||
|
return f.update(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, findDialogKeys.Select):
|
||||||
|
item, i := f.list.GetSelectedItem()
|
||||||
|
if i == -1 {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
return f, f.selectFile(item)
|
||||||
|
case key.Matches(msg, findDialogKeys.Cancel):
|
||||||
|
return f, f.Close()
|
||||||
|
default:
|
||||||
|
f.textInput, cmd = f.textInput.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
f, cmd = f.update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return f, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) update(msg tea.Msg) (*findDialogComponent, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
|
query := f.textInput.Value()
|
||||||
|
if query != f.query {
|
||||||
|
f.query = query
|
||||||
|
cmd = func() tea.Msg {
|
||||||
|
items, err := f.completionProvider.GetChildEntries(query)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get completion items", "error", err)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, cmd := f.list.Update(msg)
|
||||||
|
f.list = u.(list.List[CompletionItemI])
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
return f, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) View() string {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
f.textInput.SetWidth(f.width - 8)
|
||||||
|
f.list.SetMaxWidth(f.width - 4)
|
||||||
|
inputView := f.textInput.View()
|
||||||
|
inputView = styles.NewStyle().
|
||||||
|
Background(t.BackgroundPanel()).
|
||||||
|
Height(1).
|
||||||
|
Width(f.width-4).
|
||||||
|
Padding(0, 0).
|
||||||
|
Render(inputView)
|
||||||
|
|
||||||
|
listView := f.list.View()
|
||||||
|
return styles.NewStyle().Height(12).Render(inputView + "\n" + listView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) SetWidth(width int) {
|
||||||
|
f.width = width
|
||||||
|
if width > 4 {
|
||||||
|
f.textInput.SetWidth(width - 4)
|
||||||
|
f.list.SetMaxWidth(width - 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) SetHeight(height int) {
|
||||||
|
f.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) IsEmpty() bool {
|
||||||
|
return f.list.IsEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) SetProvider(provider CompletionProvider) {
|
||||||
|
f.completionProvider = provider
|
||||||
|
f.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
|
||||||
|
f.list.SetItems([]CompletionItemI{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd {
|
||||||
|
return tea.Sequence(
|
||||||
|
f.Close(),
|
||||||
|
util.CmdHandler(FindSelectedMsg{
|
||||||
|
FilePath: item.GetValue(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) Render(background string) string {
|
||||||
|
return f.modal.Render(f.View(), background)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *findDialogComponent) Close() tea.Cmd {
|
||||||
|
f.textInput.Reset()
|
||||||
|
f.textInput.Blur()
|
||||||
|
return util.CmdHandler(modal.CloseModalMsg{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTextInput(existing *textinput.Model) textinput.Model {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
bgColor := t.BackgroundPanel()
|
||||||
|
textColor := t.Text()
|
||||||
|
textMutedColor := t.TextMuted()
|
||||||
|
|
||||||
|
ti := textinput.New()
|
||||||
|
|
||||||
|
ti.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
|
||||||
|
ti.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||||
|
ti.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
|
||||||
|
ti.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||||
|
ti.Styles.Cursor.Color = t.Primary()
|
||||||
|
ti.VirtualCursor = true
|
||||||
|
|
||||||
|
ti.Prompt = " "
|
||||||
|
ti.CharLimit = -1
|
||||||
|
ti.Focus()
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
ti.SetValue(existing.Value())
|
||||||
|
ti.SetWidth(existing.Width())
|
||||||
|
}
|
||||||
|
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFindDialog(completionProvider CompletionProvider) FindDialog {
|
||||||
|
ti := createTextInput(nil)
|
||||||
|
|
||||||
|
li := list.NewListComponent(
|
||||||
|
[]CompletionItemI{},
|
||||||
|
10, // max visible items
|
||||||
|
completionProvider.GetEmptyMessage(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load initial items
|
||||||
|
go func() {
|
||||||
|
items, err := completionProvider.GetChildEntries("")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get completion items", "error", err)
|
||||||
|
}
|
||||||
|
li.SetItems(items)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &findDialogComponent{
|
||||||
|
query: "",
|
||||||
|
completionProvider: completionProvider,
|
||||||
|
textInput: ti,
|
||||||
|
list: li,
|
||||||
|
modal: modal.New(
|
||||||
|
modal.WithTitle("Find Files"),
|
||||||
|
modal.WithMaxWidth(80),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,11 @@ package dialog
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"sort"
|
||||||
"slices"
|
"time"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/key"
|
"github.com/charmbracelet/bubbles/v2/key"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
|
||||||
"github.com/sst/opencode-sdk-go"
|
"github.com/sst/opencode-sdk-go"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/components/list"
|
"github.com/sst/opencode/internal/components/list"
|
||||||
@@ -21,8 +19,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
numVisibleModels = 6
|
numVisibleModels = 10
|
||||||
maxDialogWidth = 40
|
minDialogWidth = 40
|
||||||
|
maxDialogWidth = 80
|
||||||
)
|
)
|
||||||
|
|
||||||
// ModelDialog interface for the model selection dialog
|
// ModelDialog interface for the model selection dialog
|
||||||
@@ -31,33 +30,61 @@ type ModelDialog interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type modelDialog struct {
|
type modelDialog struct {
|
||||||
app *app.App
|
app *app.App
|
||||||
availableProviders []opencode.Provider
|
allModels []ModelWithProvider
|
||||||
provider opencode.Provider
|
width int
|
||||||
width int
|
height int
|
||||||
height int
|
modal *modal.Modal
|
||||||
hScrollOffset int
|
modelList list.List[ModelItem]
|
||||||
hScrollPossible bool
|
dialogWidth int
|
||||||
modal *modal.Modal
|
}
|
||||||
modelList list.List[list.StringItem]
|
|
||||||
|
type ModelWithProvider struct {
|
||||||
|
Model opencode.Model
|
||||||
|
Provider opencode.Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelItem struct {
|
||||||
|
ModelName string
|
||||||
|
ProviderName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ModelItem) Render(selected bool, width int) string {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
|
||||||
|
if selected {
|
||||||
|
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
|
||||||
|
return styles.NewStyle().
|
||||||
|
Background(t.Primary()).
|
||||||
|
Foreground(t.BackgroundElement()).
|
||||||
|
Width(width).
|
||||||
|
PaddingLeft(1).
|
||||||
|
Render(displayText)
|
||||||
|
} else {
|
||||||
|
modelStyle := styles.NewStyle().
|
||||||
|
Foreground(t.Text()).
|
||||||
|
Background(t.BackgroundElement())
|
||||||
|
providerStyle := styles.NewStyle().
|
||||||
|
Foreground(t.TextMuted()).
|
||||||
|
Background(t.BackgroundElement())
|
||||||
|
|
||||||
|
modelPart := modelStyle.Render(m.ModelName)
|
||||||
|
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
|
||||||
|
|
||||||
|
combinedText := modelPart + providerPart
|
||||||
|
return styles.NewStyle().
|
||||||
|
Background(t.BackgroundElement()).
|
||||||
|
PaddingLeft(1).
|
||||||
|
Render(combinedText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type modelKeyMap struct {
|
type modelKeyMap struct {
|
||||||
Left key.Binding
|
|
||||||
Right key.Binding
|
|
||||||
Enter key.Binding
|
Enter key.Binding
|
||||||
Escape key.Binding
|
Escape key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var modelKeys = modelKeyMap{
|
var modelKeys = modelKeyMap{
|
||||||
Left: key.NewBinding(
|
|
||||||
key.WithKeys("left", "h"),
|
|
||||||
key.WithHelp("←", "scroll left"),
|
|
||||||
),
|
|
||||||
Right: key.NewBinding(
|
|
||||||
key.WithKeys("right", "l"),
|
|
||||||
key.WithHelp("→", "scroll right"),
|
|
||||||
),
|
|
||||||
Enter: key.NewBinding(
|
Enter: key.NewBinding(
|
||||||
key.WithKeys("enter"),
|
key.WithKeys("enter"),
|
||||||
key.WithHelp("enter", "select model"),
|
key.WithHelp("enter", "select model"),
|
||||||
@@ -69,7 +96,7 @@ var modelKeys = modelKeyMap{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelDialog) Init() tea.Cmd {
|
func (m *modelDialog) Init() tea.Cmd {
|
||||||
m.setupModelsForProvider(m.provider.ID)
|
m.setupAllModels()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,34 +104,20 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, modelKeys.Left):
|
|
||||||
if m.hScrollPossible {
|
|
||||||
m.switchProvider(-1)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case key.Matches(msg, modelKeys.Right):
|
|
||||||
if m.hScrollPossible {
|
|
||||||
m.switchProvider(1)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case key.Matches(msg, modelKeys.Enter):
|
case key.Matches(msg, modelKeys.Enter):
|
||||||
selectedItem, _ := m.modelList.GetSelectedItem()
|
_, selectedIndex := m.modelList.GetSelectedItem()
|
||||||
models := m.models()
|
if selectedIndex >= 0 && selectedIndex < len(m.allModels) {
|
||||||
var selectedModel opencode.Model
|
selectedModel := m.allModels[selectedIndex]
|
||||||
for _, model := range models {
|
return m, tea.Sequence(
|
||||||
if model.Name == string(selectedItem) {
|
util.CmdHandler(modal.CloseModalMsg{}),
|
||||||
selectedModel = model
|
util.CmdHandler(
|
||||||
break
|
app.ModelSelectedMsg{
|
||||||
}
|
Provider: selectedModel.Provider,
|
||||||
|
Model: selectedModel.Model,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return m, tea.Sequence(
|
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||||
util.CmdHandler(modal.CloseModalMsg{}),
|
|
||||||
util.CmdHandler(
|
|
||||||
app.ModelSelectedMsg{
|
|
||||||
Provider: m.provider,
|
|
||||||
Model: selectedModel,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
case key.Matches(msg, modelKeys.Escape):
|
case key.Matches(msg, modelKeys.Escape):
|
||||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||||
}
|
}
|
||||||
@@ -115,74 +128,124 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
// Update the list component
|
// Update the list component
|
||||||
updatedList, cmd := m.modelList.Update(msg)
|
updatedList, cmd := m.modelList.Update(msg)
|
||||||
m.modelList = updatedList.(list.List[list.StringItem])
|
m.modelList = updatedList.(list.List[ModelItem])
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelDialog) models() []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 {
|
func (m *modelDialog) View() string {
|
||||||
listView := m.modelList.View()
|
return m.modelList.View()
|
||||||
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
|
|
||||||
return strings.Join([]string{listView, scrollIndicator}, "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelDialog) getScrollIndicators(maxWidth int) string {
|
func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
|
||||||
var indicator string
|
maxWidth := minDialogWidth
|
||||||
if m.hScrollPossible {
|
|
||||||
indicator = "← → (switch provider) "
|
|
||||||
}
|
|
||||||
if indicator == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
for _, item := range modelItems {
|
||||||
return styles.NewStyle().
|
// Calculate the width needed for this item: "ModelName (ProviderName)"
|
||||||
Foreground(t.TextMuted()).
|
// Add 4 for the parentheses, space, and some padding
|
||||||
Width(maxWidth).
|
itemWidth := len(item.ModelName) + len(item.ProviderName) + 4
|
||||||
Align(lipgloss.Right).
|
if itemWidth > maxWidth {
|
||||||
Render(indicator)
|
maxWidth = itemWidth
|
||||||
}
|
|
||||||
|
|
||||||
func (m *modelDialog) setupModelsForProvider(providerId string) {
|
|
||||||
models := m.models()
|
|
||||||
modelNames := make([]string, len(models))
|
|
||||||
for i, model := range models {
|
|
||||||
modelNames[i] = model.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true)
|
|
||||||
m.modelList.SetMaxWidth(maxDialogWidth)
|
|
||||||
|
|
||||||
if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.ID == providerId {
|
|
||||||
for i, model := range models {
|
|
||||||
if model.ID == m.app.Model.ID {
|
|
||||||
m.modelList.SetSelectedIndex(i)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if maxWidth > maxDialogWidth {
|
||||||
|
maxWidth = maxDialogWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modelDialog) setupAllModels() {
|
||||||
|
providers, _ := m.app.ListProviders(context.Background())
|
||||||
|
|
||||||
|
m.allModels = make([]ModelWithProvider, 0)
|
||||||
|
for _, provider := range providers {
|
||||||
|
for _, model := range provider.Models {
|
||||||
|
m.allModels = append(m.allModels, ModelWithProvider{
|
||||||
|
Model: model,
|
||||||
|
Provider: provider,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.sortModels()
|
||||||
|
|
||||||
|
modelItems := make([]ModelItem, len(m.allModels))
|
||||||
|
for i, modelWithProvider := range m.allModels {
|
||||||
|
modelItems[i] = ModelItem{
|
||||||
|
ModelName: modelWithProvider.Model.Name,
|
||||||
|
ProviderName: modelWithProvider.Provider.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.dialogWidth = m.calculateOptimalWidth(modelItems)
|
||||||
|
|
||||||
|
m.modelList = list.NewListComponent(modelItems, numVisibleModels, "No models available", true)
|
||||||
|
m.modelList.SetMaxWidth(m.dialogWidth)
|
||||||
|
|
||||||
|
if len(m.allModels) > 0 {
|
||||||
|
m.modelList.SetSelectedIndex(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modelDialog) sortModels() {
|
||||||
|
sort.Slice(m.allModels, func(i, j int) bool {
|
||||||
|
modelA := m.allModels[i]
|
||||||
|
modelB := m.allModels[j]
|
||||||
|
|
||||||
|
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
|
||||||
|
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
|
||||||
|
|
||||||
|
// If both have usage times, sort by most recent first
|
||||||
|
if !usageA.IsZero() && !usageB.IsZero() {
|
||||||
|
return usageA.After(usageB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only one has usage time, it goes first
|
||||||
|
if !usageA.IsZero() && usageB.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if usageA.IsZero() && !usageB.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither has usage time, sort by release date desc if available
|
||||||
|
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
|
||||||
|
dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
|
||||||
|
dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
|
||||||
|
if !dateA.IsZero() && !dateB.IsZero() {
|
||||||
|
return dateA.After(dateB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only one has release date, it goes first
|
||||||
|
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither has usage time nor release date, fall back to alphabetical sorting
|
||||||
|
return modelA.Model.Name < modelB.Model.Name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
|
||||||
|
if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
|
||||||
|
for _, usage := range m.app.State.RecentlyUsedModels {
|
||||||
|
if usage.ProviderID == providerID && usage.ModelID == modelID {
|
||||||
|
return usage.LastUsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelDialog) Render(background string) string {
|
func (m *modelDialog) Render(background string) string {
|
||||||
@@ -194,32 +257,16 @@ func (s *modelDialog) Close() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewModelDialog(app *app.App) ModelDialog {
|
func NewModelDialog(app *app.App) ModelDialog {
|
||||||
availableProviders, _ := app.ListProviders(context.Background())
|
|
||||||
|
|
||||||
currentProvider := availableProviders[0]
|
|
||||||
hScrollOffset := 0
|
|
||||||
if app.Provider != nil {
|
|
||||||
for i, provider := range availableProviders {
|
|
||||||
if provider.ID == app.Provider.ID {
|
|
||||||
currentProvider = provider
|
|
||||||
hScrollOffset = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog := &modelDialog{
|
dialog := &modelDialog{
|
||||||
app: app,
|
app: app,
|
||||||
availableProviders: availableProviders,
|
|
||||||
hScrollOffset: hScrollOffset,
|
|
||||||
hScrollPossible: len(availableProviders) > 1,
|
|
||||||
provider: currentProvider,
|
|
||||||
modal: modal.New(
|
|
||||||
modal.WithTitle(fmt.Sprintf("Select %s Model", currentProvider.Name)),
|
|
||||||
modal.WithMaxWidth(maxDialogWidth+4),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.setupModelsForProvider(currentProvider.ID)
|
dialog.setupAllModels()
|
||||||
|
|
||||||
|
dialog.modal = modal.New(
|
||||||
|
modal.WithTitle("Select Model"),
|
||||||
|
modal.WithMaxWidth(dialog.dialogWidth+4),
|
||||||
|
)
|
||||||
|
|
||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package diff
|
package diff
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
@@ -72,44 +73,6 @@ type linePair struct {
|
|||||||
right *DiffLine
|
right *DiffLine
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Side-by-Side Configuration
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// SideBySideConfig configures the rendering of side-by-side diffs
|
|
||||||
type SideBySideConfig struct {
|
|
||||||
TotalWidth int
|
|
||||||
}
|
|
||||||
|
|
||||||
// SideBySideOption modifies a SideBySideConfig
|
|
||||||
type SideBySideOption func(*SideBySideConfig)
|
|
||||||
|
|
||||||
// NewSideBySideConfig creates a SideBySideConfig with default values
|
|
||||||
func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
|
|
||||||
config := SideBySideConfig{
|
|
||||||
TotalWidth: 160, // Default width for side-by-side view
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&config)
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithTotalWidth sets the total width for side-by-side view
|
|
||||||
func WithTotalWidth(width int) SideBySideOption {
|
|
||||||
return func(s *SideBySideConfig) {
|
|
||||||
if width > 0 {
|
|
||||||
s.TotalWidth = width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Unified Configuration
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// UnifiedConfig configures the rendering of unified diffs
|
// UnifiedConfig configures the rendering of unified diffs
|
||||||
type UnifiedConfig struct {
|
type UnifiedConfig struct {
|
||||||
Width int
|
Width int
|
||||||
@@ -121,13 +84,22 @@ type UnifiedOption func(*UnifiedConfig)
|
|||||||
// NewUnifiedConfig creates a UnifiedConfig with default values
|
// NewUnifiedConfig creates a UnifiedConfig with default values
|
||||||
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
|
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
|
||||||
config := UnifiedConfig{
|
config := UnifiedConfig{
|
||||||
Width: 80, // Default width for unified view
|
Width: 80,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(&config)
|
opt(&config)
|
||||||
}
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSideBySideConfig creates a SideBySideConfig with default values
|
||||||
|
func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig {
|
||||||
|
config := UnifiedConfig{
|
||||||
|
Width: 160,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&config)
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,101 +120,87 @@ func WithWidth(width int) UnifiedOption {
|
|||||||
func ParseUnifiedDiff(diff string) (DiffResult, error) {
|
func ParseUnifiedDiff(diff string) (DiffResult, error) {
|
||||||
var result DiffResult
|
var result DiffResult
|
||||||
var currentHunk *Hunk
|
var currentHunk *Hunk
|
||||||
|
result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
|
||||||
|
|
||||||
hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
|
scanner := bufio.NewScanner(strings.NewReader(diff))
|
||||||
lines := strings.Split(diff, "\n")
|
|
||||||
|
|
||||||
var oldLine, newLine int
|
var oldLine, newLine int
|
||||||
inFileHeader := true
|
inFileHeader := true
|
||||||
|
|
||||||
for _, line := range lines {
|
for scanner.Scan() {
|
||||||
// Parse file headers
|
line := scanner.Text()
|
||||||
|
|
||||||
if inFileHeader {
|
if inFileHeader {
|
||||||
if strings.HasPrefix(line, "--- a/") {
|
if strings.HasPrefix(line, "--- a/") {
|
||||||
result.OldFile = strings.TrimPrefix(line, "--- a/")
|
result.OldFile = line[6:]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "+++ b/") {
|
if strings.HasPrefix(line, "+++ b/") {
|
||||||
result.NewFile = strings.TrimPrefix(line, "+++ b/")
|
result.NewFile = line[6:]
|
||||||
inFileHeader = false
|
inFileHeader = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse hunk headers
|
if strings.HasPrefix(line, "@@") {
|
||||||
if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
|
|
||||||
if currentHunk != nil {
|
if currentHunk != nil {
|
||||||
result.Hunks = append(result.Hunks, *currentHunk)
|
result.Hunks = append(result.Hunks, *currentHunk)
|
||||||
}
|
}
|
||||||
currentHunk = &Hunk{
|
currentHunk = &Hunk{
|
||||||
Header: line,
|
Header: line,
|
||||||
Lines: []DiffLine{},
|
Lines: make([]DiffLine, 0, 10), // Pre-allocate
|
||||||
}
|
}
|
||||||
|
|
||||||
oldStart, _ := strconv.Atoi(matches[1])
|
// Manual parsing of hunk header is faster than regex
|
||||||
newStart, _ := strconv.Atoi(matches[3])
|
parts := strings.Split(line, " ")
|
||||||
oldLine = oldStart
|
if len(parts) > 2 {
|
||||||
newLine = newStart
|
oldRange := strings.Split(parts[1][1:], ",")
|
||||||
|
newRange := strings.Split(parts[2][1:], ",")
|
||||||
|
oldLine, _ = strconv.Atoi(oldRange[0])
|
||||||
|
newLine, _ = strconv.Atoi(newRange[0])
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore "No newline at end of file" markers
|
if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
|
||||||
if strings.HasPrefix(line, "\\ No newline at end of file") {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentHunk == nil {
|
var dl DiffLine
|
||||||
continue
|
dl.Content = line
|
||||||
}
|
|
||||||
|
|
||||||
// Process the line based on its prefix
|
|
||||||
if len(line) > 0 {
|
if len(line) > 0 {
|
||||||
switch line[0] {
|
switch line[0] {
|
||||||
case '+':
|
case '+':
|
||||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
dl.Kind = LineAdded
|
||||||
OldLineNo: 0,
|
dl.NewLineNo = newLine
|
||||||
NewLineNo: newLine,
|
dl.Content = line[1:]
|
||||||
Kind: LineAdded,
|
|
||||||
Content: line[1:],
|
|
||||||
})
|
|
||||||
newLine++
|
newLine++
|
||||||
case '-':
|
case '-':
|
||||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
dl.Kind = LineRemoved
|
||||||
OldLineNo: oldLine,
|
dl.OldLineNo = oldLine
|
||||||
NewLineNo: 0,
|
dl.Content = line[1:]
|
||||||
Kind: LineRemoved,
|
|
||||||
Content: line[1:],
|
|
||||||
})
|
|
||||||
oldLine++
|
oldLine++
|
||||||
default:
|
default: // context line
|
||||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
dl.Kind = LineContext
|
||||||
OldLineNo: oldLine,
|
dl.OldLineNo = oldLine
|
||||||
NewLineNo: newLine,
|
dl.NewLineNo = newLine
|
||||||
Kind: LineContext,
|
|
||||||
Content: line,
|
|
||||||
})
|
|
||||||
oldLine++
|
oldLine++
|
||||||
newLine++
|
newLine++
|
||||||
}
|
}
|
||||||
} else {
|
} else { // empty context line
|
||||||
// Handle empty lines
|
dl.Kind = LineContext
|
||||||
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
|
dl.OldLineNo = oldLine
|
||||||
OldLineNo: oldLine,
|
dl.NewLineNo = newLine
|
||||||
NewLineNo: newLine,
|
|
||||||
Kind: LineContext,
|
|
||||||
Content: "",
|
|
||||||
})
|
|
||||||
oldLine++
|
oldLine++
|
||||||
newLine++
|
newLine++
|
||||||
}
|
}
|
||||||
|
currentHunk.Lines = append(currentHunk.Lines, dl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the last hunk if there is one
|
|
||||||
if currentHunk != nil {
|
if currentHunk != nil {
|
||||||
result.Hunks = append(result.Hunks, *currentHunk)
|
result.Hunks = append(result.Hunks, *currentHunk)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
|
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
|
||||||
@@ -744,8 +702,6 @@ func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, high
|
|||||||
content,
|
content,
|
||||||
width,
|
width,
|
||||||
"...",
|
"...",
|
||||||
// stylesi.NewStyleWithColors(t.TextMuted(), bgStyle.GetBackground()).Render("..."),
|
|
||||||
// stylesi.WithForeground(stylesi.NewStyle().Background(bgStyle.GetBackground()), t.TextMuted()).Render("..."),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -912,16 +868,17 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
|
|||||||
HighlightIntralineChanges(&hunkCopy)
|
HighlightIntralineChanges(&hunkCopy)
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, line := range hunkCopy.Lines {
|
sb.Grow(len(hunkCopy.Lines) * config.Width)
|
||||||
sb.WriteString(renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()))
|
|
||||||
sb.WriteString("\n")
|
util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
|
||||||
}
|
return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
|
||||||
|
})
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderSideBySideHunk formats a hunk for side-by-side display
|
// RenderSideBySideHunk formats a hunk for side-by-side display
|
||||||
func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
|
func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
|
||||||
// Apply options to create the configuration
|
// Apply options to create the configuration
|
||||||
config := NewSideBySideConfig(opts...)
|
config := NewSideBySideConfig(opts...)
|
||||||
|
|
||||||
@@ -936,10 +893,10 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
|
|||||||
pairs := pairLines(hunkCopy.Lines)
|
pairs := pairLines(hunkCopy.Lines)
|
||||||
|
|
||||||
// Calculate column width
|
// Calculate column width
|
||||||
colWidth := config.TotalWidth / 2
|
colWidth := config.Width / 2
|
||||||
|
|
||||||
leftWidth := colWidth
|
leftWidth := colWidth
|
||||||
rightWidth := config.TotalWidth - colWidth
|
rightWidth := config.Width - colWidth
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
util.WriteStringsPar(&sb, pairs, func(p linePair) string {
|
util.WriteStringsPar(&sb, pairs, func(p linePair) string {
|
||||||
@@ -969,32 +926,22 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, h := range diffResult.Hunks {
|
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
|
||||||
unifiedDiff := RenderUnifiedHunk(filename, h, opts...)
|
return RenderUnifiedHunk(filename, h, opts...)
|
||||||
sb.WriteString(unifiedDiff)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String(), nil
|
return sb.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatDiff creates a side-by-side formatted view of a diff
|
// FormatDiff creates a side-by-side formatted view of a diff
|
||||||
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
|
func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
|
||||||
// t := theme.CurrentTheme()
|
|
||||||
diffResult, err := ParseUnifiedDiff(diffText)
|
diffResult, err := ParseUnifiedDiff(diffText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
// config := NewSideBySideConfig(opts...)
|
|
||||||
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
|
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...)
|
return RenderSideBySideHunk(filename, h, opts...)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package fileviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/sst/opencode/internal/app"
|
||||||
|
"github.com/sst/opencode/internal/commands"
|
||||||
|
"github.com/sst/opencode/internal/components/dialog"
|
||||||
|
"github.com/sst/opencode/internal/components/diff"
|
||||||
|
"github.com/sst/opencode/internal/layout"
|
||||||
|
"github.com/sst/opencode/internal/styles"
|
||||||
|
"github.com/sst/opencode/internal/theme"
|
||||||
|
"github.com/sst/opencode/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiffStyle int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DiffStyleSplit DiffStyle = iota
|
||||||
|
DiffStyleUnified
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
app *app.App
|
||||||
|
width, height int
|
||||||
|
viewport viewport.Model
|
||||||
|
filename *string
|
||||||
|
content *string
|
||||||
|
isDiff *bool
|
||||||
|
diffStyle DiffStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileRenderedMsg struct {
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(app *app.App) Model {
|
||||||
|
vp := viewport.New()
|
||||||
|
m := Model{
|
||||||
|
app: app,
|
||||||
|
viewport: vp,
|
||||||
|
diffStyle: DiffStyleUnified,
|
||||||
|
}
|
||||||
|
if app.State.SplitDiff {
|
||||||
|
m.diffStyle = DiffStyleSplit
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return m.viewport.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case fileRenderedMsg:
|
||||||
|
m.viewport.SetContent(msg.content)
|
||||||
|
return m, util.CmdHandler(app.FileRenderedMsg{
|
||||||
|
FilePath: *m.filename,
|
||||||
|
})
|
||||||
|
case dialog.ThemeSelectedMsg:
|
||||||
|
return m, m.render()
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vp, cmd := m.viewport.Update(msg)
|
||||||
|
m.viewport = vp
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) View() string {
|
||||||
|
if !m.HasFile() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
header := *m.filename
|
||||||
|
header = styles.NewStyle().
|
||||||
|
Padding(1, 2).
|
||||||
|
Width(m.width).
|
||||||
|
Background(theme.CurrentTheme().BackgroundElement()).
|
||||||
|
Foreground(theme.CurrentTheme().Text()).
|
||||||
|
Render(header)
|
||||||
|
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
|
||||||
|
close := m.app.Key(commands.FileCloseCommand)
|
||||||
|
diffToggle := m.app.Key(commands.FileDiffToggleCommand)
|
||||||
|
if m.isDiff == nil || *m.isDiff == false {
|
||||||
|
diffToggle = ""
|
||||||
|
}
|
||||||
|
layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand)
|
||||||
|
|
||||||
|
background := t.Background()
|
||||||
|
footer := layout.Render(
|
||||||
|
layout.FlexOptions{
|
||||||
|
Background: &background,
|
||||||
|
Direction: layout.Row,
|
||||||
|
Justify: layout.JustifyCenter,
|
||||||
|
Align: layout.AlignStretch,
|
||||||
|
Width: m.width - 2,
|
||||||
|
Gap: 5,
|
||||||
|
},
|
||||||
|
layout.FlexItem{
|
||||||
|
View: close,
|
||||||
|
},
|
||||||
|
layout.FlexItem{
|
||||||
|
View: layoutToggle,
|
||||||
|
},
|
||||||
|
layout.FlexItem{
|
||||||
|
View: diffToggle,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer)
|
||||||
|
|
||||||
|
return header + "\n" + m.viewport.View() + "\n" + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Clear() (Model, tea.Cmd) {
|
||||||
|
m.filename = nil
|
||||||
|
m.content = nil
|
||||||
|
m.isDiff = nil
|
||||||
|
return *m, m.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) ToggleDiff() (Model, tea.Cmd) {
|
||||||
|
switch m.diffStyle {
|
||||||
|
case DiffStyleSplit:
|
||||||
|
m.diffStyle = DiffStyleUnified
|
||||||
|
default:
|
||||||
|
m.diffStyle = DiffStyleSplit
|
||||||
|
}
|
||||||
|
return *m, m.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) DiffStyle() DiffStyle {
|
||||||
|
return m.diffStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) HasFile() bool {
|
||||||
|
return m.filename != nil && m.content != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Filename() string {
|
||||||
|
if m.filename == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *m.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) SetSize(width, height int) (Model, tea.Cmd) {
|
||||||
|
if m.width != width || m.height != height {
|
||||||
|
m.width = width
|
||||||
|
m.height = height
|
||||||
|
m.viewport.SetWidth(width)
|
||||||
|
m.viewport.SetHeight(height - 4)
|
||||||
|
return *m, m.render()
|
||||||
|
}
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) {
|
||||||
|
m.filename = &filename
|
||||||
|
m.content = &content
|
||||||
|
m.isDiff = &isDiff
|
||||||
|
return *m, m.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) render() tea.Cmd {
|
||||||
|
if m.filename == nil || m.content == nil {
|
||||||
|
m.viewport.SetContent("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() tea.Msg {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
var rendered string
|
||||||
|
|
||||||
|
if m.isDiff != nil && *m.isDiff {
|
||||||
|
diffResult := ""
|
||||||
|
var err error
|
||||||
|
if m.diffStyle == DiffStyleSplit {
|
||||||
|
diffResult, err = diff.FormatDiff(
|
||||||
|
*m.filename,
|
||||||
|
*m.content,
|
||||||
|
diff.WithWidth(m.width),
|
||||||
|
)
|
||||||
|
} else if m.diffStyle == DiffStyleUnified {
|
||||||
|
diffResult, err = diff.FormatUnifiedDiff(
|
||||||
|
*m.filename,
|
||||||
|
*m.content,
|
||||||
|
diff.WithWidth(m.width),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
rendered = styles.NewStyle().
|
||||||
|
Foreground(t.Error()).
|
||||||
|
Render(fmt.Sprintf("Error rendering diff: %v", err))
|
||||||
|
} else {
|
||||||
|
rendered = strings.TrimRight(diffResult, "\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rendered = util.RenderFile(
|
||||||
|
*m.filename,
|
||||||
|
*m.content,
|
||||||
|
m.width,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered = styles.NewStyle().
|
||||||
|
Width(m.width).
|
||||||
|
Background(t.BackgroundPanel()).
|
||||||
|
Render(rendered)
|
||||||
|
|
||||||
|
return fileRenderedMsg{
|
||||||
|
content: rendered,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) ScrollTo(line int) {
|
||||||
|
m.viewport.SetYOffset(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) ScrollToBottom() {
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) ScrollToTop() {
|
||||||
|
m.viewport.GotoTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) PageUp() (Model, tea.Cmd) {
|
||||||
|
m.viewport.ViewUp()
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) PageDown() (Model, tea.Cmd) {
|
||||||
|
m.viewport.ViewDown()
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) HalfPageUp() (Model, tea.Cmd) {
|
||||||
|
m.viewport.HalfViewUp()
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) HalfPageDown() (Model, tea.Cmd) {
|
||||||
|
m.viewport.HalfViewDown()
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) AtTop() bool {
|
||||||
|
return m.viewport.AtTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) AtBottom() bool {
|
||||||
|
return m.viewport.AtBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) ScrollPercent() float64 {
|
||||||
|
return m.viewport.ScrollPercent()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) TotalLineCount() int {
|
||||||
|
return m.viewport.TotalLineCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) VisibleLineCount() int {
|
||||||
|
return m.viewport.VisibleLineCount()
|
||||||
|
}
|
||||||
@@ -135,11 +135,11 @@ func (m *Modal) Render(contentView string, background string) string {
|
|||||||
col := (bgWidth - modalWidth) / 2
|
col := (bgWidth - modalWidth) / 2
|
||||||
|
|
||||||
return layout.PlaceOverlay(
|
return layout.PlaceOverlay(
|
||||||
col,
|
col-1, // TODO: whyyyyy
|
||||||
row,
|
row,
|
||||||
modalView,
|
modalView,
|
||||||
background,
|
background,
|
||||||
layout.WithOverlayBorder(),
|
layout.WithOverlayBorder(),
|
||||||
layout.WithOverlayBorderColor(t.Primary()),
|
layout.WithOverlayBorderColor(t.BorderActive()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,58 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"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 {
|
type State struct {
|
||||||
Theme string `toml:"theme"`
|
Theme string `toml:"theme"`
|
||||||
Provider string `toml:"provider"`
|
Provider string `toml:"provider"`
|
||||||
Model string `toml:"model"`
|
Model string `toml:"model"`
|
||||||
|
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
|
||||||
|
MessagesRight bool `toml:"messages_right"`
|
||||||
|
SplitDiff bool `toml:"split_diff"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewState() *State {
|
func NewState() *State {
|
||||||
return &State{
|
return &State{
|
||||||
Theme: "opencode",
|
Theme: "opencode",
|
||||||
|
RecentlyUsedModels: make([]ModelUsage, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
|
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
|
"github.com/sst/opencode/internal/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Direction int
|
type Direction int
|
||||||
@@ -34,11 +36,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type FlexOptions struct {
|
type FlexOptions struct {
|
||||||
Direction Direction
|
Background *compat.AdaptiveColor
|
||||||
Justify Justify
|
Direction Direction
|
||||||
Align Align
|
Justify Justify
|
||||||
Width int
|
Align Align
|
||||||
Height int
|
Width int
|
||||||
|
Height int
|
||||||
|
Gap int
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlexItem struct {
|
type FlexItem struct {
|
||||||
@@ -53,6 +57,12 @@ func Render(opts FlexOptions, items ...FlexItem) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
if opts.Background == nil {
|
||||||
|
background := t.Background()
|
||||||
|
opts.Background = &background
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate dimensions for each item
|
// Calculate dimensions for each item
|
||||||
mainAxisSize := opts.Width
|
mainAxisSize := opts.Width
|
||||||
crossAxisSize := opts.Height
|
crossAxisSize := opts.Height
|
||||||
@@ -72,8 +82,14 @@ func Render(opts FlexOptions, items ...FlexItem) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Account for gaps between items
|
||||||
|
totalGapSize := 0
|
||||||
|
if len(items) > 1 && opts.Gap > 0 {
|
||||||
|
totalGapSize = opts.Gap * (len(items) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate available space for grow items
|
// Calculate available space for grow items
|
||||||
availableSpace := max(mainAxisSize-totalFixedSize, 0)
|
availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0)
|
||||||
|
|
||||||
// Calculate size for each grow item
|
// Calculate size for each grow item
|
||||||
growItemSize := 0
|
growItemSize := 0
|
||||||
@@ -108,6 +124,7 @@ func Render(opts FlexOptions, items ...FlexItem) string {
|
|||||||
// For row direction, constrain width and handle height alignment
|
// For row direction, constrain width and handle height alignment
|
||||||
if itemSize > 0 {
|
if itemSize > 0 {
|
||||||
view = styles.NewStyle().
|
view = styles.NewStyle().
|
||||||
|
Background(*opts.Background).
|
||||||
Width(itemSize).
|
Width(itemSize).
|
||||||
Height(crossAxisSize).
|
Height(crossAxisSize).
|
||||||
Render(view)
|
Render(view)
|
||||||
@@ -116,31 +133,65 @@ func Render(opts FlexOptions, items ...FlexItem) string {
|
|||||||
// Apply cross-axis alignment
|
// Apply cross-axis alignment
|
||||||
switch opts.Align {
|
switch opts.Align {
|
||||||
case AlignCenter:
|
case AlignCenter:
|
||||||
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view)
|
view = lipgloss.PlaceVertical(
|
||||||
|
crossAxisSize,
|
||||||
|
lipgloss.Center,
|
||||||
|
view,
|
||||||
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
|
)
|
||||||
case AlignEnd:
|
case AlignEnd:
|
||||||
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view)
|
view = lipgloss.PlaceVertical(
|
||||||
|
crossAxisSize,
|
||||||
|
lipgloss.Bottom,
|
||||||
|
view,
|
||||||
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
|
)
|
||||||
case AlignStart:
|
case AlignStart:
|
||||||
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view)
|
view = lipgloss.PlaceVertical(
|
||||||
|
crossAxisSize,
|
||||||
|
lipgloss.Top,
|
||||||
|
view,
|
||||||
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
|
)
|
||||||
case AlignStretch:
|
case AlignStretch:
|
||||||
// Already stretched by Height setting above
|
// Already stretched by Height setting above
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For column direction, constrain height and handle width alignment
|
// For column direction, constrain height and handle width alignment
|
||||||
if itemSize > 0 {
|
if itemSize > 0 {
|
||||||
view = styles.NewStyle().
|
style := styles.NewStyle().
|
||||||
Height(itemSize).
|
Background(*opts.Background).
|
||||||
Width(crossAxisSize).
|
Height(itemSize)
|
||||||
Render(view)
|
// Only set width for stretch alignment
|
||||||
|
if opts.Align == AlignStretch {
|
||||||
|
style = style.Width(crossAxisSize)
|
||||||
|
}
|
||||||
|
view = style.Render(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply cross-axis alignment
|
// Apply cross-axis alignment
|
||||||
switch opts.Align {
|
switch opts.Align {
|
||||||
case AlignCenter:
|
case AlignCenter:
|
||||||
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view)
|
view = lipgloss.PlaceHorizontal(
|
||||||
|
crossAxisSize,
|
||||||
|
lipgloss.Center,
|
||||||
|
view,
|
||||||
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
|
)
|
||||||
case AlignEnd:
|
case AlignEnd:
|
||||||
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view)
|
view = lipgloss.PlaceHorizontal(
|
||||||
|
crossAxisSize,
|
||||||
|
lipgloss.Right,
|
||||||
|
view,
|
||||||
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
|
)
|
||||||
case AlignStart:
|
case AlignStart:
|
||||||
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view)
|
view = lipgloss.PlaceHorizontal(
|
||||||
|
crossAxisSize,
|
||||||
|
lipgloss.Left,
|
||||||
|
view,
|
||||||
|
styles.WhitespaceStyle(*opts.Background),
|
||||||
|
)
|
||||||
case AlignStretch:
|
case AlignStretch:
|
||||||
// Already stretched by Width setting above
|
// Already stretched by Width setting above
|
||||||
}
|
}
|
||||||
@@ -154,11 +205,14 @@ func Render(opts FlexOptions, items ...FlexItem) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total actual size
|
// Calculate total actual size including gaps
|
||||||
totalActualSize := 0
|
totalActualSize := 0
|
||||||
for _, size := range actualSizes {
|
for _, size := range actualSizes {
|
||||||
totalActualSize += size
|
totalActualSize += size
|
||||||
}
|
}
|
||||||
|
if len(items) > 1 && opts.Gap > 0 {
|
||||||
|
totalActualSize += opts.Gap * (len(items) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply justification
|
// Apply justification
|
||||||
remainingSpace := max(mainAxisSize-totalActualSize, 0)
|
remainingSpace := max(mainAxisSize-totalActualSize, 0)
|
||||||
@@ -191,12 +245,17 @@ func Render(opts FlexOptions, items ...FlexItem) string {
|
|||||||
// Build the final layout
|
// Build the final layout
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|
||||||
|
spaceStyle := styles.NewStyle().Background(*opts.Background)
|
||||||
// Add space before if needed
|
// Add space before if needed
|
||||||
if spaceBefore > 0 {
|
if spaceBefore > 0 {
|
||||||
if opts.Direction == Row {
|
if opts.Direction == Row {
|
||||||
parts = append(parts, strings.Repeat(" ", spaceBefore))
|
space := strings.Repeat(" ", spaceBefore)
|
||||||
|
parts = append(parts, spaceStyle.Render(space))
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, strings.Repeat("\n", spaceBefore))
|
// For vertical layout, add empty lines as separate parts
|
||||||
|
for range spaceBefore {
|
||||||
|
parts = append(parts, "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,11 +264,19 @@ func Render(opts FlexOptions, items ...FlexItem) string {
|
|||||||
parts = append(parts, view)
|
parts = append(parts, view)
|
||||||
|
|
||||||
// Add space between items (not after the last one)
|
// Add space between items (not after the last one)
|
||||||
if i < len(sizedViews)-1 && spaceBetween > 0 {
|
if i < len(sizedViews)-1 {
|
||||||
if opts.Direction == Row {
|
// Add gap first, then any additional spacing from justification
|
||||||
parts = append(parts, strings.Repeat(" ", spaceBetween))
|
totalSpacing := opts.Gap + spaceBetween
|
||||||
} else {
|
if totalSpacing > 0 {
|
||||||
parts = append(parts, strings.Repeat("\n", spaceBetween))
|
if opts.Direction == Row {
|
||||||
|
space := strings.Repeat(" ", totalSpacing)
|
||||||
|
parts = append(parts, spaceStyle.Render(space))
|
||||||
|
} else {
|
||||||
|
// For vertical layout, add empty lines as separate parts
|
||||||
|
for range totalSpacing {
|
||||||
|
parts = append(parts, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,9 +284,13 @@ func Render(opts FlexOptions, items ...FlexItem) string {
|
|||||||
// Add space after if needed
|
// Add space after if needed
|
||||||
if spaceAfter > 0 {
|
if spaceAfter > 0 {
|
||||||
if opts.Direction == Row {
|
if opts.Direction == Row {
|
||||||
parts = append(parts, strings.Repeat(" ", spaceAfter))
|
space := strings.Repeat(" ", spaceAfter)
|
||||||
|
parts = append(parts, spaceStyle.Render(space))
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, strings.Repeat("\n", spaceAfter))
|
// For vertical layout, add empty lines as separate parts
|
||||||
|
for range spaceAfter {
|
||||||
|
parts = append(parts, "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package layout_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/sst/opencode/internal/layout"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleRender_withGap() {
|
||||||
|
// Create a horizontal layout with 3px gap between items
|
||||||
|
result := layout.Render(
|
||||||
|
layout.FlexOptions{
|
||||||
|
Direction: layout.Row,
|
||||||
|
Width: 30,
|
||||||
|
Height: 1,
|
||||||
|
Gap: 3,
|
||||||
|
},
|
||||||
|
layout.FlexItem{View: "Item1"},
|
||||||
|
layout.FlexItem{View: "Item2"},
|
||||||
|
layout.FlexItem{View: "Item3"},
|
||||||
|
)
|
||||||
|
fmt.Println(result)
|
||||||
|
// Output: Item1 Item2 Item3
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleRender_withGapAndJustify() {
|
||||||
|
// Create a horizontal layout with gap and space-between justification
|
||||||
|
result := layout.Render(
|
||||||
|
layout.FlexOptions{
|
||||||
|
Direction: layout.Row,
|
||||||
|
Width: 30,
|
||||||
|
Height: 1,
|
||||||
|
Gap: 2,
|
||||||
|
Justify: layout.JustifySpaceBetween,
|
||||||
|
},
|
||||||
|
layout.FlexItem{View: "A"},
|
||||||
|
layout.FlexItem{View: "B"},
|
||||||
|
layout.FlexItem{View: "C"},
|
||||||
|
)
|
||||||
|
fmt.Println(result)
|
||||||
|
// Output: A B C
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFlexGap(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts FlexOptions
|
||||||
|
items []FlexItem
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Row with gap",
|
||||||
|
opts: FlexOptions{
|
||||||
|
Direction: Row,
|
||||||
|
Width: 20,
|
||||||
|
Height: 1,
|
||||||
|
Gap: 2,
|
||||||
|
},
|
||||||
|
items: []FlexItem{
|
||||||
|
{View: "A"},
|
||||||
|
{View: "B"},
|
||||||
|
{View: "C"},
|
||||||
|
},
|
||||||
|
expected: "A B C",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Column with gap",
|
||||||
|
opts: FlexOptions{
|
||||||
|
Direction: Column,
|
||||||
|
Width: 1,
|
||||||
|
Height: 5,
|
||||||
|
Gap: 1,
|
||||||
|
Align: AlignStart,
|
||||||
|
},
|
||||||
|
items: []FlexItem{
|
||||||
|
{View: "A", FixedSize: 1},
|
||||||
|
{View: "B", FixedSize: 1},
|
||||||
|
{View: "C", FixedSize: 1},
|
||||||
|
},
|
||||||
|
expected: "A\n \nB\n \nC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Row with gap and justify space between",
|
||||||
|
opts: FlexOptions{
|
||||||
|
Direction: Row,
|
||||||
|
Width: 15,
|
||||||
|
Height: 1,
|
||||||
|
Gap: 1,
|
||||||
|
Justify: JustifySpaceBetween,
|
||||||
|
},
|
||||||
|
items: []FlexItem{
|
||||||
|
{View: "A"},
|
||||||
|
{View: "B"},
|
||||||
|
{View: "C"},
|
||||||
|
},
|
||||||
|
expected: "A B C",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No gap specified",
|
||||||
|
opts: FlexOptions{
|
||||||
|
Direction: Row,
|
||||||
|
Width: 10,
|
||||||
|
Height: 1,
|
||||||
|
},
|
||||||
|
items: []FlexItem{
|
||||||
|
{View: "A"},
|
||||||
|
{View: "B"},
|
||||||
|
{View: "C"},
|
||||||
|
},
|
||||||
|
expected: "ABC",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := Render(tt.opts, tt.items...)
|
||||||
|
// Trim any trailing spaces for comparison
|
||||||
|
result = strings.TrimRight(result, " ")
|
||||||
|
expected := strings.TrimRight(tt.expected, " ")
|
||||||
|
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Render() = %q, want %q", result, expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ type LoadedTheme struct {
|
|||||||
name string
|
name string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *LoadedTheme) Name() string {
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
|
||||||
type colorRef struct {
|
type colorRef struct {
|
||||||
value any
|
value any
|
||||||
resolved bool
|
resolved bool
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ func NewSystemTheme(terminalBg color.Color, isDark bool) *SystemTheme {
|
|||||||
return theme
|
return theme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *SystemTheme) Name() string {
|
||||||
|
return "system"
|
||||||
|
}
|
||||||
|
|
||||||
// initializeColors sets up all theme colors
|
// initializeColors sets up all theme colors
|
||||||
func (t *SystemTheme) initializeColors() {
|
func (t *SystemTheme) initializeColors() {
|
||||||
// Generate gray scale based on terminal background
|
// Generate gray scale based on terminal background
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
// All colors must be defined as compat.AdaptiveColor to support
|
// All colors must be defined as compat.AdaptiveColor to support
|
||||||
// both light and dark terminal backgrounds.
|
// both light and dark terminal backgrounds.
|
||||||
type Theme interface {
|
type Theme interface {
|
||||||
|
Name() string
|
||||||
|
|
||||||
// Background colors
|
// Background colors
|
||||||
Background() compat.AdaptiveColor // Radix 1
|
Background() compat.AdaptiveColor // Radix 1
|
||||||
BackgroundPanel() compat.AdaptiveColor // Radix 2
|
BackgroundPanel() compat.AdaptiveColor // Radix 2
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/sst/opencode/internal/components/chat"
|
"github.com/sst/opencode/internal/components/chat"
|
||||||
cmdcomp "github.com/sst/opencode/internal/components/commands"
|
cmdcomp "github.com/sst/opencode/internal/components/commands"
|
||||||
"github.com/sst/opencode/internal/components/dialog"
|
"github.com/sst/opencode/internal/components/dialog"
|
||||||
|
"github.com/sst/opencode/internal/components/fileviewer"
|
||||||
"github.com/sst/opencode/internal/components/modal"
|
"github.com/sst/opencode/internal/components/modal"
|
||||||
"github.com/sst/opencode/internal/components/status"
|
"github.com/sst/opencode/internal/components/status"
|
||||||
"github.com/sst/opencode/internal/components/toast"
|
"github.com/sst/opencode/internal/components/toast"
|
||||||
@@ -40,6 +41,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const interruptDebounceTimeout = 1 * time.Second
|
const interruptDebounceTimeout = 1 * time.Second
|
||||||
|
const fileViewerFullWidthCutoff = 200
|
||||||
|
|
||||||
type appModel struct {
|
type appModel struct {
|
||||||
width, height int
|
width, height int
|
||||||
@@ -56,6 +58,12 @@ type appModel struct {
|
|||||||
toastManager *toast.ToastManager
|
toastManager *toast.ToastManager
|
||||||
interruptKeyState InterruptKeyState
|
interruptKeyState InterruptKeyState
|
||||||
lastScroll time.Time
|
lastScroll time.Time
|
||||||
|
messagesRight bool
|
||||||
|
fileViewer fileviewer.Model
|
||||||
|
lastMouse tea.Mouse
|
||||||
|
fileViewerStart int
|
||||||
|
fileViewerEnd int
|
||||||
|
fileViewerHit bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a appModel) Init() tea.Cmd {
|
func (a appModel) Init() tea.Cmd {
|
||||||
@@ -71,6 +79,7 @@ func (a appModel) Init() tea.Cmd {
|
|||||||
cmds = append(cmds, a.status.Init())
|
cmds = append(cmds, a.status.Init())
|
||||||
cmds = append(cmds, a.completions.Init())
|
cmds = append(cmds, a.completions.Init())
|
||||||
cmds = append(cmds, a.toastManager.Init())
|
cmds = append(cmds, a.toastManager.Init())
|
||||||
|
cmds = append(cmds, a.fileViewer.Init())
|
||||||
|
|
||||||
// Check if we should show the init dialog
|
// Check if we should show the init dialog
|
||||||
cmds = append(cmds, func() tea.Msg {
|
cmds = append(cmds, func() tea.Msg {
|
||||||
@@ -99,6 +108,7 @@ var BUGGED_SCROLL_KEYS = map[string]bool{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
@@ -112,10 +122,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if a.modal != nil {
|
if a.modal != nil {
|
||||||
switch keyString {
|
switch keyString {
|
||||||
// Escape always closes current modal
|
// Escape always closes current modal
|
||||||
case "esc", "ctrl+c":
|
case "esc":
|
||||||
cmd := a.modal.Close()
|
cmd := a.modal.Close()
|
||||||
a.modal = nil
|
a.modal = nil
|
||||||
return a, cmd
|
return a, cmd
|
||||||
|
case "ctrl+c":
|
||||||
|
// give the modal a chance to handle the ctrl+c
|
||||||
|
updatedModal, cmd := a.modal.Update(msg)
|
||||||
|
a.modal = updatedModal.(layout.Modal)
|
||||||
|
if cmd != nil {
|
||||||
|
return a, cmd
|
||||||
|
}
|
||||||
|
cmd = a.modal.Close()
|
||||||
|
a.modal = nil
|
||||||
|
return a, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass all other key presses to the modal
|
// Pass all other key presses to the modal
|
||||||
@@ -246,10 +266,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if a.modal != nil {
|
if a.modal != nil {
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
updated, cmd := a.messages.Update(msg)
|
|
||||||
a.messages = updated.(chat.MessagesComponent)
|
var cmd tea.Cmd
|
||||||
cmds = append(cmds, cmd)
|
if a.fileViewerHit {
|
||||||
|
a.fileViewer, cmd = a.fileViewer.Update(msg)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
} else {
|
||||||
|
updated, cmd := a.messages.Update(msg)
|
||||||
|
a.messages = updated.(chat.MessagesComponent)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
return a, tea.Batch(cmds...)
|
return a, tea.Batch(cmds...)
|
||||||
|
case tea.MouseMotionMsg:
|
||||||
|
a.lastMouse = msg.Mouse()
|
||||||
|
a.fileViewerHit = a.fileViewer.HasFile() &&
|
||||||
|
a.lastMouse.X > a.fileViewerStart &&
|
||||||
|
a.lastMouse.X < a.fileViewerEnd
|
||||||
|
case tea.MouseClickMsg:
|
||||||
|
a.lastMouse = msg.Mouse()
|
||||||
|
a.fileViewerHit = a.fileViewer.HasFile() &&
|
||||||
|
a.lastMouse.X > a.fileViewerStart &&
|
||||||
|
a.lastMouse.X < a.fileViewerEnd
|
||||||
case tea.BackgroundColorMsg:
|
case tea.BackgroundColorMsg:
|
||||||
styles.Terminal = &styles.TerminalInfo{
|
styles.Terminal = &styles.TerminalInfo{
|
||||||
Background: msg.Color,
|
Background: msg.Color,
|
||||||
@@ -266,6 +304,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case modal.CloseModalMsg:
|
case modal.CloseModalMsg:
|
||||||
|
a.editor.Focus()
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
if a.modal != nil {
|
if a.modal != nil {
|
||||||
cmd = a.modal.Close()
|
cmd = a.modal.Close()
|
||||||
@@ -349,22 +388,47 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
|
slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
|
||||||
return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
|
return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
|
||||||
}
|
}
|
||||||
|
case opencode.EventListResponseEventFileWatcherUpdated:
|
||||||
|
if a.fileViewer.HasFile() {
|
||||||
|
if a.fileViewer.Filename() == msg.Properties.File {
|
||||||
|
return a.openFile(msg.Properties.File)
|
||||||
|
}
|
||||||
|
}
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
msg.Height -= 2 // Make space for the status bar
|
msg.Height -= 2 // Make space for the status bar
|
||||||
a.width, a.height = msg.Width, msg.Height
|
a.width, a.height = msg.Width, msg.Height
|
||||||
|
container := min(a.width, 84)
|
||||||
|
if a.fileViewer.HasFile() {
|
||||||
|
if a.width < fileViewerFullWidthCutoff {
|
||||||
|
container = a.width
|
||||||
|
} else {
|
||||||
|
container = min(min(a.width, max(a.width/2, 50)), 84)
|
||||||
|
}
|
||||||
|
}
|
||||||
layout.Current = &layout.LayoutInfo{
|
layout.Current = &layout.LayoutInfo{
|
||||||
Viewport: layout.Dimensions{
|
Viewport: layout.Dimensions{
|
||||||
Width: a.width,
|
Width: a.width,
|
||||||
Height: a.height,
|
Height: a.height,
|
||||||
},
|
},
|
||||||
Container: layout.Dimensions{
|
Container: layout.Dimensions{
|
||||||
Width: min(a.width, 80),
|
Width: container,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// Update child component sizes
|
mainWidth := layout.Current.Container.Width
|
||||||
messagesHeight := a.height - 6 // Leave room for editor and status bar
|
a.messages.SetWidth(mainWidth - 4)
|
||||||
a.messages.SetSize(a.width, messagesHeight)
|
|
||||||
a.editor.SetSize(min(a.width, 80), 5)
|
sideWidth := a.width - mainWidth
|
||||||
|
if a.width < fileViewerFullWidthCutoff {
|
||||||
|
sideWidth = a.width
|
||||||
|
}
|
||||||
|
a.fileViewerStart = mainWidth
|
||||||
|
a.fileViewerEnd = a.fileViewerStart + sideWidth
|
||||||
|
if a.messagesRight {
|
||||||
|
a.fileViewerStart = 0
|
||||||
|
a.fileViewerEnd = sideWidth
|
||||||
|
}
|
||||||
|
a.fileViewer, cmd = a.fileViewer.SetSize(sideWidth, layout.Current.Viewport.Height)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
case app.SessionSelectedMsg:
|
case app.SessionSelectedMsg:
|
||||||
messages, err := a.app.ListMessages(context.Background(), msg.ID)
|
messages, err := a.app.ListMessages(context.Background(), msg.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -373,11 +437,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
a.app.Session = msg
|
a.app.Session = msg
|
||||||
a.app.Messages = messages
|
a.app.Messages = messages
|
||||||
|
return a, util.CmdHandler(app.SessionLoadedMsg{})
|
||||||
case app.ModelSelectedMsg:
|
case app.ModelSelectedMsg:
|
||||||
a.app.Provider = &msg.Provider
|
a.app.Provider = &msg.Provider
|
||||||
a.app.Model = &msg.Model
|
a.app.Model = &msg.Model
|
||||||
a.app.State.Provider = msg.Provider.ID
|
a.app.State.Provider = msg.Provider.ID
|
||||||
a.app.State.Model = msg.Model.ID
|
a.app.State.Model = msg.Model.ID
|
||||||
|
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
|
||||||
a.app.SaveState()
|
a.app.SaveState()
|
||||||
case dialog.ThemeSelectedMsg:
|
case dialog.ThemeSelectedMsg:
|
||||||
a.app.State.Theme = msg.ThemeName
|
a.app.State.Theme = msg.ThemeName
|
||||||
@@ -394,24 +460,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// Reset interrupt key state after timeout
|
// Reset interrupt key state after timeout
|
||||||
a.interruptKeyState = InterruptKeyIdle
|
a.interruptKeyState = InterruptKeyIdle
|
||||||
a.editor.SetInterruptKeyInDebounce(false)
|
a.editor.SetInterruptKeyInDebounce(false)
|
||||||
|
case dialog.FindSelectedMsg:
|
||||||
|
return a.openFile(msg.FilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// update status bar
|
|
||||||
s, cmd := a.status.Update(msg)
|
s, cmd := a.status.Update(msg)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
a.status = s.(status.StatusComponent)
|
a.status = s.(status.StatusComponent)
|
||||||
|
|
||||||
// update editor
|
|
||||||
u, cmd := a.editor.Update(msg)
|
u, cmd := a.editor.Update(msg)
|
||||||
a.editor = u.(chat.EditorComponent)
|
a.editor = u.(chat.EditorComponent)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
// update messages
|
|
||||||
u, cmd = a.messages.Update(msg)
|
u, cmd = a.messages.Update(msg)
|
||||||
a.messages = u.(chat.MessagesComponent)
|
a.messages = u.(chat.MessagesComponent)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
// update modal
|
|
||||||
if a.modal != nil {
|
if a.modal != nil {
|
||||||
u, cmd := a.modal.Update(msg)
|
u, cmd := a.modal.Update(msg)
|
||||||
a.modal = u.(layout.Modal)
|
a.modal = u.(layout.Modal)
|
||||||
@@ -424,85 +488,95 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fv, cmd := a.fileViewer.Update(msg)
|
||||||
|
a.fileViewer = fv
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
return a, tea.Batch(cmds...)
|
return a, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a appModel) View() string {
|
func (a appModel) View() string {
|
||||||
editorView := a.editor.View()
|
|
||||||
lines := a.editor.Lines()
|
|
||||||
messagesView := a.messages.View()
|
|
||||||
if a.app.Session.ID == "" {
|
|
||||||
messagesView = a.home()
|
|
||||||
}
|
|
||||||
editorHeight := max(lines, 5)
|
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
centeredEditorView := lipgloss.PlaceHorizontal(
|
|
||||||
a.width,
|
|
||||||
lipgloss.Center,
|
|
||||||
editorView,
|
|
||||||
styles.WhitespaceStyle(t.Background()),
|
|
||||||
)
|
|
||||||
|
|
||||||
mainLayout := layout.Render(
|
var mainLayout string
|
||||||
layout.FlexOptions{
|
mainWidth := layout.Current.Container.Width - 4
|
||||||
Direction: layout.Column,
|
if a.app.Session.ID == "" {
|
||||||
Width: a.width,
|
mainLayout = a.home(mainWidth)
|
||||||
Height: a.height,
|
} else {
|
||||||
},
|
mainLayout = a.chat(mainWidth)
|
||||||
layout.FlexItem{
|
}
|
||||||
View: messagesView,
|
mainLayout = styles.NewStyle().
|
||||||
Grow: true,
|
Background(t.Background()).
|
||||||
},
|
Padding(0, 2).
|
||||||
layout.FlexItem{
|
Render(mainLayout)
|
||||||
View: centeredEditorView,
|
|
||||||
FixedSize: 5,
|
|
||||||
},
|
|
||||||
// layout.FlexItem{
|
|
||||||
// View: a.status.View(),
|
|
||||||
// FixedSize: 1,
|
|
||||||
// },
|
|
||||||
)
|
|
||||||
|
|
||||||
if lines > 1 {
|
mainHeight := lipgloss.Height(mainLayout)
|
||||||
editorWidth := min(a.width, 80)
|
|
||||||
editorX := (a.width - editorWidth) / 2
|
if a.fileViewer.HasFile() {
|
||||||
editorY := a.height - editorHeight
|
file := a.fileViewer.View()
|
||||||
mainLayout = layout.PlaceOverlay(
|
baseStyle := styles.NewStyle().Background(t.BackgroundPanel())
|
||||||
editorX,
|
sidePanel := baseStyle.Height(mainHeight).Render(file)
|
||||||
editorY,
|
if a.width >= fileViewerFullWidthCutoff {
|
||||||
a.editor.Content(),
|
if a.messagesRight {
|
||||||
|
mainLayout = lipgloss.JoinHorizontal(
|
||||||
|
lipgloss.Top,
|
||||||
|
sidePanel,
|
||||||
|
mainLayout,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
mainLayout = lipgloss.JoinHorizontal(
|
||||||
|
lipgloss.Top,
|
||||||
|
mainLayout,
|
||||||
|
sidePanel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainLayout = sidePanel
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainLayout = lipgloss.PlaceHorizontal(
|
||||||
|
a.width,
|
||||||
|
lipgloss.Center,
|
||||||
mainLayout,
|
mainLayout,
|
||||||
|
styles.WhitespaceStyle(t.Background()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.showCompletionDialog {
|
mainStyle := styles.NewStyle().Background(t.Background())
|
||||||
editorWidth := min(a.width, 80)
|
mainLayout = mainStyle.Render(mainLayout)
|
||||||
editorX := (a.width - editorWidth) / 2
|
|
||||||
a.completions.SetWidth(editorWidth)
|
|
||||||
overlay := a.completions.View()
|
|
||||||
overlayHeight := lipgloss.Height(overlay)
|
|
||||||
editorY := a.height - editorHeight + 1
|
|
||||||
|
|
||||||
mainLayout = layout.PlaceOverlay(
|
|
||||||
editorX,
|
|
||||||
editorY-overlayHeight,
|
|
||||||
overlay,
|
|
||||||
mainLayout,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.modal != nil {
|
if a.modal != nil {
|
||||||
mainLayout = a.modal.Render(mainLayout)
|
mainLayout = a.modal.Render(mainLayout)
|
||||||
}
|
}
|
||||||
mainLayout = a.toastManager.RenderOverlay(mainLayout)
|
mainLayout = a.toastManager.RenderOverlay(mainLayout)
|
||||||
|
|
||||||
if theme.CurrentThemeUsesAnsiColors() {
|
if theme.CurrentThemeUsesAnsiColors() {
|
||||||
mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
|
mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
|
||||||
}
|
}
|
||||||
return mainLayout + "\n" + a.status.View()
|
return mainLayout + "\n" + a.status.View()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a appModel) home() string {
|
func (a appModel) openFile(filepath string) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
response, err := a.app.Client.File.Read(
|
||||||
|
context.Background(),
|
||||||
|
opencode.FileReadParams{
|
||||||
|
Path: opencode.F(filepath),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to read file", "error", err)
|
||||||
|
return a, toast.NewErrorToast("Failed to read file")
|
||||||
|
}
|
||||||
|
a.fileViewer, cmd = a.fileViewer.SetFile(
|
||||||
|
filepath,
|
||||||
|
response.Content,
|
||||||
|
response.Type == "patch",
|
||||||
|
)
|
||||||
|
return a, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a appModel) home(width int) string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
baseStyle := styles.NewStyle().Background(t.Background())
|
baseStyle := styles.NewStyle().Background(t.Background())
|
||||||
base := baseStyle.Render
|
base := baseStyle.Render
|
||||||
@@ -534,7 +608,7 @@ func (a appModel) home() string {
|
|||||||
|
|
||||||
logoAndVersion := strings.Join([]string{logo, version}, "\n")
|
logoAndVersion := strings.Join([]string{logo, version}, "\n")
|
||||||
logoAndVersion = lipgloss.PlaceHorizontal(
|
logoAndVersion = lipgloss.PlaceHorizontal(
|
||||||
a.width,
|
width,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
logoAndVersion,
|
logoAndVersion,
|
||||||
styles.WhitespaceStyle(t.Background()),
|
styles.WhitespaceStyle(t.Background()),
|
||||||
@@ -545,13 +619,15 @@ func (a appModel) home() string {
|
|||||||
cmdcomp.WithLimit(6),
|
cmdcomp.WithLimit(6),
|
||||||
)
|
)
|
||||||
cmds := lipgloss.PlaceHorizontal(
|
cmds := lipgloss.PlaceHorizontal(
|
||||||
a.width,
|
width,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
commandsView.View(),
|
commandsView.View(),
|
||||||
styles.WhitespaceStyle(t.Background()),
|
styles.WhitespaceStyle(t.Background()),
|
||||||
)
|
)
|
||||||
|
|
||||||
lines := []string{}
|
lines := []string{}
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, "")
|
||||||
lines = append(lines, logoAndVersion)
|
lines = append(lines, logoAndVersion)
|
||||||
lines = append(lines, "")
|
lines = append(lines, "")
|
||||||
lines = append(lines, "")
|
lines = append(lines, "")
|
||||||
@@ -559,18 +635,100 @@ func (a appModel) home() string {
|
|||||||
// lines = append(lines, base("config ")+muted(config))
|
// lines = append(lines, base("config ")+muted(config))
|
||||||
// lines = append(lines, "")
|
// lines = append(lines, "")
|
||||||
lines = append(lines, cmds)
|
lines = append(lines, cmds)
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, "")
|
||||||
|
|
||||||
return lipgloss.Place(
|
mainHeight := lipgloss.Height(strings.Join(lines, "\n"))
|
||||||
a.width,
|
|
||||||
a.height-5,
|
editorWidth := min(width, 80)
|
||||||
|
editorView := a.editor.View(editorWidth)
|
||||||
|
editorView = lipgloss.PlaceHorizontal(
|
||||||
|
width,
|
||||||
|
lipgloss.Center,
|
||||||
|
editorView,
|
||||||
|
styles.WhitespaceStyle(t.Background()),
|
||||||
|
)
|
||||||
|
lines = append(lines, editorView)
|
||||||
|
|
||||||
|
editorLines := a.editor.Lines()
|
||||||
|
|
||||||
|
mainLayout := lipgloss.Place(
|
||||||
|
width,
|
||||||
|
a.height,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
baseStyle.Render(strings.Join(lines, "\n")),
|
baseStyle.Render(strings.Join(lines, "\n")),
|
||||||
styles.WhitespaceStyle(t.Background()),
|
styles.WhitespaceStyle(t.Background()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
editorX := (width - editorWidth) / 2
|
||||||
|
editorY := (a.height / 2) + (mainHeight / 2) - 2
|
||||||
|
|
||||||
|
if editorLines > 1 {
|
||||||
|
mainLayout = layout.PlaceOverlay(
|
||||||
|
editorX,
|
||||||
|
editorY,
|
||||||
|
a.editor.Content(editorWidth),
|
||||||
|
mainLayout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.showCompletionDialog {
|
||||||
|
a.completions.SetWidth(editorWidth)
|
||||||
|
overlay := a.completions.View()
|
||||||
|
overlayHeight := lipgloss.Height(overlay)
|
||||||
|
|
||||||
|
mainLayout = layout.PlaceOverlay(
|
||||||
|
editorX,
|
||||||
|
editorY-overlayHeight+1,
|
||||||
|
overlay,
|
||||||
|
mainLayout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a appModel) chat(width int) string {
|
||||||
|
editorView := a.editor.View(width)
|
||||||
|
lines := a.editor.Lines()
|
||||||
|
messagesView := a.messages.View(width, a.height-5)
|
||||||
|
|
||||||
|
editorWidth := lipgloss.Width(editorView)
|
||||||
|
editorHeight := max(lines, 5)
|
||||||
|
|
||||||
|
mainLayout := messagesView + "\n" + editorView
|
||||||
|
editorX := (a.width - editorWidth) / 2
|
||||||
|
|
||||||
|
if lines > 1 {
|
||||||
|
editorY := a.height - editorHeight
|
||||||
|
mainLayout = layout.PlaceOverlay(
|
||||||
|
editorX,
|
||||||
|
editorY,
|
||||||
|
a.editor.Content(width),
|
||||||
|
mainLayout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.showCompletionDialog {
|
||||||
|
a.completions.SetWidth(editorWidth)
|
||||||
|
overlay := a.completions.View()
|
||||||
|
overlayHeight := lipgloss.Height(overlay)
|
||||||
|
editorY := a.height - editorHeight + 1
|
||||||
|
|
||||||
|
mainLayout = layout.PlaceOverlay(
|
||||||
|
editorX,
|
||||||
|
editorY-overlayHeight,
|
||||||
|
overlay,
|
||||||
|
mainLayout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
|
func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
cmds := []tea.Cmd{
|
cmds := []tea.Cmd{
|
||||||
util.CmdHandler(commands.CommandExecutedMsg(command)),
|
util.CmdHandler(commands.CommandExecutedMsg(command)),
|
||||||
}
|
}
|
||||||
@@ -674,6 +832,22 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||||||
case commands.ThemeListCommand:
|
case commands.ThemeListCommand:
|
||||||
themeDialog := dialog.NewThemeDialog()
|
themeDialog := dialog.NewThemeDialog()
|
||||||
a.modal = themeDialog
|
a.modal = themeDialog
|
||||||
|
case commands.FileListCommand:
|
||||||
|
a.editor.Blur()
|
||||||
|
provider := completions.NewFileAndFolderContextGroup(a.app)
|
||||||
|
findDialog := dialog.NewFindDialog(provider)
|
||||||
|
findDialog.SetWidth(layout.Current.Container.Width - 8)
|
||||||
|
a.modal = findDialog
|
||||||
|
case commands.FileCloseCommand:
|
||||||
|
a.fileViewer, cmd = a.fileViewer.Clear()
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
case commands.FileDiffToggleCommand:
|
||||||
|
a.fileViewer, cmd = a.fileViewer.ToggleDiff()
|
||||||
|
a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
|
||||||
|
a.app.SaveState()
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
case commands.FileSearchCommand:
|
||||||
|
return a, nil
|
||||||
case commands.ProjectInitCommand:
|
case commands.ProjectInitCommand:
|
||||||
cmds = append(cmds, a.app.InitializeProject(context.Background()))
|
cmds = append(cmds, a.app.InitializeProject(context.Background()))
|
||||||
case commands.InputClearCommand:
|
case commands.InputClearCommand:
|
||||||
@@ -695,20 +869,6 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||||||
updated, cmd := a.editor.Newline()
|
updated, cmd := a.editor.Newline()
|
||||||
a.editor = updated.(chat.EditorComponent)
|
a.editor = updated.(chat.EditorComponent)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
case commands.HistoryPreviousCommand:
|
|
||||||
if a.showCompletionDialog {
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
updated, cmd := a.editor.Previous()
|
|
||||||
a.editor = updated.(chat.EditorComponent)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
case commands.HistoryNextCommand:
|
|
||||||
if a.showCompletionDialog {
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
updated, cmd := a.editor.Next()
|
|
||||||
a.editor = updated.(chat.EditorComponent)
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
case commands.MessagesFirstCommand:
|
case commands.MessagesFirstCommand:
|
||||||
updated, cmd := a.messages.First()
|
updated, cmd := a.messages.First()
|
||||||
a.messages = updated.(chat.MessagesComponent)
|
a.messages = updated.(chat.MessagesComponent)
|
||||||
@@ -718,21 +878,62 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
|||||||
a.messages = updated.(chat.MessagesComponent)
|
a.messages = updated.(chat.MessagesComponent)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
case commands.MessagesPageUpCommand:
|
case commands.MessagesPageUpCommand:
|
||||||
updated, cmd := a.messages.PageUp()
|
if a.fileViewer.HasFile() {
|
||||||
a.messages = updated.(chat.MessagesComponent)
|
a.fileViewer, cmd = a.fileViewer.PageUp()
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
} else {
|
||||||
|
updated, cmd := a.messages.PageUp()
|
||||||
|
a.messages = updated.(chat.MessagesComponent)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
case commands.MessagesPageDownCommand:
|
case commands.MessagesPageDownCommand:
|
||||||
updated, cmd := a.messages.PageDown()
|
if a.fileViewer.HasFile() {
|
||||||
a.messages = updated.(chat.MessagesComponent)
|
a.fileViewer, cmd = a.fileViewer.PageDown()
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
} else {
|
||||||
|
updated, cmd := a.messages.PageDown()
|
||||||
|
a.messages = updated.(chat.MessagesComponent)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
case commands.MessagesHalfPageUpCommand:
|
case commands.MessagesHalfPageUpCommand:
|
||||||
updated, cmd := a.messages.HalfPageUp()
|
if a.fileViewer.HasFile() {
|
||||||
a.messages = updated.(chat.MessagesComponent)
|
a.fileViewer, cmd = a.fileViewer.HalfPageUp()
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
} else {
|
||||||
|
updated, cmd := a.messages.HalfPageUp()
|
||||||
|
a.messages = updated.(chat.MessagesComponent)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
case commands.MessagesHalfPageDownCommand:
|
case commands.MessagesHalfPageDownCommand:
|
||||||
updated, cmd := a.messages.HalfPageDown()
|
if a.fileViewer.HasFile() {
|
||||||
|
a.fileViewer, cmd = a.fileViewer.HalfPageDown()
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
} else {
|
||||||
|
updated, cmd := a.messages.HalfPageDown()
|
||||||
|
a.messages = updated.(chat.MessagesComponent)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
case commands.MessagesPreviousCommand:
|
||||||
|
updated, cmd := a.messages.Previous()
|
||||||
a.messages = updated.(chat.MessagesComponent)
|
a.messages = updated.(chat.MessagesComponent)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
case commands.MessagesNextCommand:
|
||||||
|
updated, cmd := a.messages.Next()
|
||||||
|
a.messages = updated.(chat.MessagesComponent)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
case commands.MessagesLayoutToggleCommand:
|
||||||
|
a.messagesRight = !a.messagesRight
|
||||||
|
a.app.State.MessagesRight = a.messagesRight
|
||||||
|
a.app.SaveState()
|
||||||
|
case commands.MessagesCopyCommand:
|
||||||
|
selected := a.messages.Selected()
|
||||||
|
if selected != "" {
|
||||||
|
cmd = tea.SetClipboard(selected)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
cmd = toast.NewSuccessToast("Message copied to clipboard")
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
case commands.MessagesRevertCommand:
|
||||||
case commands.AppExitCommand:
|
case commands.AppExitCommand:
|
||||||
return a, tea.Quit
|
return a, tea.Quit
|
||||||
}
|
}
|
||||||
@@ -774,6 +975,8 @@ func NewModel(app *app.App) tea.Model {
|
|||||||
showCompletionDialog: false,
|
showCompletionDialog: false,
|
||||||
toastManager: toast.NewToastManager(),
|
toastManager: toast.NewToastManager(),
|
||||||
interruptKeyState: InterruptKeyIdle,
|
interruptKeyState: InterruptKeyIdle,
|
||||||
|
fileViewer: fileviewer.New(app),
|
||||||
|
messagesRight: app.State.MessagesRight,
|
||||||
}
|
}
|
||||||
|
|
||||||
return model
|
return model
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
"github.com/sst/opencode/internal/styles"
|
||||||
|
"github.com/sst/opencode/internal/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
var RootPath string
|
||||||
|
var CwdPath string
|
||||||
|
|
||||||
|
type fileRenderer struct {
|
||||||
|
filename string
|
||||||
|
content string
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileRenderingOption func(*fileRenderer)
|
||||||
|
|
||||||
|
func WithTruncate(height int) fileRenderingOption {
|
||||||
|
return func(c *fileRenderer) {
|
||||||
|
c.height = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderFile(
|
||||||
|
filename string,
|
||||||
|
content string,
|
||||||
|
width int,
|
||||||
|
options ...fileRenderingOption) string {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
renderer := &fileRenderer{
|
||||||
|
filename: filename,
|
||||||
|
content: content,
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
option(renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{}
|
||||||
|
for line := range strings.SplitSeq(content, "\n") {
|
||||||
|
line = strings.TrimRightFunc(line, unicode.IsSpace)
|
||||||
|
line = strings.ReplaceAll(line, "\t", " ")
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
content = strings.Join(lines, "\n")
|
||||||
|
|
||||||
|
if renderer.height > 0 {
|
||||||
|
content = TruncateHeight(content, renderer.height)
|
||||||
|
}
|
||||||
|
content = fmt.Sprintf("```%s\n%s\n```", Extension(renderer.filename), content)
|
||||||
|
content = ToMarkdown(content, width, t.BackgroundPanel())
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func TruncateHeight(content string, height int) string {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
if len(lines) > height {
|
||||||
|
return strings.Join(lines[:height], "\n")
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func Relative(path string) string {
|
||||||
|
path = strings.TrimPrefix(path, CwdPath+"/")
|
||||||
|
return strings.TrimPrefix(path, RootPath+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Extension(path string) string {
|
||||||
|
ext := filepath.Ext(path)
|
||||||
|
if ext == "" {
|
||||||
|
ext = ""
|
||||||
|
} else {
|
||||||
|
ext = strings.ToLower(ext[1:])
|
||||||
|
}
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
|
||||||
|
r := styles.GetMarkdownRenderer(width-7, backgroundColor)
|
||||||
|
content = strings.ReplaceAll(content, RootPath+"/", "")
|
||||||
|
rendered, _ := r.Render(content)
|
||||||
|
lines := strings.Split(rendered, "\n")
|
||||||
|
|
||||||
|
if len(lines) > 0 {
|
||||||
|
firstLine := lines[0]
|
||||||
|
cleaned := ansi.Strip(firstLine)
|
||||||
|
nospace := strings.ReplaceAll(cleaned, " ", "")
|
||||||
|
if nospace == "" {
|
||||||
|
lines = lines[1:]
|
||||||
|
}
|
||||||
|
if len(lines) > 0 {
|
||||||
|
lastLine := lines[len(lines)-1]
|
||||||
|
cleaned = ansi.Strip(lastLine)
|
||||||
|
nospace = strings.ReplaceAll(cleaned, " ", "")
|
||||||
|
if nospace == "" {
|
||||||
|
lines = lines[:len(lines)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = strings.Join(lines, "\n")
|
||||||
|
return strings.TrimSuffix(content, "\n")
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
editLink: {
|
editLink: {
|
||||||
baseUrl: `${github}/edit/master/www/`,
|
baseUrl: `${github}/edit/dev/packages/web/`,
|
||||||
},
|
},
|
||||||
markdown: {
|
markdown: {
|
||||||
headingLinks: false,
|
headingLinks: false,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"shiki": "3.4.2",
|
"shiki": "3.4.2",
|
||||||
"solid-js": "1.9.7",
|
"solid-js": "1.9.7",
|
||||||
"toolbeam-docs-theme": "0.3.0"
|
"toolbeam-docs-theme": "0.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"opencode": "workspace:*",
|
"opencode": "workspace:*",
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import config from '../../config.mjs'
|
|||||||
const slug = Astro.url.pathname.replace(/^\//, "").replace(/\/$/, "");
|
const slug = Astro.url.pathname.replace(/^\//, "").replace(/\/$/, "");
|
||||||
const {
|
const {
|
||||||
entry: {
|
entry: {
|
||||||
data: { title },
|
data: { title , description },
|
||||||
},
|
},
|
||||||
} = Astro.locals.starlightRoute;
|
} = Astro.locals.starlightRoute;
|
||||||
const isDocs = slug.startsWith("docs")
|
const isDocs = slug.startsWith("docs")
|
||||||
|
|
||||||
let encodedTitle = '';
|
let encodedTitle = '';
|
||||||
let ogImage = `${config.url}/social-share.png`;
|
let ogImage = `${config.url}/social-share.png`;
|
||||||
|
let truncatedDesc = '';
|
||||||
|
|
||||||
if (isDocs) {
|
if (isDocs) {
|
||||||
// Truncate to fit S3's max key size
|
// Truncate to fit S3's max key size
|
||||||
@@ -26,7 +27,12 @@ if (isDocs) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
ogImage = `${config.socialCard}/opencode-docs/${encodedTitle}.png`;
|
|
||||||
|
if (description) {
|
||||||
|
truncatedDesc = encodeURIComponent(description.substring(0, 400))
|
||||||
|
}
|
||||||
|
|
||||||
|
ogImage = `${config.socialCard}/opencode-docs/${encodedTitle}.png?desc=${truncatedDesc}`;
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1346,7 +1346,8 @@ export default function Share(props: {
|
|||||||
</ErrorPart>
|
</ErrorPart>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={preview()}>
|
{/* Always try to show CodeBlock if preview is available (even if empty string) */}
|
||||||
|
<Match when={typeof preview() === 'string'}>
|
||||||
<div data-part-tool-result>
|
<div data-part-tool-result>
|
||||||
<ResultsButton
|
<ResultsButton
|
||||||
showCopy="Show preview"
|
showCopy="Show preview"
|
||||||
@@ -1366,7 +1367,8 @@ export default function Share(props: {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={toolData()?.result}>
|
{/* Fallback to TextPart if preview is not a string (e.g. undefined) AND result exists */}
|
||||||
|
<Match when={typeof preview() !== 'string' && toolData()?.result}>
|
||||||
<div data-part-tool-result>
|
<div data-part-tool-result>
|
||||||
<ResultsButton
|
<ResultsButton
|
||||||
results={showResults()}
|
results={showResults()}
|
||||||
@@ -1616,7 +1618,7 @@ export default function Share(props: {
|
|||||||
>
|
>
|
||||||
{(_part) => {
|
{(_part) => {
|
||||||
const todos = createMemo(() =>
|
const todos = createMemo(() =>
|
||||||
sortTodosByStatus(toolData()?.args.todos),
|
sortTodosByStatus(toolData()?.args?.todos ?? []),
|
||||||
)
|
)
|
||||||
const starting = () =>
|
const starting = () =>
|
||||||
todos().every((t) => t.status === "pending")
|
todos().every((t) => t.status === "pending")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: CLI
|
title: CLI
|
||||||
|
description: The opencode CLI options and commands.
|
||||||
---
|
---
|
||||||
|
|
||||||
Running the opencode CLI starts it for the current directory.
|
Running the opencode CLI starts it for the current directory.
|
||||||
@@ -20,6 +21,8 @@ opencode /path/to/project
|
|||||||
|
|
||||||
The opencode CLI also has the following commands.
|
The opencode CLI also has the following commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### run
|
### run
|
||||||
|
|
||||||
Run opencode in non-interactive mode by passing a prompt directly.
|
Run opencode in non-interactive mode by passing a prompt directly.
|
||||||
@@ -41,7 +44,7 @@ opencode run Explain the use of context in Go
|
|||||||
| `--continue` | `-c` | Continue the last session |
|
| `--continue` | `-c` | Continue the last session |
|
||||||
| `--session` | `-s` | Session ID to continue |
|
| `--session` | `-s` | Session ID to continue |
|
||||||
| `--share` | | Share the session |
|
| `--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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -53,6 +56,8 @@ Command to manage credentials and login for providers.
|
|||||||
opencode auth [command]
|
opencode auth [command]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### login
|
#### login
|
||||||
|
|
||||||
Logs you into a provider and saves them in the credentials file in `~/.local/share/opencode/auth.json`.
|
Logs you into a provider and saves them in the credentials file in `~/.local/share/opencode/auth.json`.
|
||||||
@@ -63,6 +68,8 @@ opencode auth login
|
|||||||
|
|
||||||
When opencode starts up it will loads the providers from the credentials file. And if there are any keys defined in your environments or a `.env` file in your project.
|
When opencode starts up it will loads the providers from the credentials file. And if there are any keys defined in your environments or a `.env` file in your project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### list
|
#### list
|
||||||
|
|
||||||
Lists all the authenticated providers as stored in the credentials file.
|
Lists all the authenticated providers as stored in the credentials file.
|
||||||
@@ -77,6 +84,8 @@ Or the short version.
|
|||||||
opencode auth ls
|
opencode auth ls
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### logout
|
#### logout
|
||||||
|
|
||||||
Logs you out of a provider by clearing it from the credentials file.
|
Logs you out of a provider by clearing it from the credentials file.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Config
|
title: Config
|
||||||
|
description: Using the opencode JSON config.
|
||||||
---
|
---
|
||||||
|
|
||||||
You can configure opencode using a JSON config file that can be placed in:
|
You can configure opencode using a JSON config file that can be placed in:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Intro
|
title: Intro
|
||||||
|
description: Get started with opencode.
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
@@ -62,12 +63,12 @@ brew install sst/tap/opencode
|
|||||||
paru -S opencode-bin
|
paru -S opencode-bin
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### Windows
|
##### Windows
|
||||||
|
|
||||||
Right now the automatic installation methods do not work properly on Windows. However you can grab the binary from the [Releases](https://github.com/sst/opencode/releases).
|
Right now the automatic installation methods do not work properly on Windows. However you can grab the binary from the [Releases](https://github.com/sst/opencode/releases).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Providers
|
## Providers
|
||||||
|
|
||||||
We recommend signing up for Claude Pro or Max, running `opencode auth login` and selecting Anthropic. It's the most cost-effective way to use opencode.
|
We recommend signing up for Claude Pro or Max, running `opencode auth login` and selecting Anthropic. It's the most cost-effective way to use opencode.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Keybinds
|
title: Keybinds
|
||||||
|
description: Customize your keybinds.
|
||||||
---
|
---
|
||||||
|
|
||||||
opencode has a list of keybinds that you can customize through the opencode config.
|
opencode has a list of keybinds that you can customize through the opencode config.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: MCP servers
|
title: MCP servers
|
||||||
|
description: Add local and remote MCP tools.
|
||||||
---
|
---
|
||||||
|
|
||||||
You can add external tools to opencode using the _Model Context Protocol_, or MCP. opencode supports both:
|
You can add external tools to opencode using the _Model Context Protocol_, or MCP. opencode supports both:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Models
|
title: Models
|
||||||
|
description: Configuring an LLM provider and model.
|
||||||
---
|
---
|
||||||
|
|
||||||
opencode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.dev) to support for **75+ LLM providers** and it supports running local models.
|
opencode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.dev) to support for **75+ LLM providers** and it supports running local models.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Rules
|
title: Rules
|
||||||
|
description: Set custom instructions for opencode.
|
||||||
---
|
---
|
||||||
|
|
||||||
You can provide custom instructions to opencode by creating an `AGENTS.md` file. This is similar to `CLAUDE.md` or Cursor's rules. It contains instructions that will be included in the LLM's context to customize its behavior for your specific project.
|
You can provide custom instructions to opencode by creating an `AGENTS.md` file. This is similar to `CLAUDE.md` or Cursor's rules. It contains instructions that will be included in the LLM's context to customize its behavior for your specific project.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Themes
|
title: Themes
|
||||||
|
description: Select a built-in theme or define your own.
|
||||||
---
|
---
|
||||||
|
|
||||||
With opencode you can select from one of several built-in themes, use a theme that adapts to your terminal theme, or define your own custom theme.
|
With opencode you can select from one of several built-in themes, use a theme that adapts to your terminal theme, or define your own custom theme.
|
||||||
@@ -62,6 +63,8 @@ You can select a theme by bringing up the theme select with the `/theme` command
|
|||||||
|
|
||||||
opencode supports a flexible JSON-based theme system that allows users to create and customize themes easily.
|
opencode supports a flexible JSON-based theme system that allows users to create and customize themes easily.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Hierarchy
|
### Hierarchy
|
||||||
|
|
||||||
Themes are loaded from multiple directories in the following order where later directories override earlier ones:
|
Themes are loaded from multiple directories in the following order where later directories override earlier ones:
|
||||||
@@ -73,6 +76,8 @@ Themes are loaded from multiple directories in the following order where later d
|
|||||||
|
|
||||||
If multiple directories contain a theme with the same name, the theme from the directory with higher priority will be used.
|
If multiple directories contain a theme with the same name, the theme from the directory with higher priority will be used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Creating a theme
|
### Creating a theme
|
||||||
|
|
||||||
To create a custom theme, create a JSON file in one of the theme directories.
|
To create a custom theme, create a JSON file in one of the theme directories.
|
||||||
@@ -91,6 +96,8 @@ mkdir -p .opencode/themes
|
|||||||
vim .opencode/themes/my-theme.json
|
vim .opencode/themes/my-theme.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### JSON format
|
### JSON format
|
||||||
|
|
||||||
Themes use a flexible JSON format with support for:
|
Themes use a flexible JSON format with support for:
|
||||||
@@ -101,6 +108,23 @@ Themes use a flexible JSON format with support for:
|
|||||||
- **Dark/light variants**: `{"dark": "#000", "light": "#fff"}`
|
- **Dark/light variants**: `{"dark": "#000", "light": "#fff"}`
|
||||||
- **No color**: `"none"` - Uses the terminal's default color or transparent
|
- **No color**: `"none"` - Uses the terminal's default color or transparent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Color definitions
|
||||||
|
|
||||||
|
The `defs` section is optional and it allows you to define reusable colors that can be referenced in the theme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Terminal defaults
|
||||||
|
|
||||||
|
The special value `"none"` can be used for any color to inherit the terminal's default color. This is particularly useful for creating themes that blend seamlessly with your terminal's color scheme:
|
||||||
|
|
||||||
|
- `"text": "none"` - Uses terminal's default foreground color
|
||||||
|
- `"background": "none"` - Uses terminal's default background color
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Example
|
### Example
|
||||||
|
|
||||||
Here's an example of a custom theme:
|
Here's an example of a custom theme:
|
||||||
@@ -330,14 +354,3 @@ Here's an example of a custom theme:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Color definitions
|
|
||||||
|
|
||||||
The `defs` section is optional and it allows you to define reusable colors that can be referenced in the theme.
|
|
||||||
|
|
||||||
### Terminal defaults
|
|
||||||
|
|
||||||
The special value `\"none\"` can be used for any color to inherit the terminal's default color. This is particularly useful for creating themes that blend seamlessly with your terminal's color scheme:
|
|
||||||
|
|
||||||
- `"text": "none"` - Uses terminal's default foreground color
|
|
||||||
- `"background": "none"` - Uses terminal's default background color
|
|
||||||
|
|||||||
+8
-1
@@ -51,9 +51,16 @@ resources:
|
|||||||
get: get /app
|
get: get /app
|
||||||
init: post /app/init
|
init: post /app/init
|
||||||
|
|
||||||
|
find:
|
||||||
|
methods:
|
||||||
|
text: get /find
|
||||||
|
files: get /find/file
|
||||||
|
symbols: get /find/symbol
|
||||||
|
|
||||||
file:
|
file:
|
||||||
methods:
|
methods:
|
||||||
search: get /file
|
read: get /file
|
||||||
|
status: get /file/status
|
||||||
|
|
||||||
config:
|
config:
|
||||||
models:
|
models:
|
||||||
|
|||||||
Reference in New Issue
Block a user