mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69920a73d7 | |||
| ae76a3467a | |||
| 701107cda4 | |||
| b99565959b | |||
| 67aa7ce04d | |||
| c663fbc3ee | |||
| 2090bab537 | |||
| 64d5fff9a3 | |||
| 925f695503 | |||
| f1c925795d | |||
| c82a060eca | |||
| 63e783ef79 | |||
| 35d6273fb3 | |||
| b89d4a16fd | |||
| 2799a96032 | |||
| 8f4b79227c | |||
| c810b6d206 | |||
| fa35407572 | |||
| 8bbbc07aff | |||
| 75a21ba3ce | |||
| 0d6fb68a88 | |||
| 242b886434 | |||
| caf465a9da | |||
| bbf77c6139 | |||
| 53b7e04b86 | |||
| 9e75e3ed18 | |||
| 6389858d41 | |||
| 7e5941e14b | |||
| c68aeed8d9 | |||
| b199a609a8 | |||
| 4a5a93b3f8 | |||
| e99bdcefac | |||
| 26dcb85de1 | |||
| 11d042be25 | |||
| 33b5fe236a | |||
| d56991006c | |||
| 739a9f71c3 | |||
| aef81fce0b | |||
| 8f3d7b4038 | |||
| de15e67834 | |||
| fea56d8de6 | |||
| 3d71be2b45 | |||
| 58baca2a5b | |||
| ef73926db6 | |||
| 9ad1687f04 | |||
| c573270e66 | |||
| 9ebad68274 |
@@ -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%" %*
|
||||||
|
|||||||
@@ -297,6 +297,13 @@
|
|||||||
},
|
},
|
||||||
"description": "MCP (Model Context Protocol) server configurations"
|
"description": "MCP (Model Context Protocol) server configurations"
|
||||||
},
|
},
|
||||||
|
"instructions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Additional instruction files or patterns to include"
|
||||||
|
},
|
||||||
"experimental": {
|
"experimental": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ for (const [os, arch] of targets) {
|
|||||||
console.log(`building ${os}-${arch}`)
|
console.log(`building ${os}-${arch}`)
|
||||||
const name = `${pkg.name}-${os}-${arch}`
|
const name = `${pkg.name}-${os}-${arch}`
|
||||||
await $`mkdir -p dist/${name}/bin`
|
await $`mkdir -p dist/${name}/bin`
|
||||||
await $`GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd(
|
await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd(
|
||||||
"../tui",
|
"../tui",
|
||||||
)
|
)
|
||||||
await $`bun build --define OPENCODE_VERSION="'${version}'" --compile --minify --target=bun-${os}-${arch} --outfile=dist/${name}/bin/opencode ./src/index.ts ./dist/${name}/bin/tui`
|
await $`bun build --define OPENCODE_VERSION="'${version}'" --compile --minify --target=bun-${os}-${arch} --outfile=dist/${name}/bin/opencode ./src/index.ts ./dist/${name}/bin/tui`
|
||||||
@@ -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,6 +1,8 @@
|
|||||||
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 { Share } from "../share/share"
|
import { Share } from "../share/share"
|
||||||
|
|
||||||
export async function bootstrap<T>(
|
export async function bootstrap<T>(
|
||||||
@@ -11,6 +13,8 @@ export async function bootstrap<T>(
|
|||||||
Share.init()
|
Share.init()
|
||||||
Format.init()
|
Format.init()
|
||||||
ConfigHooks.init()
|
ConfigHooks.init()
|
||||||
|
LSP.init()
|
||||||
|
FileWatcher.init()
|
||||||
|
|
||||||
return cb(app)
|
return cb(app)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { App } from "../../app/app"
|
|
||||||
import { Ripgrep } from "../../file/ripgrep"
|
|
||||||
import { LSP } from "../../lsp"
|
|
||||||
import { cmd } from "./cmd"
|
|
||||||
|
|
||||||
export const DebugCommand = cmd({
|
|
||||||
command: "debug",
|
|
||||||
builder: (yargs) =>
|
|
||||||
yargs.command(DiagnosticsCommand).command(TreeCommand).demandCommand(),
|
|
||||||
async handler() {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const DiagnosticsCommand = cmd({
|
|
||||||
command: "diagnostics <file>",
|
|
||||||
builder: (yargs) =>
|
|
||||||
yargs.positional("file", { type: "string", demandOption: true }),
|
|
||||||
async handler(args) {
|
|
||||||
await App.provide({ cwd: process.cwd() }, async () => {
|
|
||||||
await LSP.touchFile(args.file, true)
|
|
||||||
console.log(await LSP.diagnostics())
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const TreeCommand = cmd({
|
|
||||||
command: "tree",
|
|
||||||
builder: (yargs) =>
|
|
||||||
yargs.option("limit", {
|
|
||||||
type: "number",
|
|
||||||
}),
|
|
||||||
async handler(args) {
|
|
||||||
await App.provide({ cwd: process.cwd() }, async () => {
|
|
||||||
const app = App.info()
|
|
||||||
console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { File } from "../../../file"
|
||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
|
const FileReadCommand = cmd({
|
||||||
|
command: "read <path>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("path", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
description: "File path to read",
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
const content = await File.read(args.path)
|
||||||
|
console.log(content)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const FileStatusCommand = cmd({
|
||||||
|
command: "status",
|
||||||
|
builder: (yargs) => yargs,
|
||||||
|
async handler() {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
const status = await File.status()
|
||||||
|
console.log(JSON.stringify(status, null, 2))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const FileCommand = cmd({
|
||||||
|
command: "file",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
|
||||||
|
async handler() {},
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
import { FileCommand } from "./file"
|
||||||
|
import { LSPCommand } from "./lsp"
|
||||||
|
import { RipgrepCommand } from "./ripgrep"
|
||||||
|
import { SnapshotCommand } from "./snapshot"
|
||||||
|
|
||||||
|
export const DebugCommand = cmd({
|
||||||
|
command: "debug",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.command(LSPCommand)
|
||||||
|
.command(RipgrepCommand)
|
||||||
|
.command(FileCommand)
|
||||||
|
.command(SnapshotCommand)
|
||||||
|
.command({
|
||||||
|
command: "wait",
|
||||||
|
async handler() {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, 1_000 * 60 * 60 * 24),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.demandCommand(),
|
||||||
|
async handler() {},
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { LSP } from "../../../lsp"
|
||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
import { Log } from "../../../util/log"
|
||||||
|
|
||||||
|
export const LSPCommand = cmd({
|
||||||
|
command: "lsp",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
|
||||||
|
async handler() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DiagnosticsCommand = cmd({
|
||||||
|
command: "diagnostics <file>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("file", { type: "string", demandOption: true }),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
await LSP.touchFile(args.file, true)
|
||||||
|
console.log(await LSP.diagnostics())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SymbolsCommand = cmd({
|
||||||
|
command: "symbols <query>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("query", { type: "string", demandOption: true }),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
await LSP.touchFile("./src/index.ts", true)
|
||||||
|
using _ = Log.Default.time("symbols")
|
||||||
|
const results = await LSP.workspaceSymbol(args.query)
|
||||||
|
console.log(JSON.stringify(results, null, 2))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { App } from "../../../app/app"
|
||||||
|
import { Ripgrep } from "../../../file/ripgrep"
|
||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
|
export const RipgrepCommand = cmd({
|
||||||
|
command: "rg",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.command(TreeCommand)
|
||||||
|
.command(FilesCommand)
|
||||||
|
.command(SearchCommand)
|
||||||
|
.demandCommand(),
|
||||||
|
async handler() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TreeCommand = cmd({
|
||||||
|
command: "tree",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.option("limit", {
|
||||||
|
type: "number",
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
const app = App.info()
|
||||||
|
console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const FilesCommand = cmd({
|
||||||
|
command: "files",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.option("query", {
|
||||||
|
type: "string",
|
||||||
|
description: "Filter files by query",
|
||||||
|
})
|
||||||
|
.option("glob", {
|
||||||
|
type: "string",
|
||||||
|
description: "Glob pattern to match files",
|
||||||
|
})
|
||||||
|
.option("limit", {
|
||||||
|
type: "number",
|
||||||
|
description: "Limit number of results",
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
const app = App.info()
|
||||||
|
const files = await Ripgrep.files({
|
||||||
|
cwd: app.path.cwd,
|
||||||
|
query: args.query,
|
||||||
|
glob: args.glob,
|
||||||
|
limit: args.limit,
|
||||||
|
})
|
||||||
|
console.log(files.join("\n"))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const SearchCommand = cmd({
|
||||||
|
command: "search <pattern>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.positional("pattern", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
description: "Search pattern",
|
||||||
|
})
|
||||||
|
.option("glob", {
|
||||||
|
type: "array",
|
||||||
|
description: "File glob patterns",
|
||||||
|
})
|
||||||
|
.option("limit", {
|
||||||
|
type: "number",
|
||||||
|
description: "Limit number of results",
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
const results = await Ripgrep.search({
|
||||||
|
cwd: process.cwd(),
|
||||||
|
pattern: args.pattern,
|
||||||
|
glob: args.glob as string[] | undefined,
|
||||||
|
limit: args.limit,
|
||||||
|
})
|
||||||
|
console.log(JSON.stringify(results, null, 2))
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Snapshot } from "../../../snapshot"
|
||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
|
export const SnapshotCommand = cmd({
|
||||||
|
command: "snapshot",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.command(SnapshotCreateCommand)
|
||||||
|
.command(SnapshotRestoreCommand)
|
||||||
|
.demandCommand(),
|
||||||
|
async handler() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SnapshotCreateCommand = cmd({
|
||||||
|
command: "create",
|
||||||
|
async handler() {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
const result = await Snapshot.create("test")
|
||||||
|
console.log(result)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SnapshotRestoreCommand = cmd({
|
||||||
|
command: "restore <commit>",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("commit", {
|
||||||
|
type: "string",
|
||||||
|
description: "commit",
|
||||||
|
demandOption: true,
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||||
|
await Snapshot.restore("test", args.commit)
|
||||||
|
console.log("restored")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,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"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const TuiCommand = cmd({
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
UI.println(UI.logo(" "))
|
UI.println(UI.logo(" "))
|
||||||
const result = await Bun.spawn({
|
const result = await Bun.spawn({
|
||||||
cmd: [process.execPath, "auth", "login"],
|
cmd: [...getOpencodeCommand(), "auth", "login"],
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
@@ -112,3 +112,25 @@ export const TuiCommand = cmd({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the correct command to run opencode CLI
|
||||||
|
* In development: ["bun", "run", "packages/opencode/src/index.ts"]
|
||||||
|
* In production: ["/path/to/opencode"]
|
||||||
|
*/
|
||||||
|
function getOpencodeCommand(): string[] {
|
||||||
|
// Check if OPENCODE_BIN_PATH is set (used by shell wrapper scripts)
|
||||||
|
if (process.env["OPENCODE_BIN_PATH"]) {
|
||||||
|
return [process.env["OPENCODE_BIN_PATH"]]
|
||||||
|
}
|
||||||
|
|
||||||
|
const execPath = process.execPath.toLowerCase()
|
||||||
|
|
||||||
|
if (Installation.isDev()) {
|
||||||
|
// In development, use bun to run the TypeScript entry point
|
||||||
|
return [execPath, "run", process.argv[1]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, use the current executable path
|
||||||
|
return [process.execPath]
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,6 +176,10 @@ export namespace Config {
|
|||||||
.record(z.string(), Mcp)
|
.record(z.string(), Mcp)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("MCP (Model Context Protocol) server configurations"),
|
.describe("MCP (Model Context Protocol) server configurations"),
|
||||||
|
instructions: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.describe("Additional instruction files or patterns to include"),
|
||||||
experimental: z
|
experimental: z
|
||||||
.object({
|
.object({
|
||||||
hook: z
|
hook: z
|
||||||
|
|||||||
@@ -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("SIGKILL")
|
server.process.kill("SIGTERM")
|
||||||
|
log.info("shutdown", { serverID })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,19 +3,60 @@ import { Log } from "../util/log"
|
|||||||
import { LSPClient } from "./client"
|
import { LSPClient } from "./client"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { LSPServer } from "./server"
|
import { LSPServer } from "./server"
|
||||||
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
export namespace LSP {
|
export namespace LSP {
|
||||||
const log = Log.create({ service: "lsp" })
|
const log = Log.create({ service: "lsp" })
|
||||||
|
|
||||||
|
export const Symbol = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
kind: z.number(),
|
||||||
|
location: z.object({
|
||||||
|
uri: z.string(),
|
||||||
|
range: z.object({
|
||||||
|
start: z.object({
|
||||||
|
line: z.number(),
|
||||||
|
character: z.number(),
|
||||||
|
}),
|
||||||
|
end: z.object({
|
||||||
|
line: z.number(),
|
||||||
|
character: z.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.openapi({
|
||||||
|
ref: "LSP.Symbol",
|
||||||
|
})
|
||||||
|
export type Symbol = z.infer<typeof Symbol>
|
||||||
|
|
||||||
const state = App.state(
|
const state = App.state(
|
||||||
"lsp",
|
"lsp",
|
||||||
async () => {
|
async (app) => {
|
||||||
log.info("initializing")
|
log.info("initializing")
|
||||||
const clients = new Map<string, LSPClient.Info>()
|
const clients = new Map<string, LSPClient.Info>()
|
||||||
const skip = new Set<string>()
|
for (const server of Object.values(LSPServer)) {
|
||||||
|
for (const extension of server.extensions) {
|
||||||
|
const [file] = await Ripgrep.files({
|
||||||
|
cwd: app.path.cwd,
|
||||||
|
glob: "*" + extension,
|
||||||
|
})
|
||||||
|
if (!file) continue
|
||||||
|
const handle = await server.spawn(App.info())
|
||||||
|
if (!handle) break
|
||||||
|
const client = await LSPClient.create(server.id, handle).catch(
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
if (!client) break
|
||||||
|
clients.set(server.id, client)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("initialized")
|
||||||
return {
|
return {
|
||||||
clients,
|
clients,
|
||||||
skip,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async (state) => {
|
async (state) => {
|
||||||
@@ -25,35 +66,23 @@ export namespace LSP {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export async function init() {
|
||||||
|
return state()
|
||||||
|
}
|
||||||
|
|
||||||
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
|
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
|
||||||
const extension = path.parse(input).ext
|
const extension = path.parse(input).ext
|
||||||
const s = await state()
|
const matches = Object.values(LSPServer)
|
||||||
const matches = 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
|
||||||
if (s.skip.has(match.id)) continue
|
const wait = waitForDiagnostics
|
||||||
const existing = s.clients.get(match.id)
|
? client.waitForDiagnostics({ path: input })
|
||||||
if (existing) continue
|
: Promise.resolve()
|
||||||
const handle = await match.spawn(App.info())
|
await client.notify.open({ path: input })
|
||||||
if (!handle) {
|
return wait
|
||||||
s.skip.add(match.id)
|
})
|
||||||
continue
|
|
||||||
}
|
|
||||||
const client = await LSPClient.create(match.id, handle).catch(() => {})
|
|
||||||
if (!client) {
|
|
||||||
s.skip.add(match.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s.clients.set(match.id, client)
|
|
||||||
}
|
|
||||||
if (waitForDiagnostics) {
|
|
||||||
await run(async (client) => {
|
|
||||||
const wait = client.waitForDiagnostics({ path: input })
|
|
||||||
await client.notify.open({ path: input })
|
|
||||||
return wait
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function diagnostics() {
|
export async function diagnostics() {
|
||||||
@@ -86,6 +115,14 @@ export namespace LSP {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function workspaceSymbol(query: string) {
|
||||||
|
return run((client) =>
|
||||||
|
client.connection.sendRequest("workspace/symbol", {
|
||||||
|
query,
|
||||||
|
}),
|
||||||
|
).then((result) => result.flat() as LSP.Symbol[])
|
||||||
|
}
|
||||||
|
|
||||||
async function run<T>(
|
async function run<T>(
|
||||||
input: (client: LSPClient.Info) => Promise<T>,
|
input: (client: LSPClient.Info) => Promise<T>,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
|
|||||||
@@ -123,4 +123,24 @@ export namespace LSPServer {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Pyright: Info = {
|
||||||
|
id: "pyright",
|
||||||
|
extensions: [".py", ".pyi"],
|
||||||
|
async spawn() {
|
||||||
|
const proc = spawn(
|
||||||
|
BunProc.which(),
|
||||||
|
["x", "pyright-langserver", "--stdio"],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BUN_BE_BUN: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
process: proc,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export namespace ModelsDev {
|
|||||||
|
|
||||||
export const Model = z
|
export const Model = z
|
||||||
.object({
|
.object({
|
||||||
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
release_date: z.string(),
|
||||||
attachment: z.boolean(),
|
attachment: z.boolean(),
|
||||||
reasoning: z.boolean(),
|
reasoning: z.boolean(),
|
||||||
temperature: z.boolean(),
|
temperature: z.boolean(),
|
||||||
@@ -25,7 +27,6 @@ export namespace ModelsDev {
|
|||||||
context: z.number(),
|
context: z.number(),
|
||||||
output: z.number(),
|
output: z.number(),
|
||||||
}),
|
}),
|
||||||
id: z.string(),
|
|
||||||
options: z.record(z.any()),
|
options: z.record(z.any()),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
@@ -246,6 +271,7 @@ export namespace Provider {
|
|||||||
npm: provider.npm ?? existing?.npm,
|
npm: provider.npm ?? existing?.npm,
|
||||||
name: provider.name ?? existing?.name ?? providerID,
|
name: provider.name ?? existing?.name ?? providerID,
|
||||||
env: provider.env ?? existing?.env ?? [],
|
env: provider.env ?? existing?.env ?? [],
|
||||||
|
api: provider.api ?? existing?.api,
|
||||||
models: existing?.models ?? {},
|
models: existing?.models ?? {},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +280,7 @@ export namespace Provider {
|
|||||||
const parsedModel: ModelsDev.Model = {
|
const parsedModel: ModelsDev.Model = {
|
||||||
id: modelID,
|
id: modelID,
|
||||||
name: model.name ?? existing?.name ?? modelID,
|
name: model.name ?? existing?.name ?? modelID,
|
||||||
|
release_date: model.release_date ?? existing?.release_date,
|
||||||
attachment: model.attachment ?? existing?.attachment ?? false,
|
attachment: model.attachment ?? existing?.attachment ?? false,
|
||||||
reasoning: model.reasoning ?? existing?.reasoning ?? false,
|
reasoning: model.reasoning ?? existing?.reasoning ?? false,
|
||||||
temperature: model.temperature ?? existing?.temperature ?? false,
|
temperature: model.temperature ?? existing?.temperature ?? false,
|
||||||
@@ -287,9 +314,14 @@ export namespace Provider {
|
|||||||
// load env
|
// load env
|
||||||
for (const [providerID, provider] of Object.entries(database)) {
|
for (const [providerID, provider] of Object.entries(database)) {
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
if (provider.env.some((item) => process.env[item])) {
|
const apiKey = provider.env.map((item) => process.env[item]).at(0)
|
||||||
mergeProvider(providerID, {}, "env")
|
if (!apiKey) continue
|
||||||
}
|
mergeProvider(
|
||||||
|
providerID,
|
||||||
|
// only include apiKey if there's only one potential option
|
||||||
|
provider.env.length === 1 ? { apiKey } : {},
|
||||||
|
"env",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// load apikeys
|
// load apikeys
|
||||||
@@ -450,7 +482,6 @@ export namespace Provider {
|
|||||||
// LspHoverTool,
|
// LspHoverTool,
|
||||||
PatchTool,
|
PatchTool,
|
||||||
ReadTool,
|
ReadTool,
|
||||||
EditTool,
|
|
||||||
// MultiEditTool,
|
// MultiEditTool,
|
||||||
WriteTool,
|
WriteTool,
|
||||||
TodoWriteTool,
|
TodoWriteTool,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ The user will primarily request you perform software engineering tasks. This inc
|
|||||||
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
|
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
|
||||||
- Implement the solution using all tools available to you
|
- Implement the solution using all tools available to you
|
||||||
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
|
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
|
||||||
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time.
|
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
|
||||||
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||||
|
|
||||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
|
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { App } from "../app/app"
|
|||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Config } from "../config/config"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
|
|
||||||
@@ -55,8 +56,10 @@ export namespace SystemPrompt {
|
|||||||
"CLAUDE.md",
|
"CLAUDE.md",
|
||||||
"CONTEXT.md", // deprecated
|
"CONTEXT.md", // deprecated
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function custom() {
|
export async function custom() {
|
||||||
const { cwd, root } = App.info().path
|
const { cwd, root } = App.info().path
|
||||||
|
const config = await Config.get()
|
||||||
const found = []
|
const found = []
|
||||||
for (const item of CUSTOM_FILES) {
|
for (const item of CUSTOM_FILES) {
|
||||||
const matches = await Filesystem.findUp(item, cwd, root)
|
const matches = await Filesystem.findUp(item, cwd, root)
|
||||||
@@ -72,6 +75,18 @@ export namespace SystemPrompt {
|
|||||||
.text()
|
.text()
|
||||||
.catch(() => ""),
|
.catch(() => ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (config.instructions) {
|
||||||
|
for (const instruction of config.instructions) {
|
||||||
|
try {
|
||||||
|
const matches = await Filesystem.globUp(instruction, cwd, root)
|
||||||
|
found.push(...matches.map((x) => Bun.file(x).text()))
|
||||||
|
} catch {
|
||||||
|
continue // Skip invalid glob patterns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.all(found).then((result) => result.filter(Boolean))
|
return Promise.all(found).then((result) => result.filter(Boolean))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -15,4 +15,28 @@ export namespace Filesystem {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function globUp(pattern: string, start: string, stop?: string) {
|
||||||
|
let current = start
|
||||||
|
const result = []
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const glob = new Bun.Glob(pattern)
|
||||||
|
for await (const match of glob.scan({
|
||||||
|
cwd: current,
|
||||||
|
onlyFiles: true,
|
||||||
|
dot: true,
|
||||||
|
})) {
|
||||||
|
result.push(join(current, match))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid glob patterns
|
||||||
|
}
|
||||||
|
if (stop === current) break
|
||||||
|
const parent = dirname(current)
|
||||||
|
if (parent === current) break
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"github.com/sst/opencode-sdk-go/option"
|
"github.com/sst/opencode-sdk-go/option"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/tui"
|
"github.com/sst/opencode/internal/tui"
|
||||||
"github.com/sst/opencode/pkg/client"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
@@ -78,15 +77,15 @@ func main() {
|
|||||||
tea.WithMouseCellMotion(),
|
tea.WithMouseCellMotion(),
|
||||||
)
|
)
|
||||||
|
|
||||||
evts, err := client.Event(httpClient, url, ctx)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to subscribe to events", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for item := range evts {
|
stream := httpClient.Event.ListStreaming(ctx)
|
||||||
program.Send(item)
|
for stream.Next() {
|
||||||
|
evt := stream.Current().AsUnion()
|
||||||
|
program.Send(evt)
|
||||||
|
}
|
||||||
|
if err := stream.Err(); err != nil {
|
||||||
|
slog.Error("Error streaming events", "error", err)
|
||||||
|
program.Send(err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -14,9 +14,8 @@ require (
|
|||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||||
github.com/muesli/reflow v0.3.0
|
github.com/muesli/reflow v0.3.0
|
||||||
github.com/muesli/termenv v0.16.0
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/oapi-codegen/runtime v1.1.1
|
|
||||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
||||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.5
|
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
|
||||||
)
|
)
|
||||||
@@ -25,7 +24,6 @@ require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
|
||||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
@@ -37,7 +35,6 @@ require (
|
|||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
github.com/go-openapi/swag v0.23.0 // indirect
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/goccy/go-yaml v1.17.1 // indirect
|
github.com/goccy/go-yaml v1.17.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/invopop/yaml v0.3.1 // indirect
|
github.com/invopop/yaml v0.3.1 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
|||||||
+2
-13
@@ -4,15 +4,12 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
|
|||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
|
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
|
||||||
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
||||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
|
||||||
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
|
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
|
||||||
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
|
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
@@ -23,7 +20,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
|
|||||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
|
||||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
|
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
|
||||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
||||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
|
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
|
||||||
@@ -96,8 +92,6 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
@@ -110,7 +104,6 @@ github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
|
|||||||
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
|
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -148,8 +141,6 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
|||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
|
||||||
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
|
|
||||||
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
@@ -190,12 +181,10 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
|||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
github.com/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw=
|
||||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.5 h1:iZjdSHLo6jOMjUbDH5JWi+44v76yNbEktsRqG/Qxrco=
|
github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
|
||||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.5/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -362,15 +392,15 @@ func renderToolDetails(
|
|||||||
stdout := metadata.ExtraFields["stdout"]
|
stdout := metadata.ExtraFields["stdout"]
|
||||||
if stdout != nil {
|
if stdout != nil {
|
||||||
command := toolArgsMap["command"].(string)
|
command := toolArgsMap["command"].(string)
|
||||||
body = fmt.Sprintf("```console\n> %s\n%s\n```", 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
|
||||||
|
|||||||
+324
-100
@@ -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 {
|
||||||
@@ -98,13 +107,33 @@ var BUGGED_SCROLL_KEYS = map[string]bool{
|
|||||||
";": true,
|
";": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isScrollRelatedInput(keyString string) bool {
|
||||||
|
if len(keyString) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, char := range keyString {
|
||||||
|
charStr := string(char)
|
||||||
|
if !BUGGED_SCROLL_KEYS[charStr] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keyString) > 3 && (keyString[len(keyString)-1] == 'M' || keyString[len(keyString)-1] == 'm') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(keyString) > 1
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
keyString := msg.String()
|
keyString := msg.String()
|
||||||
if time.Since(a.lastScroll) < time.Millisecond*100 && BUGGED_SCROLL_KEYS[keyString] {
|
if time.Since(a.lastScroll) < time.Millisecond*100 && (BUGGED_SCROLL_KEYS[keyString] || isScrollRelatedInput(keyString)) {
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,10 +141,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 +285,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 +323,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()
|
||||||
@@ -282,6 +340,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return updated, cmd
|
return updated, cmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case error:
|
||||||
|
return a, toast.NewErrorToast(msg.Error())
|
||||||
case app.SendMsg:
|
case app.SendMsg:
|
||||||
a.showCompletionDialog = false
|
a.showCompletionDialog = false
|
||||||
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
|
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
|
||||||
@@ -347,22 +407,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 {
|
||||||
@@ -371,11 +456,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
|
||||||
@@ -392,24 +479,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)
|
||||||
@@ -422,85 +507,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
|
||||||
@@ -532,7 +627,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()),
|
||||||
@@ -543,13 +638,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, "")
|
||||||
@@ -557,18 +654,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)),
|
||||||
}
|
}
|
||||||
@@ -672,6 +851,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:
|
||||||
@@ -693,20 +888,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)
|
||||||
@@ -716,21 +897,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
|
||||||
}
|
}
|
||||||
@@ -772,6 +994,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")
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/sst/opencode-sdk-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Event(c *opencode.Client, url string, ctx context.Context) (<-chan any, error) {
|
|
||||||
events := make(chan any)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url+"event", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(events)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
|
||||||
scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if strings.HasPrefix(line, "data: ") {
|
|
||||||
data := strings.TrimPrefix(line, "data: ")
|
|
||||||
|
|
||||||
var event opencode.EventListResponse
|
|
||||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val := event.AsUnion()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case events <- val:
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return events, nil
|
|
||||||
}
|
|
||||||
@@ -42,7 +42,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
editLink: {
|
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
|
|
||||||
|
|||||||
+20
-6
@@ -1,4 +1,4 @@
|
|||||||
# yaml-language-server: $schema=https://app.stainless.com/config.schema.json
|
# yaml-language-server: $schema=https://app.stainless.com/config-internal.schema.json
|
||||||
|
|
||||||
organization:
|
organization:
|
||||||
name: opencode
|
name: opencode
|
||||||
@@ -11,22 +11,24 @@ targets:
|
|||||||
production_repo: "sst/opencode-sdk-js"
|
production_repo: "sst/opencode-sdk-js"
|
||||||
publish:
|
publish:
|
||||||
npm: true
|
npm: true
|
||||||
skip: false
|
|
||||||
go:
|
go:
|
||||||
package_name: opencode
|
package_name: opencode
|
||||||
production_repo: sst/opencode-sdk-go
|
production_repo: sst/opencode-sdk-go
|
||||||
skip: false
|
|
||||||
python:
|
python:
|
||||||
project_name: opencode-ai
|
project_name: opencode-ai
|
||||||
package_name: opencode_ai
|
package_name: opencode_ai
|
||||||
production_repo: sst/opencode-sdk-python
|
production_repo: sst/opencode-sdk-python
|
||||||
publish:
|
publish:
|
||||||
pypi: true
|
pypi: true
|
||||||
skip: false
|
|
||||||
|
|
||||||
environments:
|
environments:
|
||||||
production: http://localhost:54321
|
production: http://localhost:54321
|
||||||
|
|
||||||
|
streaming:
|
||||||
|
on_event:
|
||||||
|
- kind: fallthrough
|
||||||
|
handle: yield
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
$shared:
|
$shared:
|
||||||
models:
|
models:
|
||||||
@@ -35,7 +37,12 @@ resources:
|
|||||||
|
|
||||||
event:
|
event:
|
||||||
methods:
|
methods:
|
||||||
list: get /event
|
list:
|
||||||
|
endpoint: get /event
|
||||||
|
paginated: false
|
||||||
|
streaming:
|
||||||
|
# This method is always streaming.
|
||||||
|
param_discriminator: null
|
||||||
|
|
||||||
app:
|
app:
|
||||||
models:
|
models:
|
||||||
@@ -44,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