mirror of
https://github.com/sst/opencode.git
synced 2025-07-10 01:24:59 +00:00
Merge branch 'dev' into fix-cancle-compact
This commit is contained in:
commit
c85f23e9fa
38 changed files with 1302 additions and 596 deletions
2
STATS.md
2
STATS.md
|
@ -7,4 +7,4 @@
|
||||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
| 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) |
|
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||||
| 2025-07-04 | 30,646 (+2,812) | 54,758 (+4,803) | 85,404 (+7,615) |
|
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||||
|
|
29
bun.lock
29
bun.lock
|
@ -5,7 +5,7 @@
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"sst": "3.17.6",
|
"sst": "3.17.8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
|
@ -78,6 +78,7 @@
|
||||||
"lang-map": "0.4.0",
|
"lang-map": "0.4.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "15.0.12",
|
"marked": "15.0.12",
|
||||||
|
"marked-shiki": "1.2.0",
|
||||||
"rehype-autolink-headings": "7.1.0",
|
"rehype-autolink-headings": "7.1.0",
|
||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"shiki": "3.4.2",
|
"shiki": "3.4.2",
|
||||||
|
@ -462,7 +463,7 @@
|
||||||
|
|
||||||
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
|
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
|
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
|
||||||
|
|
||||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||||
|
|
||||||
|
@ -492,6 +493,8 @@
|
||||||
|
|
||||||
"@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
"@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
||||||
|
|
||||||
"@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="],
|
"@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="],
|
||||||
|
|
||||||
"@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="],
|
"@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="],
|
||||||
|
@ -602,7 +605,7 @@
|
||||||
|
|
||||||
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
|
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
|
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
|
||||||
|
|
||||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||||
|
|
||||||
|
@ -1050,6 +1053,8 @@
|
||||||
|
|
||||||
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||||
|
|
||||||
|
"marked-shiki": ["marked-shiki@1.2.0", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-N924hp8veE6Mc91g5/kCNVoTU7TkeJfB2G2XEWb+k1fVA0Bck2T0rVt93d39BlOYH6ohP4Q9BFlPk+UkblhXbg=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
"mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="],
|
"mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="],
|
||||||
|
@ -1476,23 +1481,23 @@
|
||||||
|
|
||||||
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
|
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
|
||||||
|
|
||||||
"sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="],
|
"sst": ["sst@3.17.8", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.8", "sst-darwin-x64": "3.17.8", "sst-linux-arm64": "3.17.8", "sst-linux-x64": "3.17.8", "sst-linux-x86": "3.17.8", "sst-win32-arm64": "3.17.8", "sst-win32-x64": "3.17.8", "sst-win32-x86": "3.17.8" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-P/a9/ZsjtQRrTBerBMO1ODaVa5HVTmNLrQNJiYvu2Bgd0ov+vefQeHv6oima8HLlPwpDIPS2gxJk8BZrTZMfCA=="],
|
||||||
|
|
||||||
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="],
|
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-50P6YRMnZVItZUfB0+NzqMww2mmm4vB3zhTVtWUtGoXeiw78g1AEnVlmS28gYXPHM1P987jTvR7EON9u9ig/Dg=="],
|
||||||
|
|
||||||
"sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="],
|
"sst-darwin-x64": ["sst-darwin-x64@3.17.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-P0pnMHCmpkpcsxkWpilmeoD79LkbkoIcv6H0aeM9ArT/71/JBhvqH+HjMHSJCzni/9uR6er+nH5F+qol0UO6Bw=="],
|
||||||
|
|
||||||
"sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="],
|
"sst-linux-arm64": ["sst-linux-arm64@3.17.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-vun54YA/UzprCu9p8BC4rMwFU5Cj9xrHAHYLYUp/yq4H0pfmBIiQM62nsfIKizRThe/TkBFy60EEi9myf6raYA=="],
|
||||||
|
|
||||||
"sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="],
|
"sst-linux-x64": ["sst-linux-x64@3.17.8", "", { "os": "linux", "cpu": "x64" }, "sha512-HqByCaLE2gEJbM20P1QRd+GqDMAiieuU53FaZA1F+AGxQi+kR82NWjrPqFcMj4dMYg8w/TWXuV+G5+PwoUmpDw=="],
|
||||||
|
|
||||||
"sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="],
|
"sst-linux-x86": ["sst-linux-x86@3.17.8", "", { "os": "linux", "cpu": "none" }, "sha512-bCd6QM3MejfSmdvg8I/k+aUJQIZEQJg023qmN78fv00vwlAtfECvY7tjT9E2m3LDp33pXrcRYbFOQzPu+tWFfA=="],
|
||||||
|
|
||||||
"sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="],
|
"sst-win32-arm64": ["sst-win32-arm64@3.17.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-pilx0n8gm4aHJae/vNiqIwZkWF3tdwWzD/ON7hkytw+CVSZ0FXtyFW/yO/+2u3Yw0Kj0lSWPnUqYgm/eHPLwQA=="],
|
||||||
|
|
||||||
"sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="],
|
"sst-win32-x64": ["sst-win32-x64@3.17.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb0FVRyiOtESudF1V8ucW65PuHrx/iOHUamIO0JnbujWNHZBTRPB2QHN1dbewgkueYDaCmyS8lvuIImLwYJnzQ=="],
|
||||||
|
|
||||||
"sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="],
|
"sst-win32-x86": ["sst-win32-x86@3.17.8", "", { "os": "win32", "cpu": "none" }, "sha512-oVmFa/PoElQmfnGJlB0w6rPXiYuldiagO6AbrLMT/6oAnWerLQ8Uhv9tJWfMh3xtPLImQLTjxDo1v0AIzEv9QA=="],
|
||||||
|
|
||||||
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
|
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"sst": "3.17.6"
|
"sst": "3.17.8"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
1
packages/opencode/.gitignore
vendored
1
packages/opencode/.gitignore
vendored
|
@ -1,4 +1,3 @@
|
||||||
node_modules
|
|
||||||
research
|
research
|
||||||
dist
|
dist
|
||||||
gen
|
gen
|
||||||
|
|
|
@ -9,6 +9,7 @@ import fs from "fs/promises"
|
||||||
import { Installation } from "../../installation"
|
import { Installation } from "../../installation"
|
||||||
import { Config } from "../../config/config"
|
import { Config } from "../../config/config"
|
||||||
import { Bus } from "../../bus"
|
import { Bus } from "../../bus"
|
||||||
|
import { Log } from "../../util/log"
|
||||||
|
|
||||||
export const TuiCommand = cmd({
|
export const TuiCommand = cmd({
|
||||||
command: "$0 [project]",
|
command: "$0 [project]",
|
||||||
|
@ -57,6 +58,9 @@ export const TuiCommand = cmd({
|
||||||
cwd = process.cwd()
|
cwd = process.cwd()
|
||||||
cmd = [binary]
|
cmd = [binary]
|
||||||
}
|
}
|
||||||
|
Log.Default.info("tui", {
|
||||||
|
cmd,
|
||||||
|
})
|
||||||
const proc = Bun.spawn({
|
const proc = Bun.spawn({
|
||||||
cmd: [...cmd, ...process.argv.slice(2)],
|
cmd: [...cmd, ...process.argv.slice(2)],
|
||||||
cwd,
|
cwd,
|
||||||
|
|
|
@ -92,11 +92,20 @@ export namespace LSPClient {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
5_000,
|
5_000,
|
||||||
).catch(() => {
|
).catch((err) => {
|
||||||
throw new InitializeError({ serverID })
|
log.error("initialize error", { error: err })
|
||||||
|
throw new InitializeError(
|
||||||
|
{ serverID },
|
||||||
|
{
|
||||||
|
cause: err,
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
await connection.sendNotification("initialized", {})
|
await connection.sendNotification("initialized", {})
|
||||||
log.info("initialized")
|
log.info("initialized", {
|
||||||
|
serverID,
|
||||||
|
})
|
||||||
|
|
||||||
const files: {
|
const files: {
|
||||||
[path: string]: number
|
[path: string]: number
|
||||||
|
@ -174,7 +183,6 @@ export namespace LSPClient {
|
||||||
log.info("shutting down", { serverID })
|
log.info("shutting down", { serverID })
|
||||||
connection.end()
|
connection.end()
|
||||||
connection.dispose()
|
connection.dispose()
|
||||||
server.process.kill("SIGTERM")
|
|
||||||
log.info("shutdown", { serverID })
|
log.info("shutdown", { serverID })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,7 +47,7 @@ export namespace LSP {
|
||||||
const handle = await server.spawn(App.info())
|
const handle = await server.spawn(App.info())
|
||||||
if (!handle) break
|
if (!handle) break
|
||||||
const client = await LSPClient.create(server.id, handle).catch(
|
const client = await LSPClient.create(server.id, handle).catch(
|
||||||
() => {},
|
(err) => log.error("", { error: err }),
|
||||||
)
|
)
|
||||||
if (!client) break
|
if (!client) break
|
||||||
clients.set(server.id, client)
|
clients.set(server.id, client)
|
||||||
|
|
|
@ -4,6 +4,8 @@ import path from "path"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
|
import { $ } from "bun"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
|
||||||
export namespace LSPServer {
|
export namespace LSPServer {
|
||||||
const log = Log.create({ service: "lsp.server" })
|
const log = Log.create({ service: "lsp.server" })
|
||||||
|
@ -144,4 +146,60 @@ export namespace LSPServer {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ElixirLS: Info = {
|
||||||
|
id: "elixir-ls",
|
||||||
|
extensions: [".ex", ".exs"],
|
||||||
|
async spawn() {
|
||||||
|
let binary = Bun.which("elixir-ls")
|
||||||
|
if (!binary) {
|
||||||
|
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
|
||||||
|
binary = path.join(
|
||||||
|
Global.Path.bin,
|
||||||
|
"elixir-ls-master",
|
||||||
|
"release",
|
||||||
|
process.platform === "win32"
|
||||||
|
? "language_server.bar"
|
||||||
|
: "language_server.sh",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!(await Bun.file(binary).exists())) {
|
||||||
|
const elixir = Bun.which("elixir")
|
||||||
|
if (!elixir) {
|
||||||
|
log.error("elixir is required to run elixir-ls")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("downloading elixir-ls from GitHub releases")
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip",
|
||||||
|
)
|
||||||
|
if (!response.ok) return
|
||||||
|
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
|
||||||
|
await Bun.file(zipPath).write(response)
|
||||||
|
|
||||||
|
await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow()
|
||||||
|
|
||||||
|
await fs.rm(zipPath, {
|
||||||
|
force: true,
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
|
||||||
|
.quiet()
|
||||||
|
.cwd(path.join(Global.Path.bin, "elixir-ls-master"))
|
||||||
|
.env({ MIX_ENV: "prod", ...process.env })
|
||||||
|
|
||||||
|
log.info(`installed elixir-ls`, {
|
||||||
|
path: elixirLsPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
process: spawn(binary),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import path from "path"
|
import path from "node:path"
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
import { Storage } from "../storage/storage"
|
import { Storage } from "../storage/storage"
|
||||||
|
@ -15,6 +15,7 @@ import {
|
||||||
type UIMessage,
|
type UIMessage,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
wrapLanguageModel,
|
wrapLanguageModel,
|
||||||
|
type Attachment,
|
||||||
} from "ai"
|
} from "ai"
|
||||||
import { z, ZodSchema } from "zod"
|
import { z, ZodSchema } from "zod"
|
||||||
import { Decimal } from "decimal.js"
|
import { Decimal } from "decimal.js"
|
||||||
|
@ -187,7 +188,6 @@ export namespace Session {
|
||||||
export async function unshare(id: string) {
|
export async function unshare(id: string) {
|
||||||
const share = await getShare(id)
|
const share = await getShare(id)
|
||||||
if (!share) return
|
if (!share) return
|
||||||
console.log("share", share)
|
|
||||||
await Storage.remove("session/share/" + id)
|
await Storage.remove("session/share/" + id)
|
||||||
await update(id, (draft) => {
|
await update(id, (draft) => {
|
||||||
draft.share = undefined
|
draft.share = undefined
|
||||||
|
@ -361,6 +361,36 @@ 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()
|
||||||
|
input.parts = await Promise.all(
|
||||||
|
input.parts.map(async (part) => {
|
||||||
|
if (part.type === "file") {
|
||||||
|
const url = new URL(part.url)
|
||||||
|
switch (url.protocol) {
|
||||||
|
case "file:":
|
||||||
|
let content = await Bun.file(
|
||||||
|
path.join(app.path.cwd, url.pathname),
|
||||||
|
).text()
|
||||||
|
const range = {
|
||||||
|
start: url.searchParams.get("start"),
|
||||||
|
end: url.searchParams.get("end"),
|
||||||
|
}
|
||||||
|
if (range.start != null && part.mediaType === "text/plain") {
|
||||||
|
const lines = content.split("\n")
|
||||||
|
const start = parseInt(range.start)
|
||||||
|
const end = range.end ? parseInt(range.end) : lines.length
|
||||||
|
content = lines.slice(start, end).join("\n")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "file",
|
||||||
|
url: `data:${part.mediaType};base64,` + btoa(content),
|
||||||
|
mediaType: part.mediaType,
|
||||||
|
filename: part.filename,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
}),
|
||||||
|
)
|
||||||
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,
|
||||||
|
@ -376,7 +406,7 @@ export namespace Session {
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "",
|
content: "",
|
||||||
parts: toParts(input.parts),
|
parts: toParts(input.parts).parts,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
@ -1028,7 +1058,7 @@ function toUIMessage(msg: Message.Info): UIMessage {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "",
|
content: "",
|
||||||
parts: toParts(msg.parts),
|
...toParts(msg.parts),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1037,35 +1067,41 @@ function toUIMessage(msg: Message.Info): UIMessage {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "",
|
content: "",
|
||||||
parts: toParts(msg.parts),
|
...toParts(msg.parts),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("not implemented")
|
throw new Error("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
function toParts(parts: Message.MessagePart[]): UIMessage["parts"] {
|
function toParts(parts: Message.MessagePart[]) {
|
||||||
const result: UIMessage["parts"] = []
|
const result: {
|
||||||
|
parts: UIMessage["parts"]
|
||||||
|
experimental_attachments: Attachment[]
|
||||||
|
} = {
|
||||||
|
parts: [],
|
||||||
|
experimental_attachments: [],
|
||||||
|
}
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case "text":
|
case "text":
|
||||||
result.push({ type: "text", text: part.text })
|
result.parts.push({ type: "text", text: part.text })
|
||||||
break
|
break
|
||||||
case "file":
|
case "file":
|
||||||
result.push({
|
result.experimental_attachments.push({
|
||||||
type: "file",
|
url: part.url,
|
||||||
data: part.url,
|
contentType: part.mediaType,
|
||||||
mimeType: part.mediaType,
|
name: part.filename,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "tool-invocation":
|
case "tool-invocation":
|
||||||
result.push({
|
result.parts.push({
|
||||||
type: "tool-invocation",
|
type: "tool-invocation",
|
||||||
toolInvocation: part.toolInvocation,
|
toolInvocation: part.toolInvocation,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "step-start":
|
case "step-start":
|
||||||
result.push({
|
result.parts.push({
|
||||||
type: "step-start",
|
type: "step-start",
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|
|
@ -1,14 +1,7 @@
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
import {
|
import { $ } from "bun"
|
||||||
add,
|
|
||||||
commit,
|
|
||||||
init,
|
|
||||||
checkout,
|
|
||||||
statusMatrix,
|
|
||||||
remove,
|
|
||||||
} from "isomorphic-git"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs"
|
import fs from "fs/promises"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
|
@ -16,66 +9,55 @@ export namespace Snapshot {
|
||||||
const log = Log.create({ service: "snapshot" })
|
const log = Log.create({ service: "snapshot" })
|
||||||
|
|
||||||
export async function create(sessionID: string) {
|
export async function create(sessionID: string) {
|
||||||
|
return
|
||||||
|
log.info("creating snapshot")
|
||||||
const app = App.info()
|
const app = App.info()
|
||||||
const git = gitdir(sessionID)
|
const git = gitdir(sessionID)
|
||||||
const files = await Ripgrep.files({
|
|
||||||
cwd: app.path.cwd,
|
// not a git repo, check if too big to snapshot
|
||||||
limit: app.git ? undefined : 1000,
|
if (!app.git) {
|
||||||
})
|
const files = await Ripgrep.files({
|
||||||
// not a git repo and too big to snapshot
|
cwd: app.path.cwd,
|
||||||
if (!app.git && files.length === 1000) return
|
limit: 1000,
|
||||||
await init({
|
})
|
||||||
dir: app.path.cwd,
|
log.info("found files", { count: files.length })
|
||||||
gitdir: git,
|
if (files.length > 1000) return
|
||||||
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,
|
if (await fs.mkdir(git, { recursive: true })) {
|
||||||
gitdir: git,
|
await $`git init`
|
||||||
dir: app.path.cwd,
|
.env({
|
||||||
message: "snapshot",
|
...process.env,
|
||||||
author: {
|
GIT_DIR: git,
|
||||||
name: "opencode",
|
GIT_WORK_TREE: app.path.root,
|
||||||
email: "mail@opencode.ai",
|
})
|
||||||
},
|
.quiet()
|
||||||
})
|
.nothrow()
|
||||||
log.info("commit", { result })
|
log.info("initialized")
|
||||||
return result
|
}
|
||||||
|
|
||||||
|
await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow()
|
||||||
|
log.info("added files")
|
||||||
|
|
||||||
|
const result =
|
||||||
|
await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --author="opencode <mail@opencode.ai>"`
|
||||||
|
.quiet()
|
||||||
|
.cwd(app.path.cwd)
|
||||||
|
.nothrow()
|
||||||
|
log.info("commit")
|
||||||
|
|
||||||
|
const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/)
|
||||||
|
if (!match) return
|
||||||
|
return match![1]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restore(sessionID: string, commit: string) {
|
export async function restore(sessionID: string, commit: string) {
|
||||||
log.info("restore", { commit })
|
log.info("restore", { commit })
|
||||||
const app = App.info()
|
const app = App.info()
|
||||||
await checkout({
|
const git = gitdir(sessionID)
|
||||||
fs,
|
await $`git --git-dir=${git} checkout ${commit} --force`
|
||||||
gitdir: gitdir(sessionID),
|
.quiet()
|
||||||
dir: app.path.cwd,
|
.cwd(app.path.root)
|
||||||
ref: commit,
|
|
||||||
force: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function gitdir(sessionID: string) {
|
function gitdir(sessionID: string) {
|
||||||
|
|
|
@ -4,25 +4,6 @@ import DESCRIPTION from "./bash.txt"
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
|
|
||||||
const MAX_OUTPUT_LENGTH = 30000
|
const MAX_OUTPUT_LENGTH = 30000
|
||||||
const BANNED_COMMANDS = [
|
|
||||||
"alias",
|
|
||||||
"curl",
|
|
||||||
"curlie",
|
|
||||||
"wget",
|
|
||||||
"axel",
|
|
||||||
"aria2c",
|
|
||||||
"nc",
|
|
||||||
"telnet",
|
|
||||||
"lynx",
|
|
||||||
"w3m",
|
|
||||||
"links",
|
|
||||||
"httpie",
|
|
||||||
"xh",
|
|
||||||
"http-prompt",
|
|
||||||
"chrome",
|
|
||||||
"firefox",
|
|
||||||
"safari",
|
|
||||||
]
|
|
||||||
const DEFAULT_TIMEOUT = 1 * 60 * 1000
|
const DEFAULT_TIMEOUT = 1 * 60 * 1000
|
||||||
const MAX_TIMEOUT = 10 * 60 * 1000
|
const MAX_TIMEOUT = 10 * 60 * 1000
|
||||||
|
|
||||||
|
@ -45,8 +26,6 @@ export const BashTool = Tool.define({
|
||||||
}),
|
}),
|
||||||
async execute(params, ctx) {
|
async execute(params, ctx) {
|
||||||
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
|
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
|
||||||
if (BANNED_COMMANDS.some((item) => params.command.startsWith(item)))
|
|
||||||
throw new Error(`Command '${params.command}' is not allowed`)
|
|
||||||
|
|
||||||
const process = Bun.spawn({
|
const process = Bun.spawn({
|
||||||
cmd: ["bash", "-c", params.command],
|
cmd: ["bash", "-c", params.command],
|
||||||
|
|
|
@ -489,10 +489,10 @@ export function replace(
|
||||||
BlockAnchorReplacer,
|
BlockAnchorReplacer,
|
||||||
WhitespaceNormalizedReplacer,
|
WhitespaceNormalizedReplacer,
|
||||||
IndentationFlexibleReplacer,
|
IndentationFlexibleReplacer,
|
||||||
EscapeNormalizedReplacer,
|
// EscapeNormalizedReplacer,
|
||||||
TrimmedBoundaryReplacer,
|
// TrimmedBoundaryReplacer,
|
||||||
ContextAwareReplacer,
|
// ContextAwareReplacer,
|
||||||
MultiOccurrenceReplacer,
|
// MultiOccurrenceReplacer,
|
||||||
]) {
|
]) {
|
||||||
for (const search of replacer(content, oldString)) {
|
for (const search of replacer(content, oldString)) {
|
||||||
const index = content.indexOf(search)
|
const index = content.indexOf(search)
|
||||||
|
|
|
@ -37,6 +37,7 @@ 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
|
||||||
|
|
|
@ -92,6 +92,8 @@ 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=
|
||||||
|
|
|
@ -45,7 +45,7 @@ type SessionClearedMsg struct{}
|
||||||
type CompactSessionMsg struct{}
|
type CompactSessionMsg struct{}
|
||||||
type SendMsg struct {
|
type SendMsg struct {
|
||||||
Text string
|
Text string
|
||||||
Attachments []Attachment
|
Attachments []opencode.FilePartParam
|
||||||
}
|
}
|
||||||
type OptimisticMessageAddedMsg struct {
|
type OptimisticMessageAddedMsg struct {
|
||||||
Message opencode.Message
|
Message opencode.Message
|
||||||
|
@ -218,13 +218,6 @@ func getDefaultModel(
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Attachment struct {
|
|
||||||
FilePath string
|
|
||||||
FileName string
|
|
||||||
MimeType string
|
|
||||||
Content []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) IsBusy() bool {
|
func (a *App) IsBusy() bool {
|
||||||
if len(a.Messages) == 0 {
|
if len(a.Messages) == 0 {
|
||||||
return false
|
return false
|
||||||
|
@ -310,24 +303,40 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
|
||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
|
func (a *App) SendChatMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
text string,
|
||||||
|
attachments []opencode.FilePartParam,
|
||||||
|
) (*App, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
if a.Session.ID == "" {
|
if a.Session.ID == "" {
|
||||||
session, err := a.CreateSession(ctx)
|
session, err := a.CreateSession(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return toast.NewErrorToast(err.Error())
|
return a, toast.NewErrorToast(err.Error())
|
||||||
}
|
}
|
||||||
a.Session = session
|
a.Session = session
|
||||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
optimisticParts := []opencode.MessagePart{{
|
||||||
|
Type: opencode.MessagePartTypeText,
|
||||||
|
Text: text,
|
||||||
|
}}
|
||||||
|
if len(attachments) > 0 {
|
||||||
|
for _, attachment := range attachments {
|
||||||
|
optimisticParts = append(optimisticParts, opencode.MessagePart{
|
||||||
|
Type: opencode.MessagePartTypeFile,
|
||||||
|
Filename: attachment.Filename.Value,
|
||||||
|
MediaType: attachment.MediaType.Value,
|
||||||
|
URL: attachment.URL.Value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
optimisticMessage := opencode.Message{
|
optimisticMessage := opencode.Message{
|
||||||
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
|
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
|
||||||
Role: opencode.MessageRoleUser,
|
Role: opencode.MessageRoleUser,
|
||||||
Parts: []opencode.MessagePart{{
|
Parts: optimisticParts,
|
||||||
Type: opencode.MessagePartTypeText,
|
|
||||||
Text: text,
|
|
||||||
}},
|
|
||||||
Metadata: opencode.MessageMetadata{
|
Metadata: opencode.MessageMetadata{
|
||||||
SessionID: a.Session.ID,
|
SessionID: a.Session.ID,
|
||||||
Time: opencode.MessageMetadataTime{
|
Time: opencode.MessageMetadataTime{
|
||||||
|
@ -340,13 +349,25 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
||||||
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
|
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
|
||||||
|
|
||||||
cmds = append(cmds, func() tea.Msg {
|
cmds = append(cmds, func() tea.Msg {
|
||||||
|
parts := []opencode.MessagePartUnionParam{
|
||||||
|
opencode.TextPartParam{
|
||||||
|
Type: opencode.F(opencode.TextPartTypeText),
|
||||||
|
Text: opencode.F(text),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if len(attachments) > 0 {
|
||||||
|
for _, attachment := range attachments {
|
||||||
|
parts = append(parts, opencode.FilePartParam{
|
||||||
|
MediaType: attachment.MediaType,
|
||||||
|
Type: attachment.Type,
|
||||||
|
URL: attachment.URL,
|
||||||
|
Filename: attachment.Filename,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
|
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
|
||||||
Parts: opencode.F([]opencode.MessagePartUnionParam{
|
Parts: opencode.F(parts),
|
||||||
opencode.TextPartParam{
|
|
||||||
Type: opencode.F(opencode.TextPartTypeText),
|
|
||||||
Text: opencode.F(text),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
ProviderID: opencode.F(a.Provider.ID),
|
ProviderID: opencode.F(a.Provider.ID),
|
||||||
ModelID: opencode.F(a.Model.ID),
|
ModelID: opencode.F(a.Model.ID),
|
||||||
})
|
})
|
||||||
|
@ -360,7 +381,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
||||||
|
|
||||||
// The actual response will come through SSE
|
// The actual response will come through SSE
|
||||||
// For now, just return success
|
// For now, just return success
|
||||||
return tea.Batch(cmds...)
|
return a, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
||||||
|
|
|
@ -16,12 +16,11 @@ import (
|
||||||
|
|
||||||
type filesAndFoldersContextGroup struct {
|
type filesAndFoldersContextGroup struct {
|
||||||
app *app.App
|
app *app.App
|
||||||
prefix string
|
|
||||||
gitFiles []dialog.CompletionItemI
|
gitFiles []dialog.CompletionItemI
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) GetId() string {
|
func (cg *filesAndFoldersContextGroup) GetId() string {
|
||||||
return cg.prefix
|
return "files"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
|
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
|
||||||
|
@ -107,9 +106,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(
|
||||||
|
|
||||||
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
|
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
|
||||||
cg := &filesAndFoldersContextGroup{
|
cg := &filesAndFoldersContextGroup{
|
||||||
app: app,
|
app: app,
|
||||||
prefix: "file",
|
|
||||||
}
|
}
|
||||||
cg.gitFiles = cg.getGitFiles()
|
go func() {
|
||||||
|
cg.gitFiles = cg.getGitFiles()
|
||||||
|
}()
|
||||||
return cg
|
return cg
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,14 @@ package chat
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"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/commands"
|
||||||
"github.com/sst/opencode/internal/components/dialog"
|
"github.com/sst/opencode/internal/components/dialog"
|
||||||
|
@ -37,7 +40,6 @@ type EditorComponent interface {
|
||||||
type editorComponent struct {
|
type editorComponent struct {
|
||||||
app *app.App
|
app *app.App
|
||||||
textarea textarea.Model
|
textarea textarea.Model
|
||||||
attachments []app.Attachment
|
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
interruptKeyInDebounce bool
|
interruptKeyInDebounce bool
|
||||||
}
|
}
|
||||||
|
@ -66,17 +68,55 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.spinner = createSpinner()
|
m.spinner = createSpinner()
|
||||||
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
|
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
|
||||||
case dialog.CompletionSelectedMsg:
|
case dialog.CompletionSelectedMsg:
|
||||||
if msg.IsCommand {
|
switch msg.ProviderID {
|
||||||
|
case "commands":
|
||||||
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
||||||
updated, cmd := m.Clear()
|
updated, cmd := m.Clear()
|
||||||
m = updated.(*editorComponent)
|
m = updated.(*editorComponent)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
|
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
} else {
|
case "files":
|
||||||
existingValue := m.textarea.Value()
|
atIndex := m.textarea.LastRuneIndex('@')
|
||||||
|
if atIndex == -1 {
|
||||||
|
// Should not happen, but as a fallback, just insert.
|
||||||
|
m.textarea.InsertString(msg.CompletionValue + " ")
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Replace the current token (after last space)
|
// The range to replace is from the '@' up to the current cursor position.
|
||||||
|
// Replace the search term (e.g., "@search") with an empty string first.
|
||||||
|
cursorCol := m.textarea.CursorColumn()
|
||||||
|
m.textarea.ReplaceRange(atIndex, cursorCol, "")
|
||||||
|
|
||||||
|
// Now, insert the attachment at the position where the '@' was.
|
||||||
|
// The cursor is now at `atIndex` after the replacement.
|
||||||
|
filePath := msg.CompletionValue
|
||||||
|
fileName := filepath.Base(filePath)
|
||||||
|
extension := filepath.Ext(filePath)
|
||||||
|
mediaType := ""
|
||||||
|
switch extension {
|
||||||
|
case ".jpg":
|
||||||
|
mediaType = "image/jpeg"
|
||||||
|
case ".png", ".jpeg", ".gif", ".webp":
|
||||||
|
mediaType = "image/" + extension[1:]
|
||||||
|
case ".pdf":
|
||||||
|
mediaType = "application/pdf"
|
||||||
|
default:
|
||||||
|
mediaType = "text/plain"
|
||||||
|
}
|
||||||
|
attachment := &textarea.Attachment{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Display: "@" + fileName,
|
||||||
|
URL: fmt.Sprintf("file://./%s", filePath),
|
||||||
|
Filename: filePath,
|
||||||
|
MediaType: mediaType,
|
||||||
|
}
|
||||||
|
m.textarea.InsertAttachment(attachment)
|
||||||
|
m.textarea.InsertString(" ")
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
existingValue := m.textarea.Value()
|
||||||
lastSpaceIndex := strings.LastIndex(existingValue, " ")
|
lastSpaceIndex := strings.LastIndex(existingValue, " ")
|
||||||
if lastSpaceIndex == -1 {
|
if lastSpaceIndex == -1 {
|
||||||
m.textarea.SetValue(msg.CompletionValue + " ")
|
m.textarea.SetValue(msg.CompletionValue + " ")
|
||||||
|
@ -128,7 +168,15 @@ func (m *editorComponent) Content(width int) string {
|
||||||
if m.app.IsBusy() {
|
if m.app.IsBusy() {
|
||||||
keyText := m.getInterruptKeyText()
|
keyText := m.getInterruptKeyText()
|
||||||
if m.interruptKeyInDebounce {
|
if m.interruptKeyInDebounce {
|
||||||
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
|
hint = muted(
|
||||||
|
"working",
|
||||||
|
) + m.spinner.View() + muted(
|
||||||
|
" ",
|
||||||
|
) + base(
|
||||||
|
keyText+" again",
|
||||||
|
) + muted(
|
||||||
|
" interrupt",
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
|
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
|
||||||
}
|
}
|
||||||
|
@ -190,19 +238,29 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
if len(value) > 0 && value[len(value)-1] == '\\' {
|
if len(value) > 0 && value[len(value)-1] == '\\' {
|
||||||
// If the last character is a backslash, remove it and add a newline
|
// If the last character is a backslash, remove it and add a newline
|
||||||
m.textarea.SetValue(value[:len(value)-1] + "\n")
|
m.textarea.ReplaceRange(len(value)-1, len(value), "")
|
||||||
|
m.textarea.InsertString("\n")
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
|
attachments := m.textarea.GetAttachments()
|
||||||
|
fileParts := make([]opencode.FilePartParam, 0)
|
||||||
|
for _, attachment := range attachments {
|
||||||
|
fileParts = append(fileParts, opencode.FilePartParam{
|
||||||
|
Type: opencode.F(opencode.FilePartTypeFile),
|
||||||
|
MediaType: opencode.F(attachment.MediaType),
|
||||||
|
URL: opencode.F(attachment.URL),
|
||||||
|
Filename: opencode.F(attachment.Filename),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
updated, cmd := m.Clear()
|
updated, cmd := m.Clear()
|
||||||
m = updated.(*editorComponent)
|
m = updated.(*editorComponent)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
attachments := m.attachments
|
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
|
||||||
m.attachments = nil
|
|
||||||
|
|
||||||
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
|
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -212,18 +270,23 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
|
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
|
||||||
imageBytes, text, err := image.GetImageFromClipboard()
|
_, text, err := image.GetImageFromClipboard()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error(err.Error())
|
slog.Error(err.Error())
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if len(imageBytes) != 0 {
|
// if len(imageBytes) != 0 {
|
||||||
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
|
// attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
|
||||||
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
|
// attachment := app.Attachment{
|
||||||
m.attachments = append(m.attachments, attachment)
|
// FilePath: attachmentName,
|
||||||
} else {
|
// FileName: attachmentName,
|
||||||
m.textarea.SetValue(m.textarea.Value() + text)
|
// Content: imageBytes,
|
||||||
}
|
// MimeType: "image/png",
|
||||||
|
// }
|
||||||
|
// m.attachments = append(m.attachments, attachment)
|
||||||
|
// } else {
|
||||||
|
m.textarea.InsertString(text)
|
||||||
|
// }
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -254,12 +317,26 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
||||||
|
|
||||||
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
|
ta.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||||
|
Foreground(textMutedColor).
|
||||||
|
Background(bgColor).
|
||||||
|
Lipgloss()
|
||||||
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||||
ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
|
ta.Styles.Focused.Placeholder = styles.NewStyle().
|
||||||
|
Foreground(textMutedColor).
|
||||||
|
Background(bgColor).
|
||||||
|
Lipgloss()
|
||||||
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||||
|
ta.Styles.Attachment = styles.NewStyle().
|
||||||
|
Foreground(t.Secondary()).
|
||||||
|
Background(bgColor).
|
||||||
|
Lipgloss()
|
||||||
|
ta.Styles.SelectedAttachment = styles.NewStyle().
|
||||||
|
Foreground(t.Text()).
|
||||||
|
Background(t.Secondary()).
|
||||||
|
Lipgloss()
|
||||||
ta.Styles.Cursor.Color = t.Primary()
|
ta.Styles.Cursor.Color = t.Primary()
|
||||||
|
|
||||||
ta.Prompt = " "
|
ta.Prompt = " "
|
||||||
|
|
|
@ -223,6 +223,7 @@ func renderText(
|
||||||
showToolDetails bool,
|
showToolDetails bool,
|
||||||
highlight bool,
|
highlight bool,
|
||||||
width int,
|
width int,
|
||||||
|
extra string,
|
||||||
toolCalls ...opencode.ToolInvocationPart,
|
toolCalls ...opencode.ToolInvocationPart,
|
||||||
) string {
|
) string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
|
@ -269,7 +270,11 @@ func renderText(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
content = strings.Join([]string{content, info}, "\n")
|
sections := []string{content, info}
|
||||||
|
if extra != "" {
|
||||||
|
sections = append(sections, "\n"+extra)
|
||||||
|
}
|
||||||
|
content = strings.Join(sections, "\n")
|
||||||
|
|
||||||
switch message.Role {
|
switch message.Role {
|
||||||
case opencode.MessageRoleUser:
|
case opencode.MessageRoleUser:
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"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"
|
||||||
|
@ -133,10 +134,49 @@ func (m *messagesComponent) renderView(width int) {
|
||||||
|
|
||||||
switch message.Role {
|
switch message.Role {
|
||||||
case opencode.MessageRoleUser:
|
case opencode.MessageRoleUser:
|
||||||
for _, part := range message.Parts {
|
for partIndex, 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, width, m.selectedPart == m.partCount)
|
remainingParts := message.Parts[partIndex+1:]
|
||||||
|
fileParts := make([]opencode.FilePart, 0)
|
||||||
|
for _, part := range remainingParts {
|
||||||
|
switch part := part.AsUnion().(type) {
|
||||||
|
case opencode.FilePart:
|
||||||
|
fileParts = append(fileParts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flexItems := []layout.FlexItem{}
|
||||||
|
if len(fileParts) > 0 {
|
||||||
|
fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1)
|
||||||
|
mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1)
|
||||||
|
for _, filePart := range fileParts {
|
||||||
|
mediaType := ""
|
||||||
|
switch filePart.MediaType {
|
||||||
|
case "text/plain":
|
||||||
|
mediaType = "txt"
|
||||||
|
case "image/png", "image/jpeg", "image/gif", "image/webp":
|
||||||
|
mediaType = "img"
|
||||||
|
mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
|
||||||
|
case "application/pdf":
|
||||||
|
mediaType = "pdf"
|
||||||
|
mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
|
||||||
|
}
|
||||||
|
flexItems = append(flexItems, layout.FlexItem{
|
||||||
|
View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bgColor := t.BackgroundPanel()
|
||||||
|
files := layout.Render(
|
||||||
|
layout.FlexOptions{
|
||||||
|
Background: &bgColor,
|
||||||
|
Width: width - 6,
|
||||||
|
Direction: layout.Column,
|
||||||
|
},
|
||||||
|
flexItems...,
|
||||||
|
)
|
||||||
|
|
||||||
|
key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount, files)
|
||||||
content, cached = m.cache.Get(key)
|
content, cached = m.cache.Get(key)
|
||||||
if !cached {
|
if !cached {
|
||||||
content = renderText(
|
content = renderText(
|
||||||
|
@ -147,6 +187,7 @@ func (m *messagesComponent) renderView(width int) {
|
||||||
m.showToolDetails,
|
m.showToolDetails,
|
||||||
m.partCount == m.selectedPart,
|
m.partCount == m.selectedPart,
|
||||||
width,
|
width,
|
||||||
|
files,
|
||||||
)
|
)
|
||||||
m.cache.Set(key, content)
|
m.cache.Set(key, content)
|
||||||
}
|
}
|
||||||
|
@ -206,6 +247,7 @@ func (m *messagesComponent) renderView(width int) {
|
||||||
m.showToolDetails,
|
m.showToolDetails,
|
||||||
m.partCount == m.selectedPart,
|
m.partCount == m.selectedPart,
|
||||||
width,
|
width,
|
||||||
|
"",
|
||||||
toolCallParts...,
|
toolCallParts...,
|
||||||
)
|
)
|
||||||
m.cache.Set(key, content)
|
m.cache.Set(key, content)
|
||||||
|
@ -219,6 +261,7 @@ func (m *messagesComponent) renderView(width int) {
|
||||||
m.showToolDetails,
|
m.showToolDetails,
|
||||||
m.partCount == m.selectedPart,
|
m.partCount == m.selectedPart,
|
||||||
width,
|
width,
|
||||||
|
"",
|
||||||
toolCallParts...,
|
toolCallParts...,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,7 +64,7 @@ type CompletionProvider interface {
|
||||||
type CompletionSelectedMsg struct {
|
type CompletionSelectedMsg struct {
|
||||||
SearchString string
|
SearchString string
|
||||||
CompletionValue string
|
CompletionValue string
|
||||||
IsCommand bool
|
ProviderID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CompletionDialogCompleteItemMsg struct {
|
type CompletionDialogCompleteItemMsg struct {
|
||||||
|
@ -121,9 +121,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
var query string
|
var query string
|
||||||
query = c.pseudoSearchTextArea.Value()
|
query = c.pseudoSearchTextArea.Value()
|
||||||
if query != "" {
|
|
||||||
query = query[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
if query != c.query {
|
if query != c.query {
|
||||||
c.query = query
|
c.query = query
|
||||||
|
@ -183,8 +180,9 @@ func (c *completionDialogComponent) View() string {
|
||||||
|
|
||||||
for _, cmd := range completions {
|
for _, cmd := range completions {
|
||||||
title := cmd.DisplayValue()
|
title := cmd.DisplayValue()
|
||||||
if len(title) > maxWidth-4 {
|
width := lipgloss.Width(title)
|
||||||
maxWidth = len(title) + 4
|
if width > maxWidth-4 {
|
||||||
|
maxWidth = width + 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -213,14 +211,11 @@ func (c *completionDialogComponent) IsEmpty() bool {
|
||||||
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
||||||
value := c.pseudoSearchTextArea.Value()
|
value := c.pseudoSearchTextArea.Value()
|
||||||
|
|
||||||
// Check if this is a command completion
|
|
||||||
isCommand := c.completionProvider.GetId() == "commands"
|
|
||||||
|
|
||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
util.CmdHandler(CompletionSelectedMsg{
|
util.CmdHandler(CompletionSelectedMsg{
|
||||||
SearchString: value,
|
SearchString: value,
|
||||||
CompletionValue: item.GetValue(),
|
CompletionValue: item.GetValue(),
|
||||||
IsCommand: isCommand,
|
ProviderID: c.completionProvider.GetId(),
|
||||||
}),
|
}),
|
||||||
c.close(),
|
c.close(),
|
||||||
)
|
)
|
||||||
|
|
|
@ -124,7 +124,7 @@ func (f *findDialogComponent) View() string {
|
||||||
f.list.SetMaxWidth(f.width - 4)
|
f.list.SetMaxWidth(f.width - 4)
|
||||||
inputView := f.textInput.View()
|
inputView := f.textInput.View()
|
||||||
inputView = styles.NewStyle().
|
inputView = styles.NewStyle().
|
||||||
Background(t.BackgroundPanel()).
|
Background(t.BackgroundElement()).
|
||||||
Height(1).
|
Height(1).
|
||||||
Width(f.width-4).
|
Width(f.width-4).
|
||||||
Padding(0, 0).
|
Padding(0, 0).
|
||||||
|
@ -171,7 +171,7 @@ func (f *findDialogComponent) Close() tea.Cmd {
|
||||||
|
|
||||||
func createTextInput(existing *textinput.Model) textinput.Model {
|
func createTextInput(existing *textinput.Model) textinput.Model {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
bgColor := t.BackgroundPanel()
|
bgColor := t.BackgroundElement()
|
||||||
textColor := t.Text()
|
textColor := t.Text()
|
||||||
textMutedColor := t.TextMuted()
|
textMutedColor := t.TextMuted()
|
||||||
|
|
||||||
|
|
|
@ -56,24 +56,24 @@ func (m ModelItem) Render(selected bool, width int) string {
|
||||||
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
|
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
|
||||||
return styles.NewStyle().
|
return styles.NewStyle().
|
||||||
Background(t.Primary()).
|
Background(t.Primary()).
|
||||||
Foreground(t.BackgroundElement()).
|
Foreground(t.BackgroundPanel()).
|
||||||
Width(width).
|
Width(width).
|
||||||
PaddingLeft(1).
|
PaddingLeft(1).
|
||||||
Render(displayText)
|
Render(displayText)
|
||||||
} else {
|
} else {
|
||||||
modelStyle := styles.NewStyle().
|
modelStyle := styles.NewStyle().
|
||||||
Foreground(t.Text()).
|
Foreground(t.Text()).
|
||||||
Background(t.BackgroundElement())
|
Background(t.BackgroundPanel())
|
||||||
providerStyle := styles.NewStyle().
|
providerStyle := styles.NewStyle().
|
||||||
Foreground(t.TextMuted()).
|
Foreground(t.TextMuted()).
|
||||||
Background(t.BackgroundElement())
|
Background(t.BackgroundPanel())
|
||||||
|
|
||||||
modelPart := modelStyle.Render(m.ModelName)
|
modelPart := modelStyle.Render(m.ModelName)
|
||||||
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
|
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
|
||||||
|
|
||||||
combinedText := modelPart + providerPart
|
combinedText := modelPart + providerPart
|
||||||
return styles.NewStyle().
|
return styles.NewStyle().
|
||||||
Background(t.BackgroundElement()).
|
Background(t.BackgroundPanel()).
|
||||||
PaddingLeft(1).
|
PaddingLeft(1).
|
||||||
Render(combinedText)
|
Render(combinedText)
|
||||||
}
|
}
|
||||||
|
|
|
@ -158,7 +158,12 @@ func (c *listComponent[T]) View() string {
|
||||||
return strings.Join(listItems, "\n")
|
return strings.Join(listItems, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
|
func NewListComponent[T ListItem](
|
||||||
|
items []T,
|
||||||
|
maxVisibleItems int,
|
||||||
|
fallbackMsg string,
|
||||||
|
useAlphaNumericKeys bool,
|
||||||
|
) List[T] {
|
||||||
return &listComponent[T]{
|
return &listComponent[T]{
|
||||||
fallbackMsg: fallbackMsg,
|
fallbackMsg: fallbackMsg,
|
||||||
items: items,
|
items: items,
|
||||||
|
@ -194,7 +199,12 @@ func (s StringItem) Render(selected bool, width int) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStringList creates a new list component with string items
|
// NewStringList creates a new list component with string items
|
||||||
func NewStringList(items []string, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[StringItem] {
|
func NewStringList(
|
||||||
|
items []string,
|
||||||
|
maxVisibleItems int,
|
||||||
|
fallbackMsg string,
|
||||||
|
useAlphaNumericKeys bool,
|
||||||
|
) List[StringItem] {
|
||||||
stringItems := make([]StringItem, len(items))
|
stringItems := make([]StringItem, len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
stringItems[i] = StringItem(item)
|
stringItems[i] = StringItem(item)
|
||||||
|
|
|
@ -90,7 +90,7 @@ func (m *Modal) Render(contentView string, background string) string {
|
||||||
|
|
||||||
innerWidth := outerWidth - 4
|
innerWidth := outerWidth - 4
|
||||||
|
|
||||||
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement())
|
baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel())
|
||||||
|
|
||||||
var finalContent string
|
var finalContent string
|
||||||
if m.title != "" {
|
if m.title != "" {
|
||||||
|
@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string {
|
||||||
modalView,
|
modalView,
|
||||||
background,
|
background,
|
||||||
layout.WithOverlayBorder(),
|
layout.WithOverlayBorder(),
|
||||||
layout.WithOverlayBorderColor(t.BorderActive()),
|
layout.WithOverlayBorderColor(t.Primary()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ func Generate(text string) (string, int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create lipgloss style for QR code with theme colors
|
// Create lipgloss style for QR code with theme colors
|
||||||
qrStyle := styles.NewStyleWithColors(t.Text(), t.Background())
|
qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
|
||||||
|
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,41 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,90 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -52,7 +52,9 @@ type appModel struct {
|
||||||
messages chat.MessagesComponent
|
messages chat.MessagesComponent
|
||||||
completions dialog.CompletionDialog
|
completions dialog.CompletionDialog
|
||||||
commandProvider dialog.CompletionProvider
|
commandProvider dialog.CompletionProvider
|
||||||
|
fileProvider dialog.CompletionProvider
|
||||||
showCompletionDialog bool
|
showCompletionDialog bool
|
||||||
|
fileCompletionActive bool
|
||||||
leaderBinding *key.Binding
|
leaderBinding *key.Binding
|
||||||
isLeaderSequence bool
|
isLeaderSequence bool
|
||||||
toastManager *toast.ToastManager
|
toastManager *toast.ToastManager
|
||||||
|
@ -180,11 +182,33 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
!a.showCompletionDialog &&
|
!a.showCompletionDialog &&
|
||||||
a.editor.Value() == "" {
|
a.editor.Value() == "" {
|
||||||
a.showCompletionDialog = true
|
a.showCompletionDialog = true
|
||||||
|
a.fileCompletionActive = false
|
||||||
|
|
||||||
updated, cmd := a.editor.Update(msg)
|
updated, cmd := a.editor.Update(msg)
|
||||||
a.editor = updated.(chat.EditorComponent)
|
a.editor = updated.(chat.EditorComponent)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
// Set command provider for command completion
|
||||||
|
a.completions = dialog.NewCompletionDialogComponent(a.commandProvider)
|
||||||
|
updated, cmd = a.completions.Update(msg)
|
||||||
|
a.completions = updated.(dialog.CompletionDialog)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
return a, tea.Sequence(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file completions trigger
|
||||||
|
if keyString == "@" &&
|
||||||
|
!a.showCompletionDialog {
|
||||||
|
a.showCompletionDialog = true
|
||||||
|
a.fileCompletionActive = true
|
||||||
|
|
||||||
|
updated, cmd := a.editor.Update(msg)
|
||||||
|
a.editor = updated.(chat.EditorComponent)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
// Set file provider for file completion
|
||||||
|
a.completions = dialog.NewCompletionDialogComponent(a.fileProvider)
|
||||||
updated, cmd = a.completions.Update(msg)
|
updated, cmd = a.completions.Update(msg)
|
||||||
a.completions = updated.(dialog.CompletionDialog)
|
a.completions = updated.(dialog.CompletionDialog)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
@ -194,7 +218,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
if a.showCompletionDialog {
|
if a.showCompletionDialog {
|
||||||
switch keyString {
|
switch keyString {
|
||||||
case "tab", "enter", "esc", "ctrl+c":
|
case "tab", "enter", "esc", "ctrl+c", "up", "down":
|
||||||
updated, cmd := a.completions.Update(msg)
|
updated, cmd := a.completions.Update(msg)
|
||||||
a.completions = updated.(dialog.CompletionDialog)
|
a.completions = updated.(dialog.CompletionDialog)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
@ -326,10 +350,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return a, toast.NewErrorToast(msg.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)
|
a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
case dialog.CompletionDialogCloseMsg:
|
case dialog.CompletionDialogCloseMsg:
|
||||||
a.showCompletionDialog = false
|
a.showCompletionDialog = false
|
||||||
|
a.fileCompletionActive = false
|
||||||
case opencode.EventListResponseEventInstallationUpdated:
|
case opencode.EventListResponseEventInstallationUpdated:
|
||||||
return a, toast.NewSuccessToast(
|
return a, toast.NewSuccessToast(
|
||||||
"opencode updated to "+msg.Properties.Version+", restart to apply.",
|
"opencode updated to "+msg.Properties.Version+", restart to apply.",
|
||||||
|
@ -778,11 +803,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
os.Remove(tmpfile.Name())
|
os.Remove(tmpfile.Name())
|
||||||
// attachments := m.attachments
|
|
||||||
// m.attachments = nil
|
|
||||||
return app.SendMsg{
|
return app.SendMsg{
|
||||||
Text: string(content),
|
Text: string(content),
|
||||||
Attachments: []app.Attachment{}, // attachments,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
@ -954,6 +976,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
|
||||||
|
|
||||||
func NewModel(app *app.App) tea.Model {
|
func NewModel(app *app.App) tea.Model {
|
||||||
commandProvider := completions.NewCommandCompletionProvider(app)
|
commandProvider := completions.NewCommandCompletionProvider(app)
|
||||||
|
fileProvider := completions.NewFileAndFolderContextGroup(app)
|
||||||
|
|
||||||
messages := chat.NewMessagesComponent(app)
|
messages := chat.NewMessagesComponent(app)
|
||||||
editor := chat.NewEditorComponent(app)
|
editor := chat.NewEditorComponent(app)
|
||||||
|
@ -972,9 +995,11 @@ func NewModel(app *app.App) tea.Model {
|
||||||
messages: messages,
|
messages: messages,
|
||||||
completions: completions,
|
completions: completions,
|
||||||
commandProvider: commandProvider,
|
commandProvider: commandProvider,
|
||||||
|
fileProvider: fileProvider,
|
||||||
leaderBinding: leaderBinding,
|
leaderBinding: leaderBinding,
|
||||||
isLeaderSequence: false,
|
isLeaderSequence: false,
|
||||||
showCompletionDialog: false,
|
showCompletionDialog: false,
|
||||||
|
fileCompletionActive: false,
|
||||||
toastManager: toast.NewToastManager(),
|
toastManager: toast.NewToastManager(),
|
||||||
interruptKeyState: InterruptKeyIdle,
|
interruptKeyState: InterruptKeyIdle,
|
||||||
fileViewer: fileviewer.New(app),
|
fileViewer: fileviewer.New(app),
|
||||||
|
|
|
@ -83,7 +83,7 @@ func Extension(path string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
|
func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
|
||||||
r := styles.GetMarkdownRenderer(width-7, backgroundColor)
|
r := styles.GetMarkdownRenderer(width-6, backgroundColor)
|
||||||
content = strings.ReplaceAll(content, RootPath+"/", "")
|
content = strings.ReplaceAll(content, RootPath+"/", "")
|
||||||
rendered, _ := r.Render(content)
|
rendered, _ := r.Render(content)
|
||||||
lines := strings.Split(rendered, "\n")
|
lines := strings.Split(rendered, "\n")
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
"lang-map": "0.4.0",
|
"lang-map": "0.4.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "15.0.12",
|
"marked": "15.0.12",
|
||||||
|
"marked-shiki": "1.2.0",
|
||||||
"rehype-autolink-headings": "7.1.0",
|
"rehype-autolink-headings": "7.1.0",
|
||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"shiki": "3.4.2",
|
"shiki": "3.4.2",
|
||||||
|
|
|
@ -36,6 +36,10 @@ if (isDocs) {
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
|
{ slug === "" && (
|
||||||
|
<title>{title} | AI coding agent built for the terminal</title>
|
||||||
|
)}
|
||||||
|
|
||||||
<Default {...Astro.props}><slot /></Default>
|
<Default {...Astro.props}><slot /></Default>
|
||||||
|
|
||||||
{ (isDocs || !slug.startsWith("s")) && (
|
{ (isDocs || !slug.startsWith("s")) && (
|
||||||
|
|
|
@ -1,21 +1,39 @@
|
||||||
import { type JSX, splitProps, createResource } from "solid-js"
|
import { type JSX, splitProps, createResource } from "solid-js"
|
||||||
import { marked } from "marked"
|
import { marked } from "marked"
|
||||||
|
import markedShiki from "marked-shiki"
|
||||||
|
import { codeToHtml } from "shiki"
|
||||||
|
import { transformerNotationDiff } from "@shikijs/transformers"
|
||||||
import styles from "./markdownview.module.css"
|
import styles from "./markdownview.module.css"
|
||||||
|
|
||||||
interface MarkdownViewProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
interface MarkdownViewProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||||
markdown: string
|
markdown: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markedWithShiki = marked.use(
|
||||||
|
markedShiki({
|
||||||
|
highlight(code, lang) {
|
||||||
|
return codeToHtml(code, {
|
||||||
|
lang: lang || "text",
|
||||||
|
themes: {
|
||||||
|
light: "github-light",
|
||||||
|
dark: "github-dark",
|
||||||
|
},
|
||||||
|
transformers: [transformerNotationDiff()],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
function MarkdownView(props: MarkdownViewProps) {
|
function MarkdownView(props: MarkdownViewProps) {
|
||||||
const [local, rest] = splitProps(props, ["markdown"])
|
const [local, rest] = splitProps(props, ["markdown"])
|
||||||
const [html] = createResource(() => local.markdown, async (markdown) => {
|
const [html] = createResource(
|
||||||
return marked.parse(markdown)
|
() => local.markdown,
|
||||||
})
|
async (markdown) => {
|
||||||
|
return markedWithShiki.parse(markdown)
|
||||||
return (
|
},
|
||||||
<div innerHTML={html()} class={styles["markdown-body"]} {...rest} />
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return <div innerHTML={html()} class={styles["markdown-body"]} {...rest} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MarkdownView
|
export default MarkdownView
|
||||||
|
|
||||||
|
|
|
@ -294,15 +294,11 @@ function ResultsButton(props: ResultsButtonProps) {
|
||||||
interface TextPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
interface TextPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||||
text: string
|
text: string
|
||||||
expand?: boolean
|
expand?: boolean
|
||||||
invert?: boolean
|
|
||||||
highlight?: boolean
|
|
||||||
}
|
}
|
||||||
function TextPart(props: TextPartProps) {
|
function TextPart(props: TextPartProps) {
|
||||||
const [local, rest] = splitProps(props, [
|
const [local, rest] = splitProps(props, [
|
||||||
"text",
|
"text",
|
||||||
"expand",
|
"expand",
|
||||||
"invert",
|
|
||||||
"highlight",
|
|
||||||
])
|
])
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const [overflowed, setOverflowed] = createSignal(false)
|
const [overflowed, setOverflowed] = createSignal(false)
|
||||||
|
@ -321,6 +317,7 @@ function TextPart(props: TextPartProps) {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
local.text
|
local.text
|
||||||
|
local.expand
|
||||||
setTimeout(checkOverflow, 0)
|
setTimeout(checkOverflow, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -331,8 +328,6 @@ function TextPart(props: TextPartProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={styles["message-text"]}
|
class={styles["message-text"]}
|
||||||
data-invert={local.invert}
|
|
||||||
data-highlight={local.highlight}
|
|
||||||
data-expanded={expanded() || local.expand === true}
|
data-expanded={expanded() || local.expand === true}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
|
@ -372,6 +367,7 @@ function ErrorPart(props: ErrorPartProps) {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
local.children
|
local.children
|
||||||
|
local.expand
|
||||||
setTimeout(checkOverflow, 0)
|
setTimeout(checkOverflow, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -425,6 +421,7 @@ function MarkdownPart(props: MarkdownPartProps) {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
local.text
|
local.text
|
||||||
|
local.expand
|
||||||
setTimeout(checkOverflow, 0)
|
setTimeout(checkOverflow, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -486,6 +483,14 @@ function TerminalPart(props: TerminalPartProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
local.command
|
||||||
|
local.result
|
||||||
|
local.error
|
||||||
|
local.expand
|
||||||
|
setTimeout(checkOverflow, 0)
|
||||||
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
checkOverflow()
|
checkOverflow()
|
||||||
window.addEventListener("resize", checkOverflow)
|
window.addEventListener("resize", checkOverflow)
|
||||||
|
@ -596,7 +601,10 @@ export default function Share(props: {
|
||||||
messages: Record<string, Message.Info>
|
messages: Record<string, Message.Info>
|
||||||
}) {
|
}) {
|
||||||
let lastScrollY = 0
|
let lastScrollY = 0
|
||||||
|
let hasScrolledToAnchor = false
|
||||||
let scrollTimeout: number | undefined
|
let scrollTimeout: number | undefined
|
||||||
|
let scrollSentinel: HTMLElement | undefined
|
||||||
|
let scrollObserver: IntersectionObserver | undefined
|
||||||
|
|
||||||
const id = props.id
|
const id = props.id
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
@ -604,6 +612,7 @@ export default function Share(props: {
|
||||||
|
|
||||||
const [showScrollButton, setShowScrollButton] = createSignal(false)
|
const [showScrollButton, setShowScrollButton] = createSignal(false)
|
||||||
const [isButtonHovered, setIsButtonHovered] = createSignal(false)
|
const [isButtonHovered, setIsButtonHovered] = createSignal(false)
|
||||||
|
const [isNearBottom, setIsNearBottom] = createSignal(false)
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
info?: Session.Info
|
info?: Session.Info
|
||||||
|
@ -713,10 +722,9 @@ export default function Share(props: {
|
||||||
const currentScrollY = window.scrollY
|
const currentScrollY = window.scrollY
|
||||||
const isScrollingDown = currentScrollY > lastScrollY
|
const isScrollingDown = currentScrollY > lastScrollY
|
||||||
const scrolled = currentScrollY > 200 // Show after scrolling 200px
|
const scrolled = currentScrollY > 200 // Show after scrolling 200px
|
||||||
const isNearBottom = window.innerHeight + currentScrollY >= document.body.scrollHeight - 100
|
|
||||||
|
|
||||||
// Only show when scrolling down, scrolled enough, and not near bottom
|
// Only show when scrolling down, scrolled enough, and not near bottom
|
||||||
const shouldShow = isScrollingDown && scrolled && !isNearBottom
|
const shouldShow = isScrollingDown && scrolled && !isNearBottom()
|
||||||
|
|
||||||
// Update last scroll position
|
// Update last scroll position
|
||||||
lastScrollY = currentScrollY
|
lastScrollY = currentScrollY
|
||||||
|
@ -732,7 +740,7 @@ export default function Share(props: {
|
||||||
if (!isButtonHovered()) {
|
if (!isButtonHovered()) {
|
||||||
setShowScrollButton(false)
|
setShowScrollButton(false)
|
||||||
}
|
}
|
||||||
}, 3000)
|
}, 1500)
|
||||||
} else if (!isButtonHovered()) {
|
} else if (!isButtonHovered()) {
|
||||||
// Only hide if not hovered (to prevent disappearing while user is about to click)
|
// Only hide if not hovered (to prevent disappearing while user is about to click)
|
||||||
setShowScrollButton(false)
|
setShowScrollButton(false)
|
||||||
|
@ -744,6 +752,26 @@ export default function Share(props: {
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
lastScrollY = window.scrollY // Initialize scroll position
|
lastScrollY = window.scrollY // Initialize scroll position
|
||||||
|
|
||||||
|
// Create sentinel element
|
||||||
|
const sentinel = document.createElement("div")
|
||||||
|
sentinel.style.height = "1px"
|
||||||
|
sentinel.style.position = "absolute"
|
||||||
|
sentinel.style.bottom = "100px"
|
||||||
|
sentinel.style.width = "100%"
|
||||||
|
sentinel.style.pointerEvents = "none"
|
||||||
|
document.body.appendChild(sentinel)
|
||||||
|
|
||||||
|
// Create intersection observer
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
setIsNearBottom(entries[0].isIntersecting)
|
||||||
|
})
|
||||||
|
observer.observe(sentinel)
|
||||||
|
|
||||||
|
// Store references for cleanup
|
||||||
|
scrollSentinel = sentinel
|
||||||
|
scrollObserver = observer
|
||||||
|
|
||||||
checkScrollNeed()
|
checkScrollNeed()
|
||||||
window.addEventListener("scroll", checkScrollNeed)
|
window.addEventListener("scroll", checkScrollNeed)
|
||||||
window.addEventListener("resize", checkScrollNeed)
|
window.addEventListener("resize", checkScrollNeed)
|
||||||
|
@ -752,6 +780,15 @@ export default function Share(props: {
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
window.removeEventListener("scroll", checkScrollNeed)
|
window.removeEventListener("scroll", checkScrollNeed)
|
||||||
window.removeEventListener("resize", checkScrollNeed)
|
window.removeEventListener("resize", checkScrollNeed)
|
||||||
|
|
||||||
|
// Clean up observer and sentinel
|
||||||
|
if (scrollObserver) {
|
||||||
|
scrollObserver.disconnect()
|
||||||
|
}
|
||||||
|
if (scrollSentinel) {
|
||||||
|
document.body.removeChild(scrollSentinel)
|
||||||
|
}
|
||||||
|
|
||||||
if (scrollTimeout) {
|
if (scrollTimeout) {
|
||||||
clearTimeout(scrollTimeout)
|
clearTimeout(scrollTimeout)
|
||||||
}
|
}
|
||||||
|
@ -855,7 +892,6 @@ export default function Share(props: {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -865,7 +901,7 @@ export default function Share(props: {
|
||||||
fallback={<p>Waiting for messages...</p>}
|
fallback={<p>Waiting for messages...</p>}
|
||||||
>
|
>
|
||||||
<div class={styles.parts}>
|
<div class={styles.parts}>
|
||||||
<SuspenseList>
|
<SuspenseList revealOrder="forwards">
|
||||||
<For each={data().messages}>
|
<For each={data().messages}>
|
||||||
{(msg, msgIndex) => (
|
{(msg, msgIndex) => (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
@ -880,8 +916,11 @@ export default function Share(props: {
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
const anchor = createMemo(() => `${msg.id}-${partIndex()}`)
|
const anchor = createMemo(
|
||||||
const [showResults, setShowResults] = createSignal(false)
|
() => `${msg.id}-${partIndex()}`,
|
||||||
|
)
|
||||||
|
const [showResults, setShowResults] =
|
||||||
|
createSignal(false)
|
||||||
const isLastPart = createMemo(
|
const isLastPart = createMemo(
|
||||||
() =>
|
() =>
|
||||||
data().messages.length === msgIndex() + 1 &&
|
data().messages.length === msgIndex() + 1 &&
|
||||||
|
@ -903,7 +942,9 @@ export default function Share(props: {
|
||||||
const duration = DateTime.fromMillis(
|
const duration = DateTime.fromMillis(
|
||||||
metadata?.time.end || 0,
|
metadata?.time.end || 0,
|
||||||
)
|
)
|
||||||
.diff(DateTime.fromMillis(metadata?.time.start || 0))
|
.diff(
|
||||||
|
DateTime.fromMillis(metadata?.time.start || 0),
|
||||||
|
)
|
||||||
.toMillis()
|
.toMillis()
|
||||||
|
|
||||||
return { metadata, args, result, duration }
|
return { metadata, args, result, duration }
|
||||||
|
@ -911,7 +952,14 @@ export default function Share(props: {
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const hash = window.location.hash.slice(1)
|
const hash = window.location.hash.slice(1)
|
||||||
if (hash !== "" && hash === anchor()) {
|
// Wait till all parts are loaded
|
||||||
|
if (
|
||||||
|
hash !== ""
|
||||||
|
&& !hasScrolledToAnchor
|
||||||
|
&& msg.parts.length === partIndex() + 1
|
||||||
|
&& data().messages.length === msgIndex() + 1
|
||||||
|
) {
|
||||||
|
hasScrolledToAnchor = true
|
||||||
scrollToAnchor(hash)
|
scrollToAnchor(hash)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -921,7 +969,9 @@ export default function Share(props: {
|
||||||
{/* User text */}
|
{/* User text */}
|
||||||
<Match
|
<Match
|
||||||
when={
|
when={
|
||||||
msg.role === "user" && part.type === "text" && part
|
msg.role === "user" &&
|
||||||
|
part.type === "text" &&
|
||||||
|
part
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(part) => (
|
{(part) => (
|
||||||
|
@ -938,9 +988,9 @@ export default function Share(props: {
|
||||||
</div>
|
</div>
|
||||||
<div data-section="content">
|
<div data-section="content">
|
||||||
<TextPart
|
<TextPart
|
||||||
invert
|
|
||||||
text={part().text}
|
text={part().text}
|
||||||
expand={isLastPart()}
|
expand={isLastPart()}
|
||||||
|
data-background="blue"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -968,11 +1018,12 @@ export default function Share(props: {
|
||||||
</div>
|
</div>
|
||||||
<div data-section="content">
|
<div data-section="content">
|
||||||
<MarkdownPart
|
<MarkdownPart
|
||||||
highlight
|
|
||||||
expand={isLastPart()}
|
expand={isLastPart()}
|
||||||
text={stripEnclosingTag(part().text)}
|
text={stripEnclosingTag(part().text)}
|
||||||
/>
|
/>
|
||||||
<Show when={isLastPart() && data().completed}>
|
<Show
|
||||||
|
when={isLastPart() && data().completed}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
data-part-footer
|
data-part-footer
|
||||||
title={DateTime.fromMillis(
|
title={DateTime.fromMillis(
|
||||||
|
@ -1041,7 +1092,8 @@ export default function Share(props: {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(_part) => {
|
{(_part) => {
|
||||||
const matches = () => toolData()?.metadata?.matches
|
const matches = () =>
|
||||||
|
toolData()?.metadata?.matches
|
||||||
const splitArgs = () => {
|
const splitArgs = () => {
|
||||||
const { pattern, ...rest } = toolData()?.args
|
const { pattern, ...rest } = toolData()?.args
|
||||||
return { pattern, rest }
|
return { pattern, rest }
|
||||||
|
@ -1066,11 +1118,14 @@ export default function Share(props: {
|
||||||
<div data-part-tool-body>
|
<div data-part-tool-body>
|
||||||
<div data-part-title>
|
<div data-part-title>
|
||||||
<span data-element-label>Grep</span>
|
<span data-element-label>Grep</span>
|
||||||
<b>“{splitArgs().pattern}”</b>
|
<b>
|
||||||
|
“{splitArgs().pattern}”
|
||||||
|
</b>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
Object.keys(splitArgs().rest).length > 0
|
Object.keys(splitArgs().rest)
|
||||||
|
.length > 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div data-part-tool-args>
|
<div data-part-tool-args>
|
||||||
|
@ -1299,8 +1354,10 @@ export default function Share(props: {
|
||||||
data().rootDir,
|
data().rootDir,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const hasError = () => toolData()?.metadata?.error
|
const hasError = () =>
|
||||||
const preview = () => toolData()?.metadata?.preview
|
toolData()?.metadata?.error
|
||||||
|
const preview = () =>
|
||||||
|
toolData()?.metadata?.preview
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -1333,7 +1390,9 @@ export default function Share(props: {
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
{/* Always try to show CodeBlock if preview is available (even if empty string) */}
|
{/* Always try to show CodeBlock if preview is available (even if empty string) */}
|
||||||
<Match when={typeof preview() === 'string'}>
|
<Match
|
||||||
|
when={typeof preview() === "string"}
|
||||||
|
>
|
||||||
<div data-part-tool-result>
|
<div data-part-tool-result>
|
||||||
<ResultsButton
|
<ResultsButton
|
||||||
showCopy="Show preview"
|
showCopy="Show preview"
|
||||||
|
@ -1346,7 +1405,9 @@ export default function Share(props: {
|
||||||
<Show when={showResults()}>
|
<Show when={showResults()}>
|
||||||
<div data-part-tool-code>
|
<div data-part-tool-code>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
lang={getShikiLang(filePath())}
|
lang={getShikiLang(
|
||||||
|
filePath(),
|
||||||
|
)}
|
||||||
code={preview()}
|
code={preview()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1354,7 +1415,12 @@ export default function Share(props: {
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
{/* Fallback to TextPart if preview is not a string (e.g. undefined) AND result exists */}
|
{/* Fallback to TextPart if preview is not a string (e.g. undefined) AND result exists */}
|
||||||
<Match when={typeof preview() !== 'string' && toolData()?.result}>
|
<Match
|
||||||
|
when={
|
||||||
|
typeof preview() !== "string" &&
|
||||||
|
toolData()?.result
|
||||||
|
}
|
||||||
|
>
|
||||||
<div data-part-tool-result>
|
<div data-part-tool-result>
|
||||||
<ResultsButton
|
<ResultsButton
|
||||||
results={showResults()}
|
results={showResults()}
|
||||||
|
@ -1398,7 +1464,8 @@ export default function Share(props: {
|
||||||
data().rootDir,
|
data().rootDir,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const hasError = () => toolData()?.metadata?.error
|
const hasError = () =>
|
||||||
|
toolData()?.metadata?.error
|
||||||
const content = () => toolData()?.args?.content
|
const content = () => toolData()?.args?.content
|
||||||
const diagnostics = createMemo(() =>
|
const diagnostics = createMemo(() =>
|
||||||
getDiagnostics(
|
getDiagnostics(
|
||||||
|
@ -1415,7 +1482,10 @@ export default function Share(props: {
|
||||||
>
|
>
|
||||||
<div data-section="decoration">
|
<div data-section="decoration">
|
||||||
<AnchorIcon id={anchor()}>
|
<AnchorIcon id={anchor()}>
|
||||||
<IconDocumentPlus width={18} height={18} />
|
<IconDocumentPlus
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
/>
|
||||||
</AnchorIcon>
|
</AnchorIcon>
|
||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1435,7 +1505,7 @@ export default function Share(props: {
|
||||||
<div data-part-tool-result>
|
<div data-part-tool-result>
|
||||||
<ErrorPart>
|
<ErrorPart>
|
||||||
{formatErrorString(
|
{formatErrorString(
|
||||||
toolData()?.result
|
toolData()?.result,
|
||||||
)}
|
)}
|
||||||
</ErrorPart>
|
</ErrorPart>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1453,8 +1523,12 @@ export default function Share(props: {
|
||||||
<Show when={showResults()}>
|
<Show when={showResults()}>
|
||||||
<div data-part-tool-code>
|
<div data-part-tool-code>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
lang={getShikiLang(filePath())}
|
lang={getShikiLang(
|
||||||
code={toolData()?.args?.content}
|
filePath(),
|
||||||
|
)}
|
||||||
|
code={
|
||||||
|
toolData()?.args?.content
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -1481,8 +1555,10 @@ export default function Share(props: {
|
||||||
>
|
>
|
||||||
{(_part) => {
|
{(_part) => {
|
||||||
const diff = () => toolData()?.metadata?.diff
|
const diff = () => toolData()?.metadata?.diff
|
||||||
const message = () => toolData()?.metadata?.message
|
const message = () =>
|
||||||
const hasError = () => toolData()?.metadata?.error
|
toolData()?.metadata?.message
|
||||||
|
const hasError = () =>
|
||||||
|
toolData()?.metadata?.error
|
||||||
const filePath = createMemo(() =>
|
const filePath = createMemo(() =>
|
||||||
stripWorkingDirectory(
|
stripWorkingDirectory(
|
||||||
toolData()?.args.filePath,
|
toolData()?.args.filePath,
|
||||||
|
@ -1504,7 +1580,10 @@ export default function Share(props: {
|
||||||
>
|
>
|
||||||
<div data-section="decoration">
|
<div data-section="decoration">
|
||||||
<AnchorIcon id={anchor()}>
|
<AnchorIcon id={anchor()}>
|
||||||
<IconPencilSquare width={18} height={18} />
|
<IconPencilSquare
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
/>
|
||||||
</AnchorIcon>
|
</AnchorIcon>
|
||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1527,7 +1606,9 @@ export default function Share(props: {
|
||||||
<Match when={diff()}>
|
<Match when={diff()}>
|
||||||
<div data-part-tool-edit>
|
<div data-part-tool-edit>
|
||||||
<DiffView
|
<DiffView
|
||||||
class={styles["diff-code-block"]}
|
class={
|
||||||
|
styles["diff-code-block"]
|
||||||
|
}
|
||||||
diff={diff()}
|
diff={diff()}
|
||||||
lang={getShikiLang(filePath())}
|
lang={getShikiLang(filePath())}
|
||||||
/>
|
/>
|
||||||
|
@ -1556,9 +1637,12 @@ export default function Share(props: {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(_part) => {
|
{(_part) => {
|
||||||
const command = () => toolData()?.metadata?.title
|
const command = () =>
|
||||||
const desc = () => toolData()?.metadata?.description
|
toolData()?.metadata?.title
|
||||||
const result = () => toolData()?.metadata?.stdout
|
const desc = () =>
|
||||||
|
toolData()?.metadata?.description
|
||||||
|
const result = () =>
|
||||||
|
toolData()?.metadata?.stdout
|
||||||
const error = () => toolData()?.metadata?.stderr
|
const error = () => toolData()?.metadata?.stderr
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1569,7 +1653,10 @@ export default function Share(props: {
|
||||||
>
|
>
|
||||||
<div data-section="decoration">
|
<div data-section="decoration">
|
||||||
<AnchorIcon id={anchor()}>
|
<AnchorIcon id={anchor()}>
|
||||||
<IconCommandLine width={18} height={18} />
|
<IconCommandLine
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
/>
|
||||||
</AnchorIcon>
|
</AnchorIcon>
|
||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1604,7 +1691,9 @@ 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")
|
||||||
|
@ -1670,7 +1759,8 @@ export default function Share(props: {
|
||||||
{(_part) => {
|
{(_part) => {
|
||||||
const url = () => toolData()?.args.url
|
const url = () => toolData()?.args.url
|
||||||
const format = () => toolData()?.args.format
|
const format = () => toolData()?.args.format
|
||||||
const hasError = () => toolData()?.metadata?.error
|
const hasError = () =>
|
||||||
|
toolData()?.metadata?.error
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -1793,7 +1883,8 @@ export default function Share(props: {
|
||||||
</Match>
|
</Match>
|
||||||
<Match
|
<Match
|
||||||
when={
|
when={
|
||||||
part().toolInvocation.state === "call"
|
part().toolInvocation.state ===
|
||||||
|
"call"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TextPart
|
<TextPart
|
||||||
|
@ -1839,7 +1930,10 @@ export default function Share(props: {
|
||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
<Match when={msg.role === "user"}>
|
<Match when={msg.role === "user"}>
|
||||||
<IconUserCircle width={18} height={18} />
|
<IconUserCircle
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</AnchorIcon>
|
</AnchorIcon>
|
||||||
|
@ -1848,7 +1942,9 @@ export default function Share(props: {
|
||||||
<div data-section="content">
|
<div data-section="content">
|
||||||
<div data-part-tool-body>
|
<div data-part-tool-body>
|
||||||
<div data-part-title>
|
<div data-part-title>
|
||||||
<span data-element-label>{part.type}</span>
|
<span data-element-label>
|
||||||
|
{part.type}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<TextPart
|
<TextPart
|
||||||
text={JSON.stringify(part, null, 2)}
|
text={JSON.stringify(part, null, 2)}
|
||||||
|
|
|
@ -40,11 +40,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
white-space: pre-wrap;
|
--shiki-dark-bg: var(--sl-color-bg-surface) !important;
|
||||||
border-radius: 0.25rem;
|
background-color: var(--sl-color-bg-surface) !important;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
line-height: 1.6;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
span {
|
||||||
|
white-space: break-spaces;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
|
@ -61,4 +67,40 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid var(--sl-color-border);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
border-bottom: 1px solid var(--sl-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove outer borders */
|
||||||
|
table tr:first-child th,
|
||||||
|
table tr:first-child td {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th:first-child,
|
||||||
|
table td:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th:last-child,
|
||||||
|
table td:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -493,9 +493,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-highlight="true"] {
|
&[data-background="none"] { background-color: transparent; }
|
||||||
background-color: var(--sl-color-blue-low);
|
&[data-background="blue"] { background-color: var(--sl-color-blue-low); }
|
||||||
}
|
|
||||||
|
|
||||||
&[data-expanded="true"] {
|
&[data-expanded="true"] {
|
||||||
pre {
|
pre {
|
||||||
|
@ -669,7 +668,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-markdown {
|
.message-markdown {
|
||||||
background-color: var(--sl-color-bg-surface);
|
border: 1px solid var(--sl-color-blue-high);
|
||||||
padding: 0.5rem calc(0.5rem + 3px);
|
padding: 0.5rem calc(0.5rem + 3px);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -30,6 +30,7 @@ Add a local MCP servers under `mcp.localmcp`.
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"environment": {
|
"environment": {
|
||||||
"MY_ENV_VAR": "my_env_var_value"
|
"MY_ENV_VAR": "my_env_var_value"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,3 +73,29 @@ So when opencode starts, it looks for:
|
||||||
2. **Global file** by checking `~/.config/opencode/AGENTS.md`
|
2. **Global file** by checking `~/.config/opencode/AGENTS.md`
|
||||||
|
|
||||||
If you have both global and project-specific rules, opencode will combine them together.
|
If you have both global and project-specific rules, opencode will combine them together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom Instructions
|
||||||
|
|
||||||
|
You can also specify custom instruction files using the `instructions` configuration in your `opencode.json` or global `~/.config/opencode/config.json`:
|
||||||
|
|
||||||
|
```json title="opencode.json"
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"instructions": [".cursor/rules/*.md"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can specify multiple files like `CONTRIBUTING.md` and `docs/guidelines.md`, and use glob patterns to match multiple files.
|
||||||
|
|
||||||
|
For example, to reuse your existing Cursor rules:
|
||||||
|
|
||||||
|
```json title="opencode.json"
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"instructions": [".cursor/rules/*.md"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All instruction files are combined with your `AGENTS.md` files.
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue