mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-01 12:14:25 +00:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7476bc0e6 | ||
|
|
1bd494b8b3 | ||
|
|
712956af9f | ||
|
|
2622303c6b | ||
|
|
dc01e46b26 | ||
|
|
81b66f5eae | ||
|
|
f3d7a109cb | ||
|
|
13cdc21859 | ||
|
|
7b77b76c29 | ||
|
|
c00759f830 | ||
|
|
46c8568a20 | ||
|
|
0f37d2b858 | ||
|
|
3c3c0386c7 | ||
|
|
afb37d5d53 | ||
|
|
044134d646 | ||
|
|
fe0f298293 | ||
|
|
29d90056e9 | ||
|
|
276d60e82a | ||
|
|
9ea36ccd9d | ||
|
|
03c8efe9d0 | ||
|
|
3e6b5ceccc | ||
|
|
90e0a66496 | ||
|
|
efec7bdce6 | ||
|
|
b9ca79f3b6 | ||
|
|
40faadf1c6 | ||
|
|
9c0499137d | ||
|
|
e39cbc0e5b | ||
|
|
323e7a36da | ||
|
|
031d872c8a | ||
|
|
9faaa6130d | ||
|
|
14acf269aa | ||
|
|
4051bb0b50 | ||
|
|
3cde99f65e | ||
|
|
bd545f6f7a | ||
|
|
fa4bd00f54 | ||
|
|
903bdd4066 | ||
|
|
ab44597018 | ||
|
|
1d0d427b5f | ||
|
|
aec95c4d10 | ||
|
|
b2c82cb897 | ||
|
|
12dfd7e6a8 | ||
|
|
20905212f9 | ||
|
|
76cda30896 | ||
|
|
4f740306f0 | ||
|
|
c7e9851826 | ||
|
|
bf53e1c24b | ||
|
|
50004d1f94 | ||
|
|
acd7c5ad55 | ||
|
|
cf54b544e3 | ||
|
|
52b42258fa | ||
|
|
3026a005b6 | ||
|
|
a6f802d7fe | ||
|
|
9ef803be82 | ||
|
|
ce5c827a6e | ||
|
|
56decd79db | ||
|
|
fc258ea74f | ||
|
|
abd9e195ac | ||
|
|
9d78b69cd3 | ||
|
|
e31f00ad22 | ||
|
|
70b555472e | ||
|
|
e514919cc4 | ||
|
|
a90e8de050 | ||
|
|
ba5121ce0b | ||
|
|
eabf770053 | ||
|
|
86d7bdc542 | ||
|
|
d3ab78bba0 | ||
|
|
a531f3f36d | ||
|
|
bb3382311d | ||
|
|
ad545d0cc9 | ||
|
|
ac244b1458 | ||
|
|
f202536b65 | ||
|
|
405cc3f610 | ||
|
|
878c1b8c2d | ||
|
|
d8bcfd90d3 | ||
|
|
954d31903f | ||
|
|
1587d93b29 | ||
|
|
d364c43916 | ||
|
|
72eec20437 | ||
|
|
4503bde1cc | ||
|
|
1abc228e95 | ||
|
|
991e823039 | ||
|
|
62fa5c1314 | ||
|
|
93b9e47c05 | ||
|
|
bb4d978684 |
@@ -111,3 +111,7 @@ const table = sqliteTable("session", {
|
||||
- Avoid mocks as much as possible
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
## Type Checking
|
||||
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
|
||||
62
bun.lock
62
bun.lock
@@ -304,8 +304,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.81",
|
||||
"@opentui/solid": "0.1.81",
|
||||
"@opentui/core": "0.1.84",
|
||||
"@opentui/solid": "0.1.84",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -342,6 +342,7 @@
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
@@ -364,6 +365,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||
@@ -423,17 +425,17 @@
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@storybook/addon-a11y": "^10.2.10",
|
||||
"@storybook/addon-docs": "^10.2.10",
|
||||
"@storybook/addon-links": "^10.2.10",
|
||||
"@storybook/addon-onboarding": "^10.2.10",
|
||||
"@storybook/addon-vitest": "^10.2.10",
|
||||
"@storybook/addon-a11y": "^10.2.13",
|
||||
"@storybook/addon-docs": "^10.2.13",
|
||||
"@storybook/addon-links": "^10.2.13",
|
||||
"@storybook/addon-onboarding": "^10.2.13",
|
||||
"@storybook/addon-vitest": "^10.2.13",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "18.0.25",
|
||||
"react": "18.2.0",
|
||||
"solid-js": "catalog:",
|
||||
"storybook": "^10.2.10",
|
||||
"storybook": "^10.2.13",
|
||||
"storybook-solidjs-vite": "^10.0.9",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
@@ -1341,21 +1343,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.81", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.81", "@opentui/core-darwin-x64": "0.1.81", "@opentui/core-linux-arm64": "0.1.81", "@opentui/core-linux-x64": "0.1.81", "@opentui/core-win32-arm64": "0.1.81", "@opentui/core-win32-x64": "0.1.81", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ooFjkkQ80DDC4X5eLvH8dBcLAtWwGp9RTaWsaeWet3GOv4N0SDcN8mi1XGhYnUlTuxmofby5eQrPegjtWHODlA=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.84", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.84", "@opentui/core-darwin-x64": "0.1.84", "@opentui/core-linux-arm64": "0.1.84", "@opentui/core-linux-x64": "0.1.84", "@opentui/core-win32-arm64": "0.1.84", "@opentui/core-win32-x64": "0.1.84", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-UdPD/sldUSiIu588l45lQq6q09zLW8L4GOgkA4E3fyExIlIgOrpIhFSKqZwbVCbe53dQOybHVUdKob64Xxhm4w=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.81", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I3Ry5JbkSQXs2g1me8yYr0v3CUcIIfLHzbWz9WMFla8kQDSa+HOr8IpZbqZDeIFgOVzolAXBmZhg0VJI3bZ7MA=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.84", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MpLdRYd2zLJP7IcWHu1SYXFet6ZfOzTUW12iaexlUL4DkAU+P5mO1v/OdLlZrmXVj/FD3BqDM5SUIXNE0+A46Q=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.81", "", { "os": "darwin", "cpu": "x64" }, "sha512-CrtNKu41D6+bOQdUOmDX4Q3hTL6p+sT55wugPzbDq7cdqFZabCeguBAyOlvRl2g2aJ93kmOWW6MXG0bPPklEFg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.84", "", { "os": "darwin", "cpu": "x64" }, "sha512-eEFzEdo/WVVjbpwY2pnQBxkpqsWyGLHNv3kFc0RHvZ3ARIW2qb/Jqo9O9uUbR6th7H77V1NRJ0a6gvby48Oofg=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.81", "", { "os": "linux", "cpu": "arm64" }, "sha512-FJw9zmJop9WiMvtT07nSrfBLPLqskxL6xfV3GNft0mSYV+C3hdJ0qkiczGSHUX/6V7fmouM84RWwmY53Rb6hYQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-AYfG9iLWavfVYZYbNfEZkCimqk8Uq8EzvHKwRkbP24XVZO7eALztZ6PWl2nJqDrw+rYBVSJg0uS6ON+M7NxyBA=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.81", "", { "os": "linux", "cpu": "x64" }, "sha512-Rj2AFIiuWI0BEMIvh/Jeuxty9Gp5ZhLuQU7ZHJJhojKo/mpBpMs9X+5kwZPZya/tyR8uVDAVyB6AOLkhdRW5lw=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-pLi3a3rqs1BMeCqj1GZm/Qi3zdwilQxaPEI/IeWc5qdzWInpGPHs3uadPJBAllEGRzvTmStMWI6iY6bdc98bng=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.81", "", { "os": "win32", "cpu": "arm64" }, "sha512-AiZB+mZ1cVr8plAPrPT98e3kw6D0OdOSe2CQYLgJRbfRlPqq3jl26lHPzDb3ZO2OR0oVGRPJvXraus939mvoiQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.84", "", { "os": "win32", "cpu": "arm64" }, "sha512-dcQruw9VyYACxukoB+iv8SskQxl5MMeY73uqvQ17dsnM2sfukT+c2MSgbgYImCKKixtlGXLcb5Weo36lQqI5AQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.81", "", { "os": "win32", "cpu": "x64" }, "sha512-l8R2Ni1CR4eHi3DTmSkEL/EjHAtOZ/sndYs3VVw+Ej2esL3Mf0W7qSO5S0YNBanz2VXZhbkmM6ERm9keH8RD3w=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.84", "", { "os": "win32", "cpu": "x64" }, "sha512-KiCaIVtVnZYQza+vAW8TDbUMxBgd2thvjNOIJ03NmjMiiRnfmO55wA3Zh9vNuQ5PJEYI0awzXYFAZ7kaMsDQxA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.81", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.81", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-QRjS0wPuIhBRdY8tpG3yprCM4ZnOxWWHTuaZ4hhia2wFZygf7Ome6EuZnLXmtuOQjkjCwu0if8Yik6toc6QylA=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.84", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.84", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-5koIQ9Nk5CU6/uVxjjWJ7jtFeSNkczbZoEzHLdUK/T2bqHSyuPSFBeliQ1hfOwrN2G5SKFqIB3U5+1EcHxDCCA=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -1803,25 +1805,25 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||
|
||||
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-1S9pDXgvbHhBStGarCvfJ3/rfcaiAcQHRhuM3Nk4WGSIYtC1LCSRuzYdDYU0aNRpdCbCrUA7kUCbqvIE3tH+3Q=="],
|
||||
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-zuR1n1xgWoieEnr6E5xdTR40BI61IBQahgmsRpTvqRffL3mxAs5aFoORDmA5pZWI2LE9URdMkY85h218ijuLiw=="],
|
||||
|
||||
"@storybook/addon-docs": ["@storybook/addon-docs@10.2.10", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.2.10", "@storybook/icons": "^2.0.1", "@storybook/react-dom-shim": "10.2.10", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-2wIYtdvZIzPbQ5194M5Igpy8faNbQ135nuO5ZaZ2VuttqGr+IJcGnDP42zYwbAsGs28G8ohpkbSgIzVyJWUhPQ=="],
|
||||
"@storybook/addon-docs": ["@storybook/addon-docs@10.2.13", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.2.13", "@storybook/icons": "^2.0.1", "@storybook/react-dom-shim": "10.2.13", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-puMxpJbt/CuodLIbKDxWrW1ZgADYomfNHWEKp2d2l2eJjp17rADx0h3PABuNbX+YHbJwYcDdqluSnQwMysFEOA=="],
|
||||
|
||||
"@storybook/addon-links": ["@storybook/addon-links@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.10" }, "optionalPeers": ["react"] }, "sha512-oo9Xx4/2OVJtptXKpqH4ySri7ZuBdiSOXlZVGejEfLa0Jeajlh/KIlREpGvzPPOqUVT7dSddWzBjJmJUyQC3ew=="],
|
||||
"@storybook/addon-links": ["@storybook/addon-links@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.13" }, "optionalPeers": ["react"] }, "sha512-8wnAomGiHaUpNIc+lOzmazTrebxa64z9rihIbM/Q59vkOImHQNkGp7KP/qNgJA4GPTFtu8+fLjX2qCoAQPM0jQ=="],
|
||||
|
||||
"@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.2.10", "", { "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-DkzZQTXHp99SpHMIQ5plbbHcs4EWVzWhLXlW+icA8sBlKo5Bwj540YcOApKbqB0m/OzWprsznwN7Kv4vfvHu4w=="],
|
||||
"@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.2.13", "", { "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-kw2GgIY67UR8YXKfuVS0k+mfWL1joNQHeSe5DlDL4+7qbgp9zfV6cRJ199BMdfRAQNMzQoxHgRUcAMAqs3Rkpw=="],
|
||||
|
||||
"@storybook/addon-vitest": ["@storybook/addon-vitest@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.2.10", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-U2oHw+Ar+Xd06wDTB74VlujhIIW89OHThpJjwgqgM6NWrOC/XLllJ53ILFDyREBkMwpBD7gJQIoQpLEcKBIEhw=="],
|
||||
"@storybook/addon-vitest": ["@storybook/addon-vitest@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.2.13", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-qQD3xzxc31cQHS0loF9enGWi5sgA6zBTbaJ0HuSUNGO81iwfLSALh8L/1vrD5NfN2vlBeUMTsgv3EkCuLfe9EQ=="],
|
||||
|
||||
"@storybook/builder-vite": ["@storybook/builder-vite@10.2.10", "", { "dependencies": { "@storybook/csf-plugin": "10.2.10", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.10", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-Wd6CYL7LvRRNiXMz977x9u/qMm7nmMw/7Dow2BybQo+Xbfy1KhVjIoZ/gOiG515zpojSozctNrJUbM0+jH1jwg=="],
|
||||
|
||||
"@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.10", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.10", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g=="],
|
||||
"@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.13", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.13", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-gUCR7PmyrWYj3dIJJgxOm25dcXFolPIUPmug3z90Aaon7YPXw3pUN+dNDx8KqDJqRK1WDIB4HaefgYZIm5V7iA=="],
|
||||
|
||||
"@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="],
|
||||
|
||||
"@storybook/icons": ["@storybook/icons@2.0.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg=="],
|
||||
|
||||
"@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.2.10", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.10" } }, "sha512-TmBrhyLHn8B8rvDHKk5uW5BqzO1M1T+fqFNWg88NIAJOoyX4Uc90FIJjDuN1OJmWKGwB5vLmPwaKBYsTe1yS+w=="],
|
||||
"@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.2.13", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.13" } }, "sha512-ZSduoB10qTI0V9z22qeULmQLsvTs8d/rtJi03qbVxpPiMRor86AmyAaBrfhGGmWBxWQZpOGQQm6yIT2YLoPs7w=="],
|
||||
|
||||
"@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
|
||||
|
||||
@@ -2035,6 +2037,8 @@
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
||||
"@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
|
||||
@@ -3013,7 +3017,7 @@
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
"isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||
|
||||
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
|
||||
|
||||
@@ -3897,7 +3901,7 @@
|
||||
|
||||
"stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="],
|
||||
|
||||
"storybook": ["storybook@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-N4U42qKgzMHS7DjqLz5bY4P7rnvJtYkWFCyKspZr3FhPUuy6CWOae3aYC2BjXkHrdug0Jyta6VxFTuB1tYUKhg=="],
|
||||
"storybook": ["storybook@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-heMfJjOfbHvL+wlCAwFZlSxcakyJ5yQDam6e9k2RRArB1veJhRnsjO6lO1hOXjJYrqxfHA/ldIugbBVlCDqfvQ=="],
|
||||
|
||||
"storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="],
|
||||
|
||||
@@ -4215,7 +4219,7 @@
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
"which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
@@ -4721,6 +4725,8 @@
|
||||
|
||||
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||
|
||||
"@storybook/builder-vite/@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.10", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.10", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g=="],
|
||||
|
||||
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
@@ -4815,6 +4821,8 @@
|
||||
|
||||
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
|
||||
@@ -5387,6 +5395,8 @@
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"editorconfig/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
|
||||
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { spawn } from "node:child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
|
||||
connected = true
|
||||
break
|
||||
} catch (e) {}
|
||||
await Bun.sleep(300)
|
||||
await sleep(300)
|
||||
} while (retry++ < 30)
|
||||
|
||||
if (!connected) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-2XLuizbG90QDUQL+1M90XxfVZxjkIQ1cFYS46nnVO7g=",
|
||||
"aarch64-linux": "sha256-hlckiGAtbpAlwgcE7KgzKKRq9T2FEOSq3Q1MhuHfZ2c=",
|
||||
"aarch64-darwin": "sha256-V/8Kay+5bDb/BSVgBQhSMwzmRmkNGl3U0HFMVbVcMak=",
|
||||
"x86_64-darwin": "sha256-duLDF88Q/hXK5jwBy4dVxMSiTTS0R4obp9MlTuOF/Pw="
|
||||
"x86_64-linux": "sha256-zBRw0PJxQPL2e+u23BkG5MYHeki0nF6Ti+kzPFXYsrI=",
|
||||
"aarch64-linux": "sha256-95mgnJwauU62Ofe4mah+BqRNgDWd0AEMjFNikRS+SMY=",
|
||||
"aarch64-darwin": "sha256-1OP2clDYn8os5E3po9mdIvZbUIAM2DmRzOs3S7D6Um8=",
|
||||
"x86_64-darwin": "sha256-VJjlFZ+EUTu1gSfdX9J4mCtTU3qTsZkKSqoAZA4RmwE="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
|
||||
50
packages/app/src/api/releases.ts
Normal file
50
packages/app/src/api/releases.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Platform } from "@/context/platform"
|
||||
|
||||
const REPO = "anomalyco/opencode"
|
||||
const GITHUB_API_URL = `https://api.github.com/repos/${REPO}/releases`
|
||||
const PER_PAGE = 30
|
||||
const CACHE_TTL = 1000 * 60 * 30
|
||||
const CACHE_KEY = "opencode.releases"
|
||||
|
||||
type Release = {
|
||||
tag: string
|
||||
body: string
|
||||
date: string
|
||||
}
|
||||
|
||||
function loadCache() {
|
||||
const raw = localStorage.getItem(CACHE_KEY)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}
|
||||
|
||||
function saveCache(data: { releases: Release[]; timestamp: number }) {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(data))
|
||||
}
|
||||
|
||||
export async function fetchReleases(platform: Platform): Promise<{ releases: Release[] }> {
|
||||
const now = Date.now()
|
||||
const cached = loadCache()
|
||||
|
||||
if (cached && now - cached.timestamp < CACHE_TTL) {
|
||||
return { releases: cached.releases }
|
||||
}
|
||||
|
||||
const fetcher = platform.fetch ?? fetch
|
||||
const res = await fetcher(`${GITHUB_API_URL}?per_page=${PER_PAGE}`, {
|
||||
headers: { Accept: "application/vnd.github.v3+json" },
|
||||
}).then((r) => (r.ok ? r.json() : Promise.reject(new Error("Failed to load"))))
|
||||
|
||||
const releases = (Array.isArray(res) ? res : []).map((r) => ({
|
||||
tag: r.tag_name ?? "Unknown",
|
||||
body: (r.body ?? "")
|
||||
.replace(/#(\d+)/g, (_: string, id: string) => `[#${id}](https://github.com/anomalyco/opencode/pull/${id})`)
|
||||
.replace(/@([a-zA-Z0-9_-]+)/g, (_: string, u: string) => `[@${u}](https://github.com/${u})`),
|
||||
date: r.published_at ?? "",
|
||||
}))
|
||||
|
||||
saveCache({ releases, timestamp: now })
|
||||
|
||||
return { releases }
|
||||
}
|
||||
|
||||
export type { Release }
|
||||
150
packages/app/src/components/dialog-changelog.css
Normal file
150
packages/app/src/components/dialog-changelog.css
Normal file
@@ -0,0 +1,150 @@
|
||||
.dialog-changelog {
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-changelog [data-slot="dialog-body"] {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dialog-changelog-list {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-scroll"] {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-weak-base) transparent;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-thumb {
|
||||
background: var(--border-weak-base);
|
||||
border-radius: 5px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-weak-base);
|
||||
}
|
||||
|
||||
.dialog-changelog-header {
|
||||
padding: 8px 12px 8px 8px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
}
|
||||
|
||||
.dialog-changelog-header::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 16px;
|
||||
background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.dialog-changelog-header[data-stuck="true"]::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dialog-changelog-version {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dialog-changelog-date {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-item"] {
|
||||
margin-bottom: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-item"]:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-item"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dialog-changelog-list [data-slot="list-item"]:focus-visible {
|
||||
outline: 2px solid var(--focus-base);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dialog-changelog-content {
|
||||
padding: 0 8px 24px;
|
||||
}
|
||||
|
||||
.dialog-changelog-markdown h2 {
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
padding-bottom: 4px;
|
||||
margin: 32px 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.dialog-changelog-markdown h2:first-child {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dialog-changelog-markdown a.external-link {
|
||||
color: var(--text-interactive-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/pull/"],
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/issues/"],
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/"]
|
||||
{
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/pull/"]:hover,
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/issues/"]:hover,
|
||||
.dialog-changelog-markdown a.external-link[href^="https://github.com/"]:hover
|
||||
{
|
||||
background: var(--surface-weak-base);
|
||||
}
|
||||
40
packages/app/src/components/dialog-changelog.tsx
Normal file
40
packages/app/src/components/dialog-changelog.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createResource, Suspense, ErrorBoundary, Show } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { fetchReleases } from "@/api/releases"
|
||||
import { ReleaseList } from "@/components/release-list"
|
||||
|
||||
export function DialogChangelog() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [data] = createResource(() => fetchReleases(platform))
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" transition title="Changelog">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<ErrorBoundary
|
||||
fallback={(e) => (
|
||||
<p class="text-text-weak p-6">
|
||||
{e instanceof Error ? e.message : "Failed to load changelog"}
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<p class="text-text-weak p-6">{language.t("common.loading")}...</p>}>
|
||||
<Show
|
||||
when={(data()?.releases.length ?? 0) > 0}
|
||||
fallback={<p class="text-text-weak p-6">{language.t("common.noReleasesFound")}</p>}
|
||||
>
|
||||
<ReleaseList
|
||||
releases={data()!.releases}
|
||||
hasMore={false}
|
||||
loadingMore={false}
|
||||
onLoadMore={() => {}}
|
||||
/>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -459,4 +459,4 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,25 @@ import { Component } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { SettingsGeneral } from "./settings-general"
|
||||
import { SettingsKeybinds } from "./settings-keybinds"
|
||||
import { SettingsProviders } from "./settings-providers"
|
||||
import { DialogChangelog } from "@/components/dialog-changelog"
|
||||
import { SettingsModels } from "./settings-models"
|
||||
import { SettingsArchive } from "./settings-archive"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
|
||||
function handleShowChangelog() {
|
||||
dialog.show(() => <DialogChangelog />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" transition>
|
||||
@@ -47,11 +56,27 @@ export const DialogSettings: Component = () => {
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Tabs.SectionTitle>{language.t("settings.section.data")}</Tabs.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<Tabs.Trigger value="archive">
|
||||
<Icon name="archive" />
|
||||
{language.t("settings.archive.title")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span class="text-11-regular">v{platform.version}</span>
|
||||
<button
|
||||
class="text-11-regular text-text-weak hover:text-text-base self-start"
|
||||
onClick={handleShowChangelog}
|
||||
>
|
||||
Changelog
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
@@ -67,6 +92,9 @@ export const DialogSettings: Component = () => {
|
||||
<Tabs.Content value="models" class="no-scrollbar">
|
||||
<SettingsModels />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="archive" class="no-scrollbar">
|
||||
<SettingsArchive />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
61
packages/app/src/components/release-list.tsx
Normal file
61
packages/app/src/components/release-list.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Component } from "solid-js"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
|
||||
type Release = {
|
||||
tag: string
|
||||
body: string
|
||||
date: string
|
||||
}
|
||||
|
||||
interface ReleaseListProps {
|
||||
releases: Release[]
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
}
|
||||
|
||||
export const ReleaseList: Component<ReleaseListProps> = (props) => {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<List
|
||||
items={props.releases}
|
||||
key={(x) => x.tag}
|
||||
search={false}
|
||||
emptyMessage="No releases found"
|
||||
loadingMessage={language.t("common.loading")}
|
||||
class="flex-1 min-h-0 overflow-hidden flex flex-col [&_[data-slot=list-scroll]]:session-scroller [&_[data-slot=list-item]]:block [&_[data-slot=list-item]]:p-0 [&_[data-slot=list-item]]:border-0 [&_[data-slot=list-item]]:bg-transparent [&_[data-slot=list-item]]:text-left [&_[data-slot=list-item]]:cursor-default [&_[data-slot=list-item]]:hover:bg-transparent [&_[data-slot=list-item]]:focus:outline-none"
|
||||
add={{
|
||||
render: () =>
|
||||
props.hasMore ? (
|
||||
<div class="p-4 flex justify-center">
|
||||
<Button variant="secondary" size="small" onClick={props.onLoadMore} loading={props.loadingMore}>
|
||||
{language.t("common.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<div class="mb-8">
|
||||
<div class="py-2 pr-3 pl-2 flex items-baseline gap-2 sticky top-0 z-10 bg-surface-raised-stronger-non-alpha">
|
||||
<span class="text-[20px] font-semibold">{item.tag}</span>
|
||||
<span class="text-xs text-text-weak">{item.date ? getRelativeTime(item.date, language.t) : ""}</span>
|
||||
{item.tag === props.releases[0]?.tag && <Tag>{language.t("changelog.tag.latest")}</Tag>}
|
||||
</div>
|
||||
<div class="px-2 pb-2">
|
||||
<Markdown
|
||||
text={item.body}
|
||||
class="prose prose-sm max-w-none text-text-base [&_h2]:border-b [&_h2]:border-border-weak-base [&_h2]:pb-1 [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-sm [&_h2]:font-medium [&_h2]:capitalize [&_h2:first-child]:mt-4 [&_a.external-link]:text-text-interactive-base [&_a.external-link]:font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
|
||||
|
||||
function user(id: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
sessionID: "session-1",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
function assistant(id: string, parentID: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
sessionID: "session-1",
|
||||
parentID,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("findAssistantMessages", () => {
|
||||
test("normal ordering: assistant after user in array → found via forward scan", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("clock skew: assistant before user in array → found via backward scan", () => {
|
||||
// When client clock is ahead, user ID sorts after assistant ID,
|
||||
// so assistant appears earlier in the ID-sorted message array
|
||||
const messages = [assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 1, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("no assistant messages → returns empty array", () => {
|
||||
const messages = [user("u1"), user("u2")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple assistant messages with matching parentID → all found", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("a1")
|
||||
expect(result[1].id).toBe("a2")
|
||||
})
|
||||
|
||||
test("does not return assistant messages with different parentID", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops forward scan at next user message", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops backward scan at previous user message", () => {
|
||||
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 3, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("invalid index returns empty array", () => {
|
||||
const messages = [user("u1")]
|
||||
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
|
||||
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
188
packages/app/src/components/settings-archive.tsx
Normal file
188
packages/app/src/components/settings-archive.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { RadioGroup } from "@opencode-ai/ui/radio-group"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { SessionSkeleton } from "@/pages/layout/sidebar-items"
|
||||
|
||||
type FilterScope = "all" | "current"
|
||||
|
||||
type ScopeOption = { value: FilterScope; label: "settings.archive.scope.all" | "settings.archive.scope.current" }
|
||||
|
||||
const scopeOptions: ScopeOption[] = [
|
||||
{ value: "all", label: "settings.archive.scope.all" },
|
||||
{ value: "current", label: "settings.archive.scope.current" },
|
||||
]
|
||||
|
||||
export const SettingsArchive: Component = () => {
|
||||
const language = useLanguage()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const [removedIds, setRemovedIds] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const projects = createMemo(() => globalSync.data.project)
|
||||
const layoutProjects = createMemo(() => layout.projects.list())
|
||||
const hasMultipleProjects = createMemo(() => projects().length > 1)
|
||||
const homedir = createMemo(() => globalSync.data.path.home)
|
||||
|
||||
const defaultScope = () => (hasMultipleProjects() ? "current" : "all")
|
||||
const [filterScope, setFilterScope] = createSignal<FilterScope>(defaultScope())
|
||||
|
||||
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
|
||||
const currentProject = createMemo(() => {
|
||||
const dir = currentDirectory()
|
||||
if (!dir) return null
|
||||
return layoutProjects().find((p) => p.worktree === dir || p.sandboxes?.includes(dir)) ?? null
|
||||
})
|
||||
|
||||
const filteredProjects = createMemo(() => {
|
||||
if (filterScope() === "current" && currentProject()) {
|
||||
return [currentProject()!]
|
||||
}
|
||||
return layoutProjects()
|
||||
})
|
||||
|
||||
const getSessionLabel = (session: Session) => {
|
||||
const directory = session.directory
|
||||
const home = homedir()
|
||||
const path = home ? directory.replace(home, "~") : directory
|
||||
|
||||
if (filterScope() === "current" && currentProject()) {
|
||||
const current = currentProject()
|
||||
const kind =
|
||||
current && directory === current.worktree
|
||||
? language.t("workspace.type.local")
|
||||
: language.t("workspace.type.sandbox")
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
const name = store.vcs?.branch ?? getFilename(directory)
|
||||
return `${kind} : ${name || path}`
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
const [archivedSessions] = createResource(
|
||||
() => ({ scope: filterScope(), projects: filteredProjects() }),
|
||||
async ({ projects }) => {
|
||||
const allSessions: Session[] = []
|
||||
for (const project of projects) {
|
||||
const directories = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
for (const directory of directories) {
|
||||
const result = await globalSDK.client.experimental.session.list({ directory, archived: true })
|
||||
const sessions = result.data ?? []
|
||||
for (const session of sessions) {
|
||||
allSessions.push(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
return allSessions.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
|
||||
},
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const displayedSessions = () => {
|
||||
const sessions = archivedSessions() ?? []
|
||||
const removed = removedIds()
|
||||
return sessions.filter((s) => !removed.has(s.id))
|
||||
}
|
||||
|
||||
const currentScopeOption = () => scopeOptions.find((o) => o.value === filterScope())
|
||||
|
||||
const unarchiveSession = async (session: Session) => {
|
||||
setRemovedIds((prev) => new Set(prev).add(session.id))
|
||||
await globalSDK.client.session.update({
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
time: { archived: null as any },
|
||||
})
|
||||
}
|
||||
|
||||
const handleScopeChange = (option: ScopeOption | undefined) => {
|
||||
if (!option) return
|
||||
setRemovedIds(new Set<string>())
|
||||
setFilterScope(option.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.archive.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.archive.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 max-w-[720px]">
|
||||
<Show when={hasMultipleProjects()}>
|
||||
<RadioGroup
|
||||
options={scopeOptions}
|
||||
current={currentScopeOption() ?? undefined}
|
||||
value={(o) => o.value}
|
||||
size="small"
|
||||
label={(o) => language.t(o.label)}
|
||||
onSelect={handleScopeChange}
|
||||
/>
|
||||
</Show>
|
||||
<Show
|
||||
when={!archivedSessions.loading}
|
||||
fallback={
|
||||
<div class="min-h-[700px]">
|
||||
<SessionSkeleton count={4} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={displayedSessions().length}
|
||||
fallback={
|
||||
<div class="min-h-[700px]">
|
||||
<div class="text-14-regular text-text-weak">{language.t("settings.archive.none")}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="min-h-[700px] flex flex-col gap-2">
|
||||
<For each={displayedSessions()}>
|
||||
{(session) => (
|
||||
<div class="flex items-center justify-between gap-4 px-3 py-1 rounded-md hover:bg-surface-raised-base-hover">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong truncate">{session.title}</span>
|
||||
<span class="text-14-regular text-text-weak truncate">{getSessionLabel(session)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 shrink-0">
|
||||
<Show when={session.time?.updated}>
|
||||
{(updated) => (
|
||||
<span class="text-12-regular text-text-weak whitespace-nowrap">
|
||||
{getRelativeTime(new Date(updated()).toISOString(), language.t)}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="secondary"
|
||||
onClick={() => unarchiveSession(session)}
|
||||
>
|
||||
{language.t("common.unarchive")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -265,6 +265,9 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
|
||||
BETA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
|
||||
@@ -506,6 +506,10 @@ export const dict = {
|
||||
"common.close": "إغلاق",
|
||||
"common.edit": "تحرير",
|
||||
"common.loadMore": "تحميل المزيد",
|
||||
"common.changelog": "التغييرات",
|
||||
"common.noReleasesFound": "لم يتم العثور على إصدارات",
|
||||
"changelog.tag.latest": "الأحدث",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "تبديل القائمة",
|
||||
"sidebar.nav.projectsAndSessions": "المشاريع والجلسات",
|
||||
@@ -734,6 +738,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "ستتم أرشفة جلسة واحدة.",
|
||||
"workspace.reset.archived.many": "ستتم أرشفة {{count}} جلسات.",
|
||||
"workspace.reset.note": "سيؤدي هذا إلى إعادة تعيين مساحة العمل لتتطابق مع الفرع الافتراضي.",
|
||||
"settings.archive.title": "الجلسات المؤرشفة",
|
||||
"settings.archive.description": "استعادة الجلسات المؤرشفة لجعلها مرئية في الشريط الجانبي.",
|
||||
"settings.archive.none": "لا توجد جلسات مؤرشفة.",
|
||||
"settings.archive.scope.all": "جميع المشاريع",
|
||||
"settings.archive.scope.current": "المشروع الحالي",
|
||||
"common.open": "فتح",
|
||||
"dialog.releaseNotes.action.getStarted": "البدء",
|
||||
"dialog.releaseNotes.action.next": "التالي",
|
||||
@@ -748,4 +757,4 @@ export const dict = {
|
||||
"common.time.daysAgo.short": "قبل {{count}} ي",
|
||||
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
|
||||
"settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.",
|
||||
}
|
||||
}
|
||||
@@ -512,6 +512,9 @@ export const dict = {
|
||||
"common.close": "Fechar",
|
||||
"common.edit": "Editar",
|
||||
"common.loadMore": "Carregar mais",
|
||||
"common.changelog": "Novidades",
|
||||
"common.noReleasesFound": "Nenhuma release encontrada",
|
||||
"changelog.tag.latest": "Mais recente",
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "Alternar menu",
|
||||
"sidebar.nav.projectsAndSessions": "Projetos e sessões",
|
||||
@@ -742,6 +745,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sessão será arquivada.",
|
||||
"workspace.reset.archived.many": "{{count}} sessões serão arquivadas.",
|
||||
"workspace.reset.note": "Isso redefinirá o espaço de trabalho para corresponder ao branch padrão.",
|
||||
"settings.archive.title": "Sessões arquivadas",
|
||||
"settings.archive.description": "Restaure sessões arquivadas para torná-las visíveis na barra lateral.",
|
||||
"settings.archive.none": "Nenhuma sessão arquivada.",
|
||||
"settings.archive.scope.all": "Todos os projetos",
|
||||
"settings.archive.scope.current": "Projeto atual",
|
||||
"common.open": "Abrir",
|
||||
"dialog.releaseNotes.action.getStarted": "Começar",
|
||||
"dialog.releaseNotes.action.next": "Próximo",
|
||||
|
||||
@@ -572,6 +572,9 @@ export const dict = {
|
||||
"common.close": "Zatvori",
|
||||
"common.edit": "Uredi",
|
||||
"common.loadMore": "Učitaj još",
|
||||
"common.changelog": "Novosti",
|
||||
"common.noReleasesFound": "Nema pronađenih verzija",
|
||||
"changelog.tag.latest": "Najnovije",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "Prikaži/sakrij meni",
|
||||
@@ -819,6 +822,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesija će biti arhivirana.",
|
||||
"workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.",
|
||||
"workspace.reset.note": "Ovo će resetovati radni prostor da odgovara podrazumijevanoj grani.",
|
||||
"settings.archive.title": "Arhivirane sesije",
|
||||
"settings.archive.description": "Vrati arhivirane sesije da bi bile vidljive u bočnoj traci.",
|
||||
"settings.archive.none": "Nema arhiviranih sesija.",
|
||||
"settings.archive.scope.all": "Svi projekti",
|
||||
"settings.archive.scope.current": "Trenutni projekt",
|
||||
"common.open": "Otvori",
|
||||
"dialog.releaseNotes.action.getStarted": "Započni",
|
||||
"dialog.releaseNotes.action.next": "Sljedeće",
|
||||
|
||||
@@ -568,6 +568,9 @@ export const dict = {
|
||||
"common.close": "Luk",
|
||||
"common.edit": "Rediger",
|
||||
"common.loadMore": "Indlæs flere",
|
||||
"common.changelog": "Nyheder",
|
||||
"common.noReleasesFound": "Ingen versioner fundet",
|
||||
"changelog.tag.latest": "Seneste",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "Skift menu",
|
||||
@@ -813,6 +816,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 session vil blive arkiveret.",
|
||||
"workspace.reset.archived.many": "{{count}} sessioner vil blive arkiveret.",
|
||||
"workspace.reset.note": "Dette vil nulstille arbejdsområdet til at matche hovedgrenen.",
|
||||
"settings.archive.title": "Arkiverede sessioner",
|
||||
"settings.archive.description": "Gendan arkiverede sessioner for at gøre dem synlige i sidebjælken.",
|
||||
"settings.archive.none": "Ingen arkiverede sessioner.",
|
||||
"settings.archive.scope.all": "Alle projekter",
|
||||
"settings.archive.scope.current": "Nuværende projekt",
|
||||
"common.open": "Åbn",
|
||||
"dialog.releaseNotes.action.getStarted": "Kom i gang",
|
||||
"dialog.releaseNotes.action.next": "Næste",
|
||||
|
||||
@@ -520,6 +520,10 @@ export const dict = {
|
||||
"common.close": "Schließen",
|
||||
"common.edit": "Bearbeiten",
|
||||
"common.loadMore": "Mehr laden",
|
||||
"common.changelog": "Neuerungen",
|
||||
"common.noReleasesFound": "Keine Versionen gefunden",
|
||||
"changelog.tag.latest": "Neueste",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "Menü umschalten",
|
||||
"sidebar.nav.projectsAndSessions": "Projekte und Sitzungen",
|
||||
@@ -751,6 +755,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 Sitzung wird archiviert.",
|
||||
"workspace.reset.archived.many": "{{count}} Sitzungen werden archiviert.",
|
||||
"workspace.reset.note": "Dadurch wird der Arbeitsbereich auf den Standard-Branch zurückgesetzt.",
|
||||
|
||||
"settings.archive.title": "Archivierte Sitzungen",
|
||||
"settings.archive.description": "Archivierte Sitzungen wiederherstellen, um sie in der Seitenleiste anzuzeigen.",
|
||||
"settings.archive.none": "Keine archivierten Sitzungen.",
|
||||
"settings.archive.scope.all": "Alle Projekte",
|
||||
"settings.archive.scope.current": "Aktuelles Projekt",
|
||||
"common.open": "Öffnen",
|
||||
"dialog.releaseNotes.action.getStarted": "Loslegen",
|
||||
"dialog.releaseNotes.action.next": "Weiter",
|
||||
|
||||
@@ -585,16 +585,19 @@ export const dict = {
|
||||
"common.rename": "Rename",
|
||||
"common.reset": "Reset",
|
||||
"common.archive": "Archive",
|
||||
"common.unarchive": "Unarchive",
|
||||
"common.delete": "Delete",
|
||||
"common.close": "Close",
|
||||
"common.edit": "Edit",
|
||||
"common.loadMore": "Load more",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"common.changelog": "Changelog",
|
||||
"common.noReleasesFound": "No releases found",
|
||||
"common.time.justNow": "Just now",
|
||||
"common.time.minutesAgo.short": "{{count}}m ago",
|
||||
"common.time.hoursAgo.short": "{{count}}h ago",
|
||||
"common.time.daysAgo.short": "{{count}}d ago",
|
||||
"changelog.tag.latest": "Latest",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "Toggle menu",
|
||||
"sidebar.nav.projectsAndSessions": "Projects and sessions",
|
||||
@@ -613,6 +616,7 @@ export const dict = {
|
||||
|
||||
"settings.section.desktop": "Desktop",
|
||||
"settings.section.server": "Server",
|
||||
"settings.section.data": "Data",
|
||||
"settings.tab.general": "General",
|
||||
"settings.tab.shortcuts": "Shortcuts",
|
||||
"settings.desktop.section.wsl": "WSL",
|
||||
@@ -844,4 +848,10 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 session will be archived.",
|
||||
"workspace.reset.archived.many": "{{count}} sessions will be archived.",
|
||||
"workspace.reset.note": "This will reset the workspace to match the default branch.",
|
||||
|
||||
"settings.archive.title": "Archived Sessions",
|
||||
"settings.archive.description": "Restore archived sessions to make them visible in the sidebar.",
|
||||
"settings.archive.none": "No archived sessions.",
|
||||
"settings.archive.scope.all": "All projects",
|
||||
"settings.archive.scope.current": "Current project",
|
||||
}
|
||||
|
||||
@@ -575,6 +575,10 @@ export const dict = {
|
||||
"common.close": "Cerrar",
|
||||
"common.edit": "Editar",
|
||||
"common.loadMore": "Cargar más",
|
||||
"common.changelog": "Novedades",
|
||||
"common.noReleasesFound": "No se encontraron versiones",
|
||||
"changelog.tag.latest": "Último",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "Alternar menú",
|
||||
@@ -825,6 +829,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesión será archivada.",
|
||||
"workspace.reset.archived.many": "{{count}} sesiones serán archivadas.",
|
||||
"workspace.reset.note": "Esto restablecerá el espacio de trabajo para coincidir con la rama predeterminada.",
|
||||
|
||||
"settings.archive.title": "Sesiones archivadas",
|
||||
"settings.archive.description": "Restaura las sesiones archivadas para hacerlas visibles en la barra lateral.",
|
||||
"settings.archive.none": "No hay sesiones archivadas.",
|
||||
"settings.archive.scope.all": "Todos los proyectos",
|
||||
"settings.archive.scope.current": "Proyecto actual",
|
||||
"common.open": "Abrir",
|
||||
"dialog.releaseNotes.action.getStarted": "Comenzar",
|
||||
"dialog.releaseNotes.action.next": "Siguiente",
|
||||
|
||||
@@ -516,6 +516,10 @@ export const dict = {
|
||||
"common.close": "Fermer",
|
||||
"common.edit": "Modifier",
|
||||
"common.loadMore": "Charger plus",
|
||||
"common.changelog": "Nouveautés",
|
||||
"common.noReleasesFound": "Aucune version trouvée",
|
||||
"changelog.tag.latest": "Dernier",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "Basculer le menu",
|
||||
"sidebar.nav.projectsAndSessions": "Projets et sessions",
|
||||
@@ -749,6 +753,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 session sera archivée.",
|
||||
"workspace.reset.archived.many": "{{count}} sessions seront archivées.",
|
||||
"workspace.reset.note": "Cela réinitialisera l'espace de travail pour correspondre à la branche par défaut.",
|
||||
"settings.archive.title": "Sessions archivées",
|
||||
"settings.archive.description": "Restaurez les sessions archivées pour les rendre visibles dans la barre latérale.",
|
||||
"settings.archive.none": "Aucune session archivée.",
|
||||
"settings.archive.scope.all": "Tous les Projets",
|
||||
"settings.archive.scope.current": "Projet actuel",
|
||||
"common.open": "Ouvrir",
|
||||
"dialog.releaseNotes.action.getStarted": "Commencer",
|
||||
"dialog.releaseNotes.action.next": "Suivant",
|
||||
|
||||
@@ -510,6 +510,10 @@ export const dict = {
|
||||
"common.close": "閉じる",
|
||||
"common.edit": "編集",
|
||||
"common.loadMore": "さらに読み込む",
|
||||
"common.changelog": "更新履歴",
|
||||
"common.noReleasesFound": "バージョンが見つかりません",
|
||||
"changelog.tag.latest": "最新",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "メニューを切り替え",
|
||||
"sidebar.nav.projectsAndSessions": "プロジェクトとセッション",
|
||||
@@ -738,6 +742,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1つのセッションがアーカイブされます。",
|
||||
"workspace.reset.archived.many": "{{count}}個のセッションがアーカイブされます。",
|
||||
"workspace.reset.note": "これにより、ワークスペースはデフォルトブランチと一致するようにリセットされます。",
|
||||
|
||||
"settings.archive.title": "アーカイブされたセッション",
|
||||
"settings.archive.description": "アーカイブされたセッションを復元してサイドバーに表示します。",
|
||||
"settings.archive.none": "アーカイブされたセッションはありません。",
|
||||
"settings.archive.scope.all": "すべてのプロジェクト",
|
||||
"settings.archive.scope.current": "現在のプロジェクト",
|
||||
"common.open": "開く",
|
||||
"dialog.releaseNotes.action.getStarted": "始める",
|
||||
"dialog.releaseNotes.action.next": "次へ",
|
||||
|
||||
@@ -511,6 +511,10 @@ export const dict = {
|
||||
"common.close": "닫기",
|
||||
"common.edit": "편집",
|
||||
"common.loadMore": "더 불러오기",
|
||||
"common.changelog": "새로운 기능",
|
||||
"common.noReleasesFound": "버전을 찾을 수 없음",
|
||||
"changelog.tag.latest": "최신",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "메뉴 토글",
|
||||
"sidebar.nav.projectsAndSessions": "프로젝트 및 세션",
|
||||
@@ -738,6 +742,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1개의 세션이 보관됩니다.",
|
||||
"workspace.reset.archived.many": "{{count}}개의 세션이 보관됩니다.",
|
||||
"workspace.reset.note": "이 작업은 작업 공간을 기본 브랜치와 일치하도록 재설정합니다.",
|
||||
|
||||
"settings.archive.title": "보관된 세션",
|
||||
"settings.archive.description": "보관된 세션을 복원하여 사이드바에 표시합니다.",
|
||||
"settings.archive.none": "보관된 세션이 없습니다.",
|
||||
"settings.archive.scope.all": "모든 프로젝트",
|
||||
"settings.archive.scope.current": "현재 프로젝트",
|
||||
"common.open": "열기",
|
||||
"dialog.releaseNotes.action.getStarted": "시작하기",
|
||||
"dialog.releaseNotes.action.next": "다음",
|
||||
|
||||
@@ -575,6 +575,9 @@ export const dict = {
|
||||
"common.close": "Lukk",
|
||||
"common.edit": "Rediger",
|
||||
"common.loadMore": "Last flere",
|
||||
"common.changelog": "Nyheter",
|
||||
"common.noReleasesFound": "Ingen versjoner funnet",
|
||||
"changelog.tag.latest": "Siste",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "Veksle meny",
|
||||
@@ -821,6 +824,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesjon vil bli arkivert.",
|
||||
"workspace.reset.archived.many": "{{count}} sesjoner vil bli arkivert.",
|
||||
"workspace.reset.note": "Dette vil tilbakestille arbeidsområdet til å samsvare med standardgrenen.",
|
||||
|
||||
"settings.archive.title": "Arkiverte økter",
|
||||
"settings.archive.description": "Gjenopprett arkiverte økter for å gjøre dem synlige i sidefeltet.",
|
||||
"settings.archive.none": "Ingen arkiverte økter.",
|
||||
"settings.archive.scope.all": "Alle prosjekter",
|
||||
"settings.archive.scope.current": "Nåværende prosjekt",
|
||||
"common.open": "Åpne",
|
||||
"dialog.releaseNotes.action.getStarted": "Kom i gang",
|
||||
"dialog.releaseNotes.action.next": "Neste",
|
||||
|
||||
@@ -511,6 +511,9 @@ export const dict = {
|
||||
"common.close": "Zamknij",
|
||||
"common.edit": "Edytuj",
|
||||
"common.loadMore": "Załaduj więcej",
|
||||
"common.changelog": "Nowości",
|
||||
"common.noReleasesFound": "Nie znaleziono wersji",
|
||||
"changelog.tag.latest": "Najnowszy",
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "Przełącz menu",
|
||||
"sidebar.nav.projectsAndSessions": "Projekty i sesje",
|
||||
@@ -740,6 +743,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesja zostanie zarchiwizowana.",
|
||||
"workspace.reset.archived.many": "{{count}} sesji zostanie zarchiwizowanych.",
|
||||
"workspace.reset.note": "To zresetuje przestrzeń roboczą, aby odpowiadała domyślnej gałęzi.",
|
||||
"settings.archive.title": "Zarchiwizowane sesje",
|
||||
"settings.archive.description": "Przywróć zarchiwizowane sesje, aby były widoczne na pasku bocznym.",
|
||||
"settings.archive.none": "Brak zarchiwizowanych sesji.",
|
||||
"settings.archive.scope.all": "Wszystkie projekty",
|
||||
"settings.archive.scope.current": "Bieżący projekt",
|
||||
"common.open": "Otwórz",
|
||||
"dialog.releaseNotes.action.getStarted": "Rozpocznij",
|
||||
"dialog.releaseNotes.action.next": "Dalej",
|
||||
|
||||
@@ -573,6 +573,9 @@ export const dict = {
|
||||
"common.close": "Закрыть",
|
||||
"common.edit": "Редактировать",
|
||||
"common.loadMore": "Загрузить ещё",
|
||||
"common.changelog": "Что нового",
|
||||
"common.noReleasesFound": "Версии не найдены",
|
||||
"changelog.tag.latest": "Последний",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "Переключить меню",
|
||||
@@ -821,6 +824,11 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 сессия будет архивирована.",
|
||||
"workspace.reset.archived.many": "{{count}} сессий будет архивировано.",
|
||||
"workspace.reset.note": "Рабочее пространство будет сброшено в соответствие с веткой по умолчанию.",
|
||||
"settings.archive.title": "Архивированные сессии",
|
||||
"settings.archive.description": "Восстановите архивированные сессии, чтобы они отображались на боковой панели.",
|
||||
"settings.archive.none": "Нет архивированных сессий.",
|
||||
"settings.archive.scope.all": "Все проекты",
|
||||
"settings.archive.scope.current": "Текущий проект",
|
||||
"common.open": "Открыть",
|
||||
"dialog.releaseNotes.action.getStarted": "Начать",
|
||||
"dialog.releaseNotes.action.next": "Далее",
|
||||
|
||||
@@ -567,6 +567,9 @@ export const dict = {
|
||||
"common.close": "ปิด",
|
||||
"common.edit": "แก้ไข",
|
||||
"common.loadMore": "โหลดเพิ่มเติม",
|
||||
"common.changelog": "อัปเดต",
|
||||
"common.noReleasesFound": "ไม่พบเวอร์ชัน",
|
||||
"changelog.tag.latest": "ล่าสุด",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "สลับเมนู",
|
||||
@@ -811,6 +814,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 เซสชันจะถูกจัดเก็บ",
|
||||
"workspace.reset.archived.many": "{{count}} เซสชันจะถูกจัดเก็บ",
|
||||
"workspace.reset.note": "สิ่งนี้จะรีเซ็ตพื้นที่ทำงานให้ตรงกับสาขาเริ่มต้น",
|
||||
|
||||
"settings.archive.title": "เซสชันที่จัดเก็บ",
|
||||
"settings.archive.description": "กู้คืนเซสชันที่จัดเก็บเพื่อให้แสดงในแถบด้านข้าง",
|
||||
"settings.archive.none": "ไม่มีเซสชันที่จัดเก็บ",
|
||||
"settings.archive.scope.all": "โปรเจกต์ทั้งหมด",
|
||||
"settings.archive.scope.current": "โปรเจกต์ปัจจุบัน",
|
||||
"common.open": "เปิด",
|
||||
"dialog.releaseNotes.action.getStarted": "เริ่มต้น",
|
||||
"dialog.releaseNotes.action.next": "ถัดไป",
|
||||
|
||||
@@ -566,6 +566,10 @@ export const dict = {
|
||||
"common.close": "关闭",
|
||||
"common.edit": "编辑",
|
||||
"common.loadMore": "加载更多",
|
||||
"common.changelog": "更新日志",
|
||||
"common.noReleasesFound": "未找到版本",
|
||||
"changelog.tag.latest": "最新",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "切换菜单",
|
||||
@@ -809,6 +813,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "将归档 1 个会话。",
|
||||
"workspace.reset.archived.many": "将归档 {{count}} 个会话。",
|
||||
"workspace.reset.note": "这将把工作区重置为与默认分支一致。",
|
||||
|
||||
"settings.archive.title": "归档会话",
|
||||
"settings.archive.description": "恢复归档会话以使其在侧边栏中可见。",
|
||||
"settings.archive.none": "没有归档会话。",
|
||||
"settings.archive.scope.all": "所有项目",
|
||||
"settings.archive.scope.current": "当前项目",
|
||||
"common.open": "打开",
|
||||
"dialog.releaseNotes.action.getStarted": "开始",
|
||||
"dialog.releaseNotes.action.next": "下一步",
|
||||
|
||||
@@ -563,6 +563,9 @@ export const dict = {
|
||||
"common.close": "關閉",
|
||||
"common.edit": "編輯",
|
||||
"common.loadMore": "載入更多",
|
||||
"common.changelog": "更新日誌",
|
||||
"common.noReleasesFound": "未找到版本",
|
||||
"changelog.tag.latest": "最新",
|
||||
|
||||
"common.key.esc": "ESC",
|
||||
"sidebar.menu.toggle": "切換選單",
|
||||
@@ -804,6 +807,12 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "將封存 1 個工作階段。",
|
||||
"workspace.reset.archived.many": "將封存 {{count}} 個工作階段。",
|
||||
"workspace.reset.note": "這將把工作區重設為與預設分支一致。",
|
||||
|
||||
"settings.archive.title": "封存工作階段",
|
||||
"settings.archive.description": "恢復封存的工作階段以使其在側邊欄中可見。",
|
||||
"settings.archive.none": "沒有封存的工作階段。",
|
||||
"settings.archive.scope.all": "所有專案",
|
||||
"settings.archive.scope.current": "目前專案",
|
||||
"common.open": "打開",
|
||||
"dialog.releaseNotes.action.getStarted": "開始",
|
||||
"dialog.releaseNotes.action.next": "下一步",
|
||||
|
||||
@@ -43,6 +43,7 @@ import { retry } from "@opencode-ai/util/retry"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
import { createAim } from "@/utils/aim"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
@@ -66,7 +67,12 @@ import {
|
||||
syncWorkspaceOrder,
|
||||
workspaceKey,
|
||||
} from "./layout/helpers"
|
||||
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
|
||||
import {
|
||||
collectNewSessionDeepLinks,
|
||||
collectOpenProjectDeepLinks,
|
||||
deepLinkEvent,
|
||||
drainPendingDeepLinks,
|
||||
} from "./layout/deep-links"
|
||||
import { createInlineEditorController } from "./layout/inline-editor"
|
||||
import {
|
||||
LocalWorkspace,
|
||||
@@ -1157,9 +1163,20 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const handleDeepLinks = (urls: string[]) => {
|
||||
if (!server.isLocal()) return
|
||||
|
||||
for (const directory of collectOpenProjectDeepLinks(urls)) {
|
||||
openProject(directory)
|
||||
}
|
||||
|
||||
for (const link of collectNewSessionDeepLinks(urls)) {
|
||||
openProject(link.directory, false)
|
||||
const slug = base64Encode(link.directory)
|
||||
if (link.prompt) {
|
||||
setSessionHandoff(slug, { prompt: link.prompt })
|
||||
}
|
||||
const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
|
||||
navigateWithSidebarReset(href)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
export const deepLinkEvent = "opencode:deep-link"
|
||||
|
||||
export const parseDeepLink = (input: string) => {
|
||||
const parseUrl = (input: string) => {
|
||||
if (!input.startsWith("opencode://")) return
|
||||
if (typeof URL.canParse === "function" && !URL.canParse(input)) return
|
||||
const url = (() => {
|
||||
try {
|
||||
return new URL(input)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
try {
|
||||
return new URL(input)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const parseDeepLink = (input: string) => {
|
||||
const url = parseUrl(input)
|
||||
if (!url) return
|
||||
if (url.hostname !== "open-project") return
|
||||
const directory = url.searchParams.get("directory")
|
||||
@@ -17,9 +19,23 @@ export const parseDeepLink = (input: string) => {
|
||||
return directory
|
||||
}
|
||||
|
||||
export const parseNewSessionDeepLink = (input: string) => {
|
||||
const url = parseUrl(input)
|
||||
if (!url) return
|
||||
if (url.hostname !== "new-session") return
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (!directory) return
|
||||
const prompt = url.searchParams.get("prompt") || undefined
|
||||
if (!prompt) return { directory }
|
||||
return { directory, prompt }
|
||||
}
|
||||
|
||||
export const collectOpenProjectDeepLinks = (urls: string[]) =>
|
||||
urls.map(parseDeepLink).filter((directory): directory is string => !!directory)
|
||||
|
||||
export const collectNewSessionDeepLinks = (urls: string[]) =>
|
||||
urls.map(parseNewSessionDeepLink).filter((link): link is { directory: string; prompt?: string } => !!link)
|
||||
|
||||
type OpenCodeWindow = Window & {
|
||||
__OPENCODE__?: {
|
||||
deepLinks?: string[]
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
|
||||
import {
|
||||
displayName,
|
||||
errorMessage,
|
||||
getDraggableId,
|
||||
hasProjectPermissions,
|
||||
latestRootSession,
|
||||
syncWorkspaceOrder,
|
||||
workspaceKey,
|
||||
} from "./helpers"
|
||||
collectNewSessionDeepLinks,
|
||||
collectOpenProjectDeepLinks,
|
||||
drainPendingDeepLinks,
|
||||
parseDeepLink,
|
||||
parseNewSessionDeepLink,
|
||||
} from "./deep-links"
|
||||
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { hasProjectPermissions, latestRootSession } from "./helpers"
|
||||
|
||||
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
||||
({
|
||||
@@ -62,6 +61,28 @@ describe("layout deep links", () => {
|
||||
expect(result).toEqual(["/a", "/c"])
|
||||
})
|
||||
|
||||
test("parses new-session deep links with optional prompt", () => {
|
||||
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" })
|
||||
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo&prompt=hello%20world")).toEqual({
|
||||
directory: "/tmp/demo",
|
||||
prompt: "hello world",
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores new-session deep links without directory", () => {
|
||||
expect(parseNewSessionDeepLink("opencode://new-session")).toBeUndefined()
|
||||
expect(parseNewSessionDeepLink("opencode://new-session?directory=")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("collects only valid new-session deep links", () => {
|
||||
const result = collectNewSessionDeepLinks([
|
||||
"opencode://new-session?directory=/a",
|
||||
"opencode://open-project?directory=/b",
|
||||
"opencode://new-session?directory=/c&prompt=ship%20it",
|
||||
])
|
||||
expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }])
|
||||
})
|
||||
|
||||
test("drains global deep links once", () => {
|
||||
const target = {
|
||||
__OPENCODE__: {
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum, base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, Match, on, onCleanup, onMount, Show, Switch, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { SessionHeader, NewSessionView } from "@/components/session"
|
||||
import { same } from "@/utils/same"
|
||||
import { type FileSelection, type SelectedLineRange, selectionFromLines, useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { createOpenReviewFile } from "@/pages/session/helpers"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
export default function Page() {
|
||||
const layout = useLayout()
|
||||
@@ -44,6 +43,19 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
|
||||
createEffect(() => {
|
||||
if (!untrack(() => prompt.ready())) return
|
||||
prompt.ready()
|
||||
untrack(() => {
|
||||
if (params.id || !prompt.ready()) return
|
||||
const text = searchParams.prompt
|
||||
if (!text) return
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
setSearchParams({ ...searchParams, prompt: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
const [ui, setUi] = createStore({
|
||||
pendingMessage: undefined as string | undefined,
|
||||
@@ -478,7 +490,11 @@ export default function Page() {
|
||||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
|
||||
setTree({
|
||||
reviewScroll: undefined,
|
||||
pendingDiff: undefined,
|
||||
activeDiff: undefined,
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
|
||||
@@ -117,6 +117,7 @@ export function MessageTimeline(props: {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const info = createMemo(() => {
|
||||
@@ -552,16 +553,16 @@ export function MessageTimeline(props: {
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={props.renderedUserMessages}>
|
||||
{(message) => {
|
||||
const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
|
||||
<For each={rendered()}>
|
||||
{(messageID) => {
|
||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []))
|
||||
return (
|
||||
<div
|
||||
id={props.anchor(message.id)}
|
||||
data-message-id={message.id}
|
||||
id={props.anchor(messageID)}
|
||||
data-message-id={messageID}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, message.id)
|
||||
onCleanup(() => props.onUnregisterMessage(message.id))
|
||||
props.onRegisterMessage(el, messageID)
|
||||
onCleanup(() => props.onUnregisterMessage(messageID))
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
@@ -600,7 +601,7 @@ export function MessageTimeline(props: {
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={message.id}
|
||||
messageID={messageID}
|
||||
lastUserMessageID={props.lastUserMessageID}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||
|
||||
@@ -19,4 +19,4 @@ export function getRelativeTime(dateString: string, t: Translate): string {
|
||||
if (diffMinutes < 60) return t("common.time.minutesAgo.short", { count: diffMinutes })
|
||||
if (diffHours < 24) return t("common.time.hoursAgo.short", { count: diffHours })
|
||||
return t("common.time.daysAgo.short", { count: diffDays })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `account` (
|
||||
`id` text PRIMARY KEY,
|
||||
`email` text NOT NULL,
|
||||
`url` text NOT NULL,
|
||||
`access_token` text NOT NULL,
|
||||
`refresh_token` text NOT NULL,
|
||||
`token_expiry` integer,
|
||||
`workspace_id` text,
|
||||
`time_created` integer NOT NULL,
|
||||
`time_updated` integer NOT NULL
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/which": "3.0.4",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
@@ -89,8 +90,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.81",
|
||||
"@opentui/solid": "0.1.81",
|
||||
"@opentui/core": "0.1.84",
|
||||
"@opentui/solid": "0.1.84",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -127,6 +128,7 @@
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { Timestamps } from "@/storage/schema.sql"
|
||||
|
||||
export const AccountTable = sqliteTable("account", {
|
||||
id: text().primaryKey(),
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().notNull(),
|
||||
refresh_token: text().notNull(),
|
||||
token_expiry: integer(),
|
||||
workspace_id: text(),
|
||||
...Timestamps,
|
||||
})
|
||||
|
||||
// LEGACY
|
||||
export const ControlAccountTable = sqliteTable(
|
||||
"control_account",
|
||||
{
|
||||
247
packages/opencode/src/account/index.ts
Normal file
247
packages/opencode/src/account/index.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { eq, sql, isNotNull } from "drizzle-orm"
|
||||
import { Database } from "@/storage/db"
|
||||
import { AccountTable } from "./account.sql"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Account {
|
||||
export const Account = z.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
url: z.string(),
|
||||
workspace_id: z.string().nullable(),
|
||||
})
|
||||
export type Account = z.infer<typeof Account>
|
||||
|
||||
function fromRow(row: (typeof AccountTable)["$inferSelect"]): Account {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
url: row.url,
|
||||
workspace_id: row.workspace_id,
|
||||
}
|
||||
}
|
||||
|
||||
export function active(): Account | undefined {
|
||||
const row = Database.use((db) => db.select().from(AccountTable).where(isNotNull(AccountTable.workspace_id)).get())
|
||||
return row ? fromRow(row) : undefined
|
||||
}
|
||||
|
||||
export function list(): Account[] {
|
||||
return Database.use((db) => db.select().from(AccountTable).all().map(fromRow))
|
||||
}
|
||||
|
||||
export function remove(accountID: string) {
|
||||
Database.use((db) => db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run())
|
||||
}
|
||||
|
||||
export function use(accountID: string, workspaceID: string | null) {
|
||||
Database.use((db) =>
|
||||
db.update(AccountTable).set({ workspace_id: workspaceID }).where(eq(AccountTable.id, accountID)).run(),
|
||||
)
|
||||
}
|
||||
|
||||
export async function workspaces(accountID: string): Promise<{ id: string; name: string }[]> {
|
||||
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
|
||||
if (!row) return []
|
||||
|
||||
const access = await token(accountID)
|
||||
if (!access) return []
|
||||
|
||||
const res = await fetch(`${row.url}/api/orgs`, {
|
||||
headers: { authorization: `Bearer ${access}` },
|
||||
})
|
||||
|
||||
if (!res.ok) return []
|
||||
|
||||
const json = (await res.json()) as Array<{ id?: string; name?: string }>
|
||||
return json.map((x) => ({ id: x.id ?? "", name: x.name ?? "" }))
|
||||
}
|
||||
|
||||
export async function config(accountID: string, workspaceID: string): Promise<Record<string, unknown> | undefined> {
|
||||
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
|
||||
if (!row) return undefined
|
||||
|
||||
const access = await token(accountID)
|
||||
if (!access) return undefined
|
||||
|
||||
const res = await fetch(`${row.url}/api/config`, {
|
||||
headers: { authorization: `Bearer ${access}`, "x-org-id": workspaceID },
|
||||
})
|
||||
|
||||
if (!res.ok) return undefined
|
||||
const result = (await res.json()) as Record<string, any>
|
||||
return result.config
|
||||
}
|
||||
|
||||
export async function token(accountID: string): Promise<string | undefined> {
|
||||
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
|
||||
if (!row) return undefined
|
||||
if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
|
||||
|
||||
const res = await fetch(`${row.url}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: row.refresh_token,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!res.ok) return
|
||||
|
||||
const json = (await res.json()) as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(AccountTable)
|
||||
.set({
|
||||
access_token: json.access_token,
|
||||
refresh_token: json.refresh_token ?? row.refresh_token,
|
||||
token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
|
||||
})
|
||||
.where(eq(AccountTable.id, row.id))
|
||||
.run(),
|
||||
)
|
||||
|
||||
return json.access_token
|
||||
}
|
||||
|
||||
export type Login = {
|
||||
code: string
|
||||
user: string
|
||||
url: string
|
||||
server: string
|
||||
expiry: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export async function login(url?: string): Promise<Login> {
|
||||
const server = url ?? "https://web-14275-d60e67f5-pyqs0590.onporter.run"
|
||||
const res = await fetch(`${server}/auth/device/code`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ client_id: "opencode-cli" }),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`Failed to initiate device flow: ${await res.text()}`)
|
||||
|
||||
const json = (await res.json()) as {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri_complete: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
const full = `${server}${json.verification_uri_complete}`
|
||||
|
||||
return {
|
||||
code: json.device_code,
|
||||
user: json.user_code,
|
||||
url: full,
|
||||
server,
|
||||
expiry: json.expires_in,
|
||||
interval: json.interval,
|
||||
}
|
||||
}
|
||||
|
||||
export async function poll(
|
||||
input: Login,
|
||||
): Promise<
|
||||
| { type: "success"; email: string }
|
||||
| { type: "pending" }
|
||||
| { type: "slow" }
|
||||
| { type: "expired" }
|
||||
| { type: "denied" }
|
||||
| { type: "error"; msg: string }
|
||||
> {
|
||||
const res = await fetch(`${input.server}/auth/device/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: input.code,
|
||||
client_id: "opencode-cli",
|
||||
}),
|
||||
})
|
||||
|
||||
const json = (await res.json()) as {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
error?: string
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
if (json.access_token) {
|
||||
const me = await fetch(`${input.server}/api/user`, {
|
||||
headers: { authorization: `Bearer ${json.access_token}` },
|
||||
})
|
||||
const user = (await me.json()) as { id?: string; email?: string }
|
||||
if (!user.id || !user.email) {
|
||||
return { type: "error", msg: "No id or email in response" }
|
||||
}
|
||||
const id = user.id
|
||||
const email = user.email
|
||||
|
||||
const access = json.access_token
|
||||
const expiry = Date.now() + json.expires_in! * 1000
|
||||
const refresh = json.refresh_token ?? ""
|
||||
|
||||
// Fetch workspaces and get first one
|
||||
const orgsRes = await fetch(`${input.server}/api/orgs`, {
|
||||
headers: { authorization: `Bearer ${access}` },
|
||||
})
|
||||
const orgs = (await orgsRes.json()) as Array<{ id?: string; name?: string }>
|
||||
const firstWorkspaceId = orgs.length > 0 ? orgs[0].id : null
|
||||
|
||||
Database.use((db) => {
|
||||
db.update(AccountTable).set({ workspace_id: null }).run()
|
||||
db.insert(AccountTable)
|
||||
.values({
|
||||
id,
|
||||
email,
|
||||
url: input.server,
|
||||
access_token: access,
|
||||
refresh_token: refresh,
|
||||
token_expiry: expiry,
|
||||
workspace_id: firstWorkspaceId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: AccountTable.id,
|
||||
set: {
|
||||
access_token: access,
|
||||
refresh_token: refresh,
|
||||
token_expiry: expiry,
|
||||
workspace_id: firstWorkspaceId,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
})
|
||||
|
||||
return { type: "success", email }
|
||||
}
|
||||
|
||||
if (json.error === "authorization_pending") {
|
||||
return { type: "pending" }
|
||||
}
|
||||
|
||||
if (json.error === "slow_down") {
|
||||
return { type: "slow" }
|
||||
}
|
||||
|
||||
if (json.error === "expired_token") {
|
||||
return { type: "expired" }
|
||||
}
|
||||
|
||||
if (json.error === "access_denied") {
|
||||
return { type: "denied" }
|
||||
}
|
||||
|
||||
return { type: "error", msg: json.error || JSON.stringify(json) }
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export namespace Agent {
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
edit: "ask",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
|
||||
161
packages/opencode/src/cli/cmd/account.ts
Normal file
161
packages/opencode/src/cli/cmd/account.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { Account } from "@/account"
|
||||
|
||||
export const LoginCommand = cmd({
|
||||
command: "login [url]",
|
||||
describe: "log in to an opencode account",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("url", {
|
||||
describe: "server URL",
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
prompts.intro("Log in")
|
||||
|
||||
const url = args.url as string | undefined
|
||||
const login = await Account.login(url)
|
||||
|
||||
prompts.log.info("Go to: " + login.url)
|
||||
prompts.log.info("Enter code: " + login.user)
|
||||
|
||||
try {
|
||||
const open =
|
||||
process.platform === "darwin"
|
||||
? ["open", login.url]
|
||||
: process.platform === "win32"
|
||||
? ["cmd", "/c", "start", login.url]
|
||||
: ["xdg-open", login.url]
|
||||
Bun.spawn(open, { stdout: "ignore", stderr: "ignore" })
|
||||
} catch {}
|
||||
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Waiting for authorization...")
|
||||
|
||||
let wait = login.interval * 1000
|
||||
while (true) {
|
||||
await Bun.sleep(wait)
|
||||
|
||||
const result = await Account.poll(login)
|
||||
|
||||
if (result.type === "success") {
|
||||
spinner.stop("Logged in as " + result.email)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
if (result.type === "pending") continue
|
||||
|
||||
if (result.type === "slow") {
|
||||
wait += 5000
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.type === "expired") {
|
||||
spinner.stop("Device code expired", 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.type === "denied") {
|
||||
spinner.stop("Authorization denied", 1)
|
||||
return
|
||||
}
|
||||
|
||||
spinner.stop("Error: " + result.msg, 1)
|
||||
return
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const LogoutCommand = cmd({
|
||||
command: "logout [email]",
|
||||
describe: "log out from an account",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("email", {
|
||||
describe: "account email to log out from",
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
const email = args.email as string | undefined
|
||||
|
||||
if (email) {
|
||||
const accounts = Account.list()
|
||||
const match = accounts.find((a) => a.email === email)
|
||||
if (!match) {
|
||||
UI.println("Account not found: " + email)
|
||||
return
|
||||
}
|
||||
Account.remove(match.id)
|
||||
UI.println("Logged out from " + email)
|
||||
return
|
||||
}
|
||||
|
||||
const active = Account.active()
|
||||
if (!active) {
|
||||
UI.println("Not logged in")
|
||||
return
|
||||
}
|
||||
Account.remove(active.id)
|
||||
UI.println("Logged out from " + active.email)
|
||||
},
|
||||
})
|
||||
|
||||
export const SwitchCommand = cmd({
|
||||
command: "switch",
|
||||
describe: "switch active workspace",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
|
||||
const active = Account.active()
|
||||
if (!active) {
|
||||
UI.println("Not logged in")
|
||||
return
|
||||
}
|
||||
|
||||
const workspaces = await Account.workspaces(active.id)
|
||||
if (workspaces.length === 0) {
|
||||
UI.println("No workspaces found")
|
||||
return
|
||||
}
|
||||
|
||||
prompts.intro("Switch workspace")
|
||||
|
||||
const opts = workspaces.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.id === active.workspace_id ? w.name + UI.Style.TEXT_DIM + " (active)" : w.name,
|
||||
}))
|
||||
|
||||
const selected = await prompts.select({
|
||||
message: "Select workspace",
|
||||
options: opts,
|
||||
})
|
||||
|
||||
if (prompts.isCancel(selected)) return
|
||||
|
||||
Account.use(active.id, selected as string)
|
||||
prompts.outro("Switched to " + workspaces.find((w) => w.id === selected)?.name)
|
||||
},
|
||||
})
|
||||
|
||||
export const WorkspacesCommand = cmd({
|
||||
command: "workspaces",
|
||||
aliases: ["workspace"],
|
||||
describe: "list all workspaces",
|
||||
async handler() {
|
||||
const accounts = Account.list()
|
||||
|
||||
if (accounts.length === 0) {
|
||||
UI.println("No accounts found")
|
||||
return
|
||||
}
|
||||
|
||||
for (const account of accounts) {
|
||||
const workspaces = await Account.workspaces(account.id)
|
||||
for (const space of workspaces) {
|
||||
UI.println([space.name, account.email, space.id].join("\t"))
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "../../util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
@@ -38,7 +39,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
|
||||
const method = plugin.auth.methods[index]
|
||||
|
||||
// Handle prompts for all auth types
|
||||
await Bun.sleep(10)
|
||||
await sleep(10)
|
||||
const inputs: Record<string, string> = {}
|
||||
if (method.prompts) {
|
||||
for (const prompt of method.prompts) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Log } from "../../../util/log"
|
||||
import { EOL } from "os"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
@@ -19,7 +20,7 @@ const DiagnosticsCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
await Bun.sleep(1000)
|
||||
await sleep(1000)
|
||||
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { $ } from "bun"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -353,7 +354,7 @@ export const GithubInstallCommand = cmd({
|
||||
}
|
||||
|
||||
retries++
|
||||
await Bun.sleep(1000)
|
||||
await sleep(1000)
|
||||
} while (true)
|
||||
|
||||
s.stop("Installed GitHub app")
|
||||
@@ -1372,7 +1373,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
} catch (e) {
|
||||
if (retries > 0) {
|
||||
console.log(`Retrying after ${delayMs}ms...`)
|
||||
await Bun.sleep(delayMs)
|
||||
await sleep(delayMs)
|
||||
return withRetry(fn, retries - 1, delayMs)
|
||||
}
|
||||
throw e
|
||||
|
||||
438
packages/opencode/src/cli/cmd/providers.ts
Normal file
438
packages/opencode/src/cli/cmd/providers.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { Auth } from "../../auth"
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Config } from "../../config/config"
|
||||
import { Global } from "../../global"
|
||||
import { Plugin } from "../../plugin"
|
||||
import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "../../util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise<boolean> {
|
||||
let index = 0
|
||||
if (plugin.auth.methods.length > 1) {
|
||||
const method = await prompts.select({
|
||||
message: "Login method",
|
||||
options: [
|
||||
...plugin.auth.methods.map((x, index) => ({
|
||||
label: x.label,
|
||||
value: index.toString(),
|
||||
})),
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
||||
index = parseInt(method)
|
||||
}
|
||||
const method = plugin.auth.methods[index]
|
||||
|
||||
await Bun.sleep(10)
|
||||
const inputs: Record<string, string> = {}
|
||||
if (method.prompts) {
|
||||
for (const prompt of method.prompts) {
|
||||
if (prompt.condition && !prompt.condition(inputs)) {
|
||||
continue
|
||||
}
|
||||
if (prompt.type === "select") {
|
||||
const value = await prompts.select({
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
})
|
||||
if (prompts.isCancel(value)) throw new UI.CancelledError()
|
||||
inputs[prompt.key] = value
|
||||
} else {
|
||||
const value = await prompts.text({
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
|
||||
})
|
||||
if (prompts.isCancel(value)) throw new UI.CancelledError()
|
||||
inputs[prompt.key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (method.type === "oauth") {
|
||||
const authorize = await method.authorize(inputs)
|
||||
|
||||
if (authorize.url) {
|
||||
prompts.log.info("Go to: " + authorize.url)
|
||||
}
|
||||
|
||||
if (authorize.method === "auto") {
|
||||
if (authorize.instructions) {
|
||||
prompts.log.info(authorize.instructions)
|
||||
}
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Waiting for authorization...")
|
||||
const result = await authorize.callback()
|
||||
if (result.type === "failed") {
|
||||
spinner.stop("Failed to authorize", 1)
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
await Auth.set(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
expires,
|
||||
...extraFields,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
spinner.stop("Login successful")
|
||||
}
|
||||
}
|
||||
|
||||
if (authorize.method === "code") {
|
||||
const code = await prompts.text({
|
||||
message: "Paste the authorization code here: ",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
||||
const result = await authorize.callback(code)
|
||||
if (result.type === "failed") {
|
||||
prompts.log.error("Failed to authorize")
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
await Auth.set(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
expires,
|
||||
...extraFields,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
prompts.log.success("Login successful")
|
||||
}
|
||||
}
|
||||
|
||||
prompts.outro("Done")
|
||||
return true
|
||||
}
|
||||
|
||||
if (method.type === "api") {
|
||||
if (method.authorize) {
|
||||
const result = await method.authorize(inputs)
|
||||
if (result.type === "failed") {
|
||||
prompts.log.error("Failed to authorize")
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
await Auth.set(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
prompts.log.success("Login successful")
|
||||
}
|
||||
prompts.outro("Done")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function resolvePluginProviders(input: {
|
||||
hooks: Hooks[]
|
||||
existingProviders: Record<string, unknown>
|
||||
disabled: Set<string>
|
||||
enabled?: Set<string>
|
||||
providerNames: Record<string, string | undefined>
|
||||
}): Array<{ id: string; name: string }> {
|
||||
const seen = new Set<string>()
|
||||
const result: Array<{ id: string; name: string }> = []
|
||||
|
||||
for (const hook of input.hooks) {
|
||||
if (!hook.auth) continue
|
||||
const id = hook.auth.provider
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
if (Object.hasOwn(input.existingProviders, id)) continue
|
||||
if (input.disabled.has(id)) continue
|
||||
if (input.enabled && !input.enabled.has(id)) continue
|
||||
result.push({
|
||||
id,
|
||||
name: input.providerNames[id] ?? id,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const ProvidersCommand = cmd({
|
||||
command: "providers",
|
||||
aliases: ["auth"],
|
||||
describe: "manage AI providers and credentials",
|
||||
builder: (yargs) =>
|
||||
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const ProvidersListCommand = cmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list providers and credentials",
|
||||
async handler(_args) {
|
||||
UI.empty()
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
const results = Object.entries(await Auth.all())
|
||||
const database = await ModelsDev.get()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
const name = database[providerID]?.name || providerID
|
||||
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${results.length} credentials`)
|
||||
|
||||
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
for (const envVar of provider.env) {
|
||||
if (process.env[envVar]) {
|
||||
activeEnvVars.push({
|
||||
provider: provider.name || providerID,
|
||||
envVar,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeEnvVars.length > 0) {
|
||||
UI.empty()
|
||||
prompts.intro("Environment")
|
||||
|
||||
for (const { provider, envVar } of activeEnvVars) {
|
||||
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const ProvidersLoginCommand = cmd({
|
||||
command: "login [url]",
|
||||
describe: "log in to a provider",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("url", {
|
||||
describe: "opencode auth provider",
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Process.spawn(wellknown.auth.command, {
|
||||
stdout: "pipe",
|
||||
})
|
||||
if (!proc.stdout) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
|
||||
if (exit !== 0) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await Auth.set(args.url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
token: token.trim(),
|
||||
})
|
||||
prompts.log.success("Logged into " + args.url)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await ModelsDev.refresh().catch(() => {})
|
||||
|
||||
const config = await Config.get()
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
|
||||
const providers = await ModelsDev.get().then((x) => {
|
||||
const filtered: Record<string, (typeof x)[string]> = {}
|
||||
for (const [key, value] of Object.entries(x)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
})
|
||||
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
anthropic: 1,
|
||||
"github-copilot": 2,
|
||||
openai: 3,
|
||||
google: 4,
|
||||
openrouter: 5,
|
||||
vercel: 6,
|
||||
}
|
||||
const pluginProviders = resolvePluginProviders({
|
||||
hooks: await Plugin.list(),
|
||||
existingProviders: providers,
|
||||
disabled,
|
||||
enabled,
|
||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||
})
|
||||
let provider = await prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: {
|
||||
opencode: "recommended",
|
||||
anthropic: "Claude Max or API key",
|
||||
openai: "ChatGPT Plus/Pro or API key",
|
||||
}[x.id],
|
||||
})),
|
||||
),
|
||||
...pluginProviders.map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: "plugin",
|
||||
})),
|
||||
{
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
|
||||
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
||||
if (plugin && plugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
if (provider === "other") {
|
||||
provider = await prompts.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||
})
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
provider = provider.replace(/^@ai-sdk\//, "")
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
|
||||
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
||||
if (customPlugin && customPlugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
prompts.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
prompts.log.info(
|
||||
"Amazon Bedrock authentication priority:\n" +
|
||||
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
||||
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
|
||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "opencode") {
|
||||
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
||||
}
|
||||
|
||||
if (provider === "vercel") {
|
||||
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||
}
|
||||
|
||||
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
||||
prompts.log.info(
|
||||
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
|
||||
)
|
||||
}
|
||||
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
await Auth.set(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
})
|
||||
|
||||
prompts.outro("Done")
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const ProvidersLogoutCommand = cmd({
|
||||
command: "logout",
|
||||
describe: "log out from a configured provider",
|
||||
async handler(_args) {
|
||||
UI.empty()
|
||||
const credentials = await Auth.all().then((x) => Object.entries(x))
|
||||
prompts.intro("Remove credential")
|
||||
if (credentials.length === 0) {
|
||||
prompts.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = await ModelsDev.get()
|
||||
const providerID = await prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
})),
|
||||
})
|
||||
if (prompts.isCancel(providerID)) throw new UI.CancelledError()
|
||||
await Auth.remove(providerID)
|
||||
prompts.outro("Logout successful")
|
||||
},
|
||||
})
|
||||
@@ -365,6 +365,11 @@ export const RunCommand = cmd({
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "edit",
|
||||
action: "allow",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Filesystem } from "../../util/filesystem"
|
||||
import { Process } from "../../util/process"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { which } from "../../util/which"
|
||||
|
||||
function pagerCmd(): string[] {
|
||||
const lessOptions = ["-R", "-S"]
|
||||
@@ -17,7 +18,7 @@ function pagerCmd(): string[] {
|
||||
}
|
||||
|
||||
// user could have less installed via other options
|
||||
const lessOnPath = Bun.which("less")
|
||||
const lessOnPath = which("less")
|
||||
if (lessOnPath) {
|
||||
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
|
||||
}
|
||||
@@ -27,7 +28,7 @@ function pagerCmd(): string[] {
|
||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||
}
|
||||
|
||||
const git = Bun.which("git")
|
||||
const git = which("git")
|
||||
if (git) {
|
||||
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
|
||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||
|
||||
@@ -462,6 +462,7 @@ function App() {
|
||||
{
|
||||
title: "Toggle MCPs",
|
||||
value: "mcp.list",
|
||||
search: "toggle mcps",
|
||||
category: "Agent",
|
||||
slash: {
|
||||
name: "mcps",
|
||||
@@ -537,8 +538,9 @@ function App() {
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Toggle appearance",
|
||||
title: mode() === "dark" ? "Light mode" : "Dark mode",
|
||||
value: "theme.switch_mode",
|
||||
search: "toggle appearance",
|
||||
onSelect: (dialog) => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
dialog.clear()
|
||||
@@ -577,6 +579,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle debug panel",
|
||||
search: "toggle debug",
|
||||
category: "System",
|
||||
value: "app.debug",
|
||||
onSelect: (dialog) => {
|
||||
@@ -586,6 +589,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle console",
|
||||
search: "toggle console",
|
||||
category: "System",
|
||||
value: "app.console",
|
||||
onSelect: (dialog) => {
|
||||
@@ -626,6 +630,7 @@ function App() {
|
||||
{
|
||||
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
||||
value: "terminal.title.toggle",
|
||||
search: "toggle terminal title",
|
||||
keybind: "terminal_title_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
@@ -641,6 +646,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
||||
value: "app.toggle.animations",
|
||||
search: "toggle animations",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
||||
@@ -650,6 +656,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
value: "app.toggle.diffwrap",
|
||||
search: "toggle diff wrapping",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
const current = kv.get("diff_wrap_mode", "word")
|
||||
|
||||
@@ -7,6 +7,27 @@ import { useDialog } from "@tui/ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import type { Provider } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function pickLatest(models: [string, Provider["models"][string]][]) {
|
||||
const picks: Record<string, [string, Provider["models"][string]]> = {}
|
||||
for (const item of models) {
|
||||
const model = item[0]
|
||||
const info = item[1]
|
||||
const key = info.family ?? model
|
||||
const prev = picks[key]
|
||||
if (!prev) {
|
||||
picks[key] = item
|
||||
continue
|
||||
}
|
||||
if (info.release_date !== prev[1].release_date) {
|
||||
if (info.release_date > prev[1].release_date) picks[key] = item
|
||||
continue
|
||||
}
|
||||
if (model > prev[0]) picks[key] = item
|
||||
}
|
||||
return Object.values(picks)
|
||||
}
|
||||
|
||||
export function useConnected() {
|
||||
const sync = useSync()
|
||||
@@ -21,6 +42,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [all, setAll] = createSignal(false)
|
||||
|
||||
const connected = useConnected()
|
||||
const providers = createDialogProviderOptions()
|
||||
@@ -72,8 +94,8 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
flatMap((provider) => {
|
||||
const items = pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
filter(([_, info]) => info.status !== "deprecated"),
|
||||
@@ -104,8 +126,9 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
(x) => x.footer !== "Free",
|
||||
(x) => x.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return items
|
||||
}),
|
||||
)
|
||||
|
||||
const popularProviders = !connected()
|
||||
@@ -154,6 +177,13 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.model_show_all_toggle?.[0],
|
||||
title: all() ? "Show latest only" : "Show all models",
|
||||
onTrigger: () => {
|
||||
setAll((value) => !value)
|
||||
},
|
||||
},
|
||||
]}
|
||||
onFilter={setQuery}
|
||||
flat={true}
|
||||
|
||||
@@ -77,6 +77,7 @@ export function Prompt(props: PromptProps) {
|
||||
const renderer = useRenderer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
@@ -170,6 +171,17 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
|
||||
value: "permission.auto_accept.toggle",
|
||||
search: "toggle permissions",
|
||||
keybind: "permission_auto_accept_toggle",
|
||||
category: "Agent",
|
||||
onSelect: (dialog) => {
|
||||
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
@@ -996,23 +1008,30 @@ export function Prompt(props: PromptProps) {
|
||||
cursorColor={theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { useKV } from "./kv"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
@@ -103,6 +104,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
@@ -127,6 +130,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
@@ -441,6 +451,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
get ready() {
|
||||
return store.status !== "loading"
|
||||
},
|
||||
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
|
||||
@@ -46,6 +46,7 @@ export function Home() {
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
value: "tips.toggle",
|
||||
search: "toggle tips",
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
|
||||
@@ -153,7 +153,7 @@ export function Session() {
|
||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
|
||||
const [showHeader, setShowHeader] = kv.signal("header_visible", true)
|
||||
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
||||
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
||||
@@ -552,6 +552,7 @@ export function Session() {
|
||||
{
|
||||
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
|
||||
value: "session.sidebar.toggle",
|
||||
search: "toggle sidebar",
|
||||
keybind: "sidebar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -566,6 +567,7 @@ export function Session() {
|
||||
{
|
||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
search: "toggle code concealment",
|
||||
keybind: "messages_toggle_conceal" as any,
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -576,6 +578,7 @@ export function Session() {
|
||||
{
|
||||
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
|
||||
value: "session.toggle.timestamps",
|
||||
search: "toggle timestamps",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "timestamps",
|
||||
@@ -589,6 +592,7 @@ export function Session() {
|
||||
{
|
||||
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||
value: "session.toggle.thinking",
|
||||
search: "toggle thinking",
|
||||
keybind: "display_thinking",
|
||||
category: "Session",
|
||||
slash: {
|
||||
@@ -603,6 +607,7 @@ export function Session() {
|
||||
{
|
||||
title: showDetails() ? "Hide tool details" : "Show tool details",
|
||||
value: "session.toggle.actions",
|
||||
search: "toggle tool details",
|
||||
keybind: "tool_details",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -611,8 +616,9 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle session scrollbar",
|
||||
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
|
||||
value: "session.toggle.scrollbar",
|
||||
search: "toggle session scrollbar",
|
||||
keybind: "scrollbar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -1436,6 +1442,10 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
||||
streaming={true}
|
||||
content={props.part.text.trim()}
|
||||
conceal={ctx.conceal()}
|
||||
tableOptions={{
|
||||
widthMode: "full",
|
||||
columnFitter: "balanced",
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
||||
@@ -2025,7 +2035,9 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
|
||||
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
|
||||
Edit{" "}
|
||||
{normalizePath(props.input.filePath!)}{" "}
|
||||
{input({ replaceAll: "replaceAll" in props.input ? props.input.replaceAll : undefined })}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
disabled?: boolean
|
||||
@@ -85,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
keys: ["title", "category", "search"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../../../../util/filesystem"
|
||||
import { Process } from "../../../../util/process"
|
||||
import { which } from "../../../../util/which"
|
||||
|
||||
/**
|
||||
* Writes text to clipboard via OSC 52 escape sequence.
|
||||
@@ -76,7 +77,7 @@ export namespace Clipboard {
|
||||
const getCopyMethod = lazy(() => {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin" && Bun.which("osascript")) {
|
||||
if (os === "darwin" && which("osascript")) {
|
||||
console.log("clipboard: using osascript")
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
@@ -85,7 +86,7 @@ export namespace Clipboard {
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
|
||||
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
|
||||
console.log("clipboard: using wl-copy")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
@@ -95,7 +96,7 @@ export namespace Clipboard {
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (Bun.which("xclip")) {
|
||||
if (which("xclip")) {
|
||||
console.log("clipboard: using xclip")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
||||
@@ -109,7 +110,7 @@ export namespace Clipboard {
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (Bun.which("xsel")) {
|
||||
if (which("xsel")) {
|
||||
console.log("clipboard: using xsel")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { GlobalBus } from "@/bus/global"
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import type { BunWebSocketData } from "hono/bun"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
@@ -75,7 +76,7 @@ const startEventStream = (directory: string) => {
|
||||
).catch(() => undefined)
|
||||
|
||||
if (!events) {
|
||||
await Bun.sleep(250)
|
||||
await sleep(250)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -84,7 +85,7 @@ const startEventStream = (directory: string) => {
|
||||
}
|
||||
|
||||
if (!signal.aborted) {
|
||||
await Bun.sleep(250)
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
})().catch((error) => {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Glob } from "../util/glob"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Control } from "@/control"
|
||||
import { Account } from "@/account"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
@@ -107,10 +107,6 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
const token = await Control.token()
|
||||
if (token) {
|
||||
}
|
||||
|
||||
// Global user config overrides remote config.
|
||||
result = mergeConfigConcatArrays(result, await global())
|
||||
|
||||
@@ -177,6 +173,15 @@ export namespace Config {
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
const active = Account.active()
|
||||
if (active?.workspace_id) {
|
||||
const config = await Account.config(active.id, active.workspace_id)
|
||||
result = mergeConfigConcatArrays(result, config ?? {})
|
||||
const token = await Account.token(active.id)
|
||||
// TODO: this is bad
|
||||
process.env["OPENCODE_CONTROL_TOKEN"] = token
|
||||
}
|
||||
|
||||
// Load managed config files last (highest priority) - enterprise admin-controlled
|
||||
// Kept separate from directories array to avoid write operations when installing plugins
|
||||
// which would fail on system directories requiring elevated permissions
|
||||
@@ -771,6 +776,7 @@ export namespace Config {
|
||||
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
|
||||
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
|
||||
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
|
||||
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
|
||||
session_share: z.string().optional().default("none").describe("Share current session"),
|
||||
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
||||
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
|
||||
@@ -811,7 +817,12 @@ export namespace Config {
|
||||
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
|
||||
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
|
||||
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
|
||||
permission_auto_accept_toggle: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("shift+tab")
|
||||
.describe("Toggle auto-accept mode for permissions"),
|
||||
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
@@ -1150,6 +1161,16 @@ export namespace Config {
|
||||
.object({
|
||||
disable_paste_summary: z.boolean().optional(),
|
||||
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
|
||||
hashline_edit: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Enable hashline-backed edit/read tool behavior (default true, set false to disable)"),
|
||||
hashline_autocorrect: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Enable hashline autocorrect cleanup for copied prefixes and formatting artifacts (default true)",
|
||||
),
|
||||
openTelemetry: z
|
||||
.boolean()
|
||||
.optional()
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { Database } from "@/storage/db"
|
||||
import { ControlAccountTable } from "./control.sql"
|
||||
import z from "zod"
|
||||
|
||||
export * from "./control.sql"
|
||||
|
||||
export namespace Control {
|
||||
export const Account = z.object({
|
||||
email: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
export type Account = z.infer<typeof Account>
|
||||
|
||||
function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account {
|
||||
return {
|
||||
email: row.email,
|
||||
url: row.url,
|
||||
}
|
||||
}
|
||||
|
||||
export function account(): Account | undefined {
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
|
||||
)
|
||||
return row ? fromRow(row) : undefined
|
||||
}
|
||||
|
||||
export async function token(): Promise<string | undefined> {
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
|
||||
)
|
||||
if (!row) return undefined
|
||||
if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
|
||||
|
||||
const res = await fetch(`${row.url}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: row.refresh_token,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!res.ok) return
|
||||
|
||||
const json = (await res.json()) as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(ControlAccountTable)
|
||||
.set({
|
||||
access_token: json.access_token,
|
||||
refresh_token: json.refresh_token ?? row.refresh_token,
|
||||
token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
|
||||
})
|
||||
.where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url)))
|
||||
.run(),
|
||||
)
|
||||
|
||||
return json.access_token
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { lazy } from "../util/lazy"
|
||||
import { $ } from "bun"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { text } from "node:stream/consumers"
|
||||
|
||||
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
||||
@@ -126,7 +127,7 @@ export namespace Ripgrep {
|
||||
)
|
||||
|
||||
const state = lazy(async () => {
|
||||
const system = Bun.which("rg")
|
||||
const system = which("rg")
|
||||
if (system) {
|
||||
const stat = await fs.stat(system).catch(() => undefined)
|
||||
if (stat?.isFile()) return { filepath: system }
|
||||
|
||||
@@ -3,6 +3,11 @@ function truthy(key: string) {
|
||||
return value === "true" || value === "1"
|
||||
}
|
||||
|
||||
function falsy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
export namespace Flag {
|
||||
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
|
||||
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
|
||||
@@ -52,7 +57,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
||||
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
export interface Info {
|
||||
@@ -18,7 +19,7 @@ export const gofmt: Info = {
|
||||
command: ["gofmt", "-w", "$FILE"],
|
||||
extensions: [".go"],
|
||||
async enabled() {
|
||||
return Bun.which("gofmt") !== null
|
||||
return which("gofmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -27,7 +28,7 @@ export const mix: Info = {
|
||||
command: ["mix", "format", "$FILE"],
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
async enabled() {
|
||||
return Bun.which("mix") !== null
|
||||
return which("mix") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -152,7 +153,7 @@ export const zig: Info = {
|
||||
command: ["zig", "fmt", "$FILE"],
|
||||
extensions: [".zig", ".zon"],
|
||||
async enabled() {
|
||||
return Bun.which("zig") !== null
|
||||
return which("zig") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -171,7 +172,7 @@ export const ktlint: Info = {
|
||||
command: ["ktlint", "-F", "$FILE"],
|
||||
extensions: [".kt", ".kts"],
|
||||
async enabled() {
|
||||
return Bun.which("ktlint") !== null
|
||||
return which("ktlint") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -180,7 +181,7 @@ export const ruff: Info = {
|
||||
command: ["ruff", "format", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (!Bun.which("ruff")) return false
|
||||
if (!which("ruff")) return false
|
||||
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
@@ -210,7 +211,7 @@ export const rlang: Info = {
|
||||
command: ["air", "format", "$FILE"],
|
||||
extensions: [".R"],
|
||||
async enabled() {
|
||||
const airPath = Bun.which("air")
|
||||
const airPath = which("air")
|
||||
if (airPath == null) return false
|
||||
|
||||
try {
|
||||
@@ -239,7 +240,7 @@ export const uvformat: Info = {
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (await ruff.enabled()) return false
|
||||
if (Bun.which("uv") !== null) {
|
||||
if (which("uv") !== null) {
|
||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const code = await proc.exited
|
||||
return code === 0
|
||||
@@ -253,7 +254,7 @@ export const rubocop: Info = {
|
||||
command: ["rubocop", "--autocorrect", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return Bun.which("rubocop") !== null
|
||||
return which("rubocop") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -262,7 +263,7 @@ export const standardrb: Info = {
|
||||
command: ["standardrb", "--fix", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return Bun.which("standardrb") !== null
|
||||
return which("standardrb") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -271,7 +272,7 @@ export const htmlbeautifier: Info = {
|
||||
command: ["htmlbeautifier", "$FILE"],
|
||||
extensions: [".erb", ".html.erb"],
|
||||
async enabled() {
|
||||
return Bun.which("htmlbeautifier") !== null
|
||||
return which("htmlbeautifier") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -280,7 +281,7 @@ export const dart: Info = {
|
||||
command: ["dart", "format", "$FILE"],
|
||||
extensions: [".dart"],
|
||||
async enabled() {
|
||||
return Bun.which("dart") !== null
|
||||
return which("dart") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -289,7 +290,7 @@ export const ocamlformat: Info = {
|
||||
command: ["ocamlformat", "-i", "$FILE"],
|
||||
extensions: [".ml", ".mli"],
|
||||
async enabled() {
|
||||
if (!Bun.which("ocamlformat")) return false
|
||||
if (!which("ocamlformat")) return false
|
||||
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
},
|
||||
@@ -300,7 +301,7 @@ export const terraform: Info = {
|
||||
command: ["terraform", "fmt", "$FILE"],
|
||||
extensions: [".tf", ".tfvars"],
|
||||
async enabled() {
|
||||
return Bun.which("terraform") !== null
|
||||
return which("terraform") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -309,7 +310,7 @@ export const latexindent: Info = {
|
||||
command: ["latexindent", "-w", "-s", "$FILE"],
|
||||
extensions: [".tex"],
|
||||
async enabled() {
|
||||
return Bun.which("latexindent") !== null
|
||||
return which("latexindent") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -318,7 +319,7 @@ export const gleam: Info = {
|
||||
command: ["gleam", "format", "$FILE"],
|
||||
extensions: [".gleam"],
|
||||
async enabled() {
|
||||
return Bun.which("gleam") !== null
|
||||
return which("gleam") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -327,7 +328,7 @@ export const shfmt: Info = {
|
||||
command: ["shfmt", "-w", "$FILE"],
|
||||
extensions: [".sh", ".bash"],
|
||||
async enabled() {
|
||||
return Bun.which("shfmt") !== null
|
||||
return which("shfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -336,7 +337,7 @@ export const nixfmt: Info = {
|
||||
command: ["nixfmt", "$FILE"],
|
||||
extensions: [".nix"],
|
||||
async enabled() {
|
||||
return Bun.which("nixfmt") !== null
|
||||
return which("nixfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -345,7 +346,7 @@ export const rustfmt: Info = {
|
||||
command: ["rustfmt", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
return Bun.which("rustfmt") !== null
|
||||
return which("rustfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -372,7 +373,7 @@ export const ormolu: Info = {
|
||||
command: ["ormolu", "-i", "$FILE"],
|
||||
extensions: [".hs"],
|
||||
async enabled() {
|
||||
return Bun.which("ormolu") !== null
|
||||
return which("ormolu") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -381,7 +382,7 @@ export const cljfmt: Info = {
|
||||
command: ["cljfmt", "fix", "--quiet", "$FILE"],
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
async enabled() {
|
||||
return Bun.which("cljfmt") !== null
|
||||
return which("cljfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -390,6 +391,6 @@ export const dfmt: Info = {
|
||||
command: ["dfmt", "-i", "$FILE"],
|
||||
extensions: [".d"],
|
||||
async enabled() {
|
||||
return Bun.which("dfmt") !== null
|
||||
return which("dfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { Log } from "./util/log"
|
||||
import { AuthCommand } from "./cli/cmd/auth"
|
||||
import { LoginCommand, LogoutCommand, SwitchCommand, WorkspacesCommand } from "./cli/cmd/account"
|
||||
import { ProvidersCommand } from "./cli/cmd/providers"
|
||||
import { AgentCommand } from "./cli/cmd/agent"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
import { UninstallCommand } from "./cli/cmd/uninstall"
|
||||
@@ -128,7 +129,11 @@ let cli = yargs(hideBin(process.argv))
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
.command(DebugCommand)
|
||||
.command(AuthCommand)
|
||||
.command(LoginCommand)
|
||||
.command(LogoutCommand)
|
||||
.command(SwitchCommand)
|
||||
.command(WorkspacesCommand)
|
||||
.command(ProvidersCommand)
|
||||
.command(AgentCommand)
|
||||
.command(UpgradeCommand)
|
||||
.command(UninstallCommand)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Archive } from "../util/archive"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -75,7 +76,7 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
async spawn(root) {
|
||||
const deno = Bun.which("deno")
|
||||
const deno = which("deno")
|
||||
if (!deno) {
|
||||
log.info("deno not found, please install deno first")
|
||||
return
|
||||
@@ -122,7 +123,7 @@ export namespace LSPServer {
|
||||
extensions: [".vue"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("vue-language-server")
|
||||
let binary = which("vue-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
@@ -260,7 +261,7 @@ export namespace LSPServer {
|
||||
|
||||
let lintBin = await resolveBin(lintTarget)
|
||||
if (!lintBin) {
|
||||
const found = Bun.which("oxlint")
|
||||
const found = which("oxlint")
|
||||
if (found) lintBin = found
|
||||
}
|
||||
|
||||
@@ -281,7 +282,7 @@ export namespace LSPServer {
|
||||
|
||||
let serverBin = await resolveBin(serverTarget)
|
||||
if (!serverBin) {
|
||||
const found = Bun.which("oxc_language_server")
|
||||
const found = which("oxc_language_server")
|
||||
if (found) serverBin = found
|
||||
}
|
||||
if (serverBin) {
|
||||
@@ -332,7 +333,7 @@ export namespace LSPServer {
|
||||
let bin: string | undefined
|
||||
if (await Filesystem.exists(localBin)) bin = localBin
|
||||
if (!bin) {
|
||||
const found = Bun.which("biome")
|
||||
const found = which("biome")
|
||||
if (found) bin = found
|
||||
}
|
||||
|
||||
@@ -368,11 +369,11 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("gopls", {
|
||||
let bin = which("gopls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
if (!Bun.which("go")) return
|
||||
if (!which("go")) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
|
||||
log.info("installing gopls")
|
||||
@@ -405,12 +406,12 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("rubocop", {
|
||||
let bin = which("rubocop", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
const ruby = Bun.which("ruby")
|
||||
const gem = Bun.which("gem")
|
||||
const ruby = which("ruby")
|
||||
const gem = which("gem")
|
||||
if (!ruby || !gem) {
|
||||
log.info("Ruby not found, please install Ruby first")
|
||||
return
|
||||
@@ -457,7 +458,7 @@ export namespace LSPServer {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let binary = Bun.which("ty")
|
||||
let binary = which("ty")
|
||||
|
||||
const initialization: Record<string, string> = {}
|
||||
|
||||
@@ -509,7 +510,7 @@ export namespace LSPServer {
|
||||
extensions: [".py", ".pyi"],
|
||||
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("pyright-langserver")
|
||||
let binary = which("pyright-langserver")
|
||||
const args = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
||||
@@ -563,7 +564,7 @@ export namespace LSPServer {
|
||||
extensions: [".ex", ".exs"],
|
||||
root: NearestRoot(["mix.exs", "mix.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("elixir-ls")
|
||||
let binary = which("elixir-ls")
|
||||
if (!binary) {
|
||||
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
|
||||
binary = path.join(
|
||||
@@ -574,7 +575,7 @@ export namespace LSPServer {
|
||||
)
|
||||
|
||||
if (!(await Filesystem.exists(binary))) {
|
||||
const elixir = Bun.which("elixir")
|
||||
const elixir = which("elixir")
|
||||
if (!elixir) {
|
||||
log.error("elixir is required to run elixir-ls")
|
||||
return
|
||||
@@ -625,12 +626,12 @@ export namespace LSPServer {
|
||||
extensions: [".zig", ".zon"],
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("zls", {
|
||||
let bin = which("zls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
if (!bin) {
|
||||
const zig = Bun.which("zig")
|
||||
const zig = which("zig")
|
||||
if (!zig) {
|
||||
log.error("Zig is required to use zls. Please install Zig first.")
|
||||
return
|
||||
@@ -737,11 +738,11 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("csharp-ls", {
|
||||
let bin = which("csharp-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
if (!Bun.which("dotnet")) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install csharp-ls")
|
||||
return
|
||||
}
|
||||
@@ -776,11 +777,11 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("fsautocomplete", {
|
||||
let bin = which("fsautocomplete", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
if (!Bun.which("dotnet")) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install fsautocomplete")
|
||||
return
|
||||
}
|
||||
@@ -817,7 +818,7 @@ export namespace LSPServer {
|
||||
async spawn(root) {
|
||||
// Check if sourcekit-lsp is available in the PATH
|
||||
// This is installed with the Swift toolchain
|
||||
const sourcekit = Bun.which("sourcekit-lsp")
|
||||
const sourcekit = which("sourcekit-lsp")
|
||||
if (sourcekit) {
|
||||
return {
|
||||
process: spawn(sourcekit, {
|
||||
@@ -828,7 +829,7 @@ export namespace LSPServer {
|
||||
|
||||
// If sourcekit-lsp not found, check if xcrun is available
|
||||
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode
|
||||
if (!Bun.which("xcrun")) return
|
||||
if (!which("xcrun")) return
|
||||
|
||||
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
|
||||
|
||||
@@ -877,7 +878,7 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".rs"],
|
||||
async spawn(root) {
|
||||
const bin = Bun.which("rust-analyzer")
|
||||
const bin = which("rust-analyzer")
|
||||
if (!bin) {
|
||||
log.info("rust-analyzer not found in path, please install it")
|
||||
return
|
||||
@@ -896,7 +897,7 @@ export namespace LSPServer {
|
||||
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
||||
async spawn(root) {
|
||||
const args = ["--background-index", "--clang-tidy"]
|
||||
const fromPath = Bun.which("clangd")
|
||||
const fromPath = which("clangd")
|
||||
if (fromPath) {
|
||||
return {
|
||||
process: spawn(fromPath, args, {
|
||||
@@ -1041,7 +1042,7 @@ export namespace LSPServer {
|
||||
extensions: [".svelte"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("svelteserver")
|
||||
let binary = which("svelteserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
|
||||
@@ -1088,7 +1089,7 @@ export namespace LSPServer {
|
||||
}
|
||||
const tsdk = path.dirname(tsserver)
|
||||
|
||||
let binary = Bun.which("astro-ls")
|
||||
let binary = which("astro-ls")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
|
||||
@@ -1132,7 +1133,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
|
||||
extensions: [".java"],
|
||||
async spawn(root) {
|
||||
const java = Bun.which("java")
|
||||
const java = which("java")
|
||||
if (!java) {
|
||||
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
|
||||
return
|
||||
@@ -1324,7 +1325,7 @@ export namespace LSPServer {
|
||||
extensions: [".yaml", ".yml"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("yaml-language-server")
|
||||
let binary = which("yaml-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
@@ -1380,7 +1381,7 @@ export namespace LSPServer {
|
||||
]),
|
||||
extensions: [".lua"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("lua-language-server", {
|
||||
let bin = which("lua-language-server", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -1512,7 +1513,7 @@ export namespace LSPServer {
|
||||
extensions: [".php"],
|
||||
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("intelephense")
|
||||
let binary = which("intelephense")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
|
||||
@@ -1556,7 +1557,7 @@ export namespace LSPServer {
|
||||
extensions: [".prisma"],
|
||||
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
|
||||
async spawn(root) {
|
||||
const prisma = Bun.which("prisma")
|
||||
const prisma = which("prisma")
|
||||
if (!prisma) {
|
||||
log.info("prisma not found, please install prisma")
|
||||
return
|
||||
@@ -1574,7 +1575,7 @@ export namespace LSPServer {
|
||||
extensions: [".dart"],
|
||||
root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]),
|
||||
async spawn(root) {
|
||||
const dart = Bun.which("dart")
|
||||
const dart = which("dart")
|
||||
if (!dart) {
|
||||
log.info("dart not found, please install dart first")
|
||||
return
|
||||
@@ -1592,7 +1593,7 @@ export namespace LSPServer {
|
||||
extensions: [".ml", ".mli"],
|
||||
root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]),
|
||||
async spawn(root) {
|
||||
const bin = Bun.which("ocamllsp")
|
||||
const bin = which("ocamllsp")
|
||||
if (!bin) {
|
||||
log.info("ocamllsp not found, please install ocaml-lsp-server")
|
||||
return
|
||||
@@ -1609,7 +1610,7 @@ export namespace LSPServer {
|
||||
extensions: [".sh", ".bash", ".zsh", ".ksh"],
|
||||
root: async () => Instance.directory,
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("bash-language-server")
|
||||
let binary = which("bash-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
|
||||
@@ -1648,7 +1649,7 @@ export namespace LSPServer {
|
||||
extensions: [".tf", ".tfvars"],
|
||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("terraform-ls", {
|
||||
let bin = which("terraform-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -1731,7 +1732,7 @@ export namespace LSPServer {
|
||||
extensions: [".tex", ".bib"],
|
||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("texlab", {
|
||||
let bin = which("texlab", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -1821,7 +1822,7 @@ export namespace LSPServer {
|
||||
extensions: [".dockerfile", "Dockerfile"],
|
||||
root: async () => Instance.directory,
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("docker-langserver")
|
||||
let binary = which("docker-langserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
|
||||
@@ -1860,7 +1861,7 @@ export namespace LSPServer {
|
||||
extensions: [".gleam"],
|
||||
root: NearestRoot(["gleam.toml"]),
|
||||
async spawn(root) {
|
||||
const gleam = Bun.which("gleam")
|
||||
const gleam = which("gleam")
|
||||
if (!gleam) {
|
||||
log.info("gleam not found, please install gleam first")
|
||||
return
|
||||
@@ -1878,9 +1879,9 @@ export namespace LSPServer {
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("clojure-lsp")
|
||||
let bin = which("clojure-lsp")
|
||||
if (!bin && process.platform === "win32") {
|
||||
bin = Bun.which("clojure-lsp.exe")
|
||||
bin = which("clojure-lsp.exe")
|
||||
}
|
||||
if (!bin) {
|
||||
log.info("clojure-lsp not found, please install clojure-lsp first")
|
||||
@@ -1909,7 +1910,7 @@ export namespace LSPServer {
|
||||
return Instance.directory
|
||||
},
|
||||
async spawn(root) {
|
||||
const nixd = Bun.which("nixd")
|
||||
const nixd = which("nixd")
|
||||
if (!nixd) {
|
||||
log.info("nixd not found, please install nixd first")
|
||||
return
|
||||
@@ -1930,7 +1931,7 @@ export namespace LSPServer {
|
||||
extensions: [".typ", ".typc"],
|
||||
root: NearestRoot(["typst.toml"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("tinymist", {
|
||||
let bin = which("tinymist", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -2024,7 +2025,7 @@ export namespace LSPServer {
|
||||
extensions: [".hs", ".lhs"],
|
||||
root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
|
||||
async spawn(root) {
|
||||
const bin = Bun.which("haskell-language-server-wrapper")
|
||||
const bin = which("haskell-language-server-wrapper")
|
||||
if (!bin) {
|
||||
log.info("haskell-language-server-wrapper not found, please install haskell-language-server")
|
||||
return
|
||||
@@ -2042,7 +2043,7 @@ export namespace LSPServer {
|
||||
extensions: [".jl"],
|
||||
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
|
||||
async spawn(root) {
|
||||
const julia = Bun.which("julia")
|
||||
const julia = which("julia")
|
||||
if (!julia) {
|
||||
log.info("julia not found, please install julia first (https://julialang.org/downloads/)")
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Installation } from "../installation"
|
||||
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
|
||||
import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -602,7 +603,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
|
||||
await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { Installation } from "@/installation"
|
||||
import { iife } from "@/util/iife"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
||||
// Add a small safety buffer when polling to avoid hitting the server
|
||||
@@ -270,7 +271,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
}
|
||||
|
||||
if (data.error === "authorization_pending") {
|
||||
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -286,13 +287,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
newInterval = serverInterval * 1000
|
||||
}
|
||||
|
||||
await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
if (data.error) return { type: "failed" as const }
|
||||
|
||||
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
continue
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GlobalBus } from "@/bus/global"
|
||||
import { existsSync } from "fs"
|
||||
import { git } from "../util/git"
|
||||
import { Glob } from "../util/glob"
|
||||
import { which } from "../util/which"
|
||||
|
||||
export namespace Project {
|
||||
const log = Log.create({ service: "project" })
|
||||
@@ -97,7 +98,7 @@ export namespace Project {
|
||||
if (dotgit) {
|
||||
let sandbox = path.dirname(dotgit)
|
||||
|
||||
const gitBinary = Bun.which("git")
|
||||
const gitBinary = which("git")
|
||||
|
||||
// cached id calculation
|
||||
let id = await Filesystem.readText(path.join(dotgit, "opencode"))
|
||||
|
||||
@@ -263,13 +263,19 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
time: z
|
||||
.object({
|
||||
archived: z.number().optional(),
|
||||
archived: z.number().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
@@ -282,8 +288,8 @@ export const SessionRoutes = lazy(() =>
|
||||
if (updates.title !== undefined) {
|
||||
session = await Session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
session = await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||
if (updates.time !== undefined && "archived" in updates.time) {
|
||||
session = await Session.setArchived({ sessionID, time: updates.time.archived ?? undefined })
|
||||
}
|
||||
|
||||
return c.json(session)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Flag } from "../flag/flag"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||
import { Database, NotFoundError, eq, and, or, gte, isNull, isNotNull, desc, like, inArray, lt } from "../storage/db"
|
||||
import type { SQL } from "../storage/db"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
@@ -401,7 +401,7 @@ export namespace Session {
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({ time_archived: input.time })
|
||||
.set({ time_archived: input.time ?? null })
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
@@ -599,6 +599,9 @@ export namespace Session {
|
||||
if (input?.search) {
|
||||
conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
}
|
||||
if (input?.archived) {
|
||||
conditions.push(isNotNull(SessionTable.time_archived))
|
||||
}
|
||||
if (!input?.archived) {
|
||||
conditions.push(isNull(SessionTable.time_archived))
|
||||
}
|
||||
|
||||
@@ -315,11 +315,7 @@ export namespace SessionPrompt {
|
||||
}
|
||||
|
||||
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
|
||||
if (
|
||||
lastAssistant?.finish &&
|
||||
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
|
||||
lastUser.id < lastAssistant.id
|
||||
) {
|
||||
if (shouldExitLoop(lastUser, lastAssistant)) {
|
||||
log.info("exiting loop", { sessionID })
|
||||
break
|
||||
}
|
||||
@@ -1956,4 +1952,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return Session.setTitle({ sessionID: input.session.id, title })
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for testing — determines whether the prompt loop should exit */
|
||||
export function shouldExitLoop(
|
||||
lastUser: MessageV2.User | undefined,
|
||||
lastAssistant: MessageV2.Assistant | undefined,
|
||||
): boolean {
|
||||
if (!lastUser) return false
|
||||
if (!lastAssistant?.finish) return false
|
||||
if (["tool-calls", "unknown"].includes(lastAssistant.finish)) return false
|
||||
return lastAssistant.parentID === lastUser.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
|
||||
## Editing constraints
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
- Prefer the edit tool for file edits. Use apply_patch only when it is available and clearly a better fit. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
|
||||
## Tool usage
|
||||
- Prefer specialized tools over shell for file operations:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { which } from "@/util/which"
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
|
||||
@@ -22,13 +24,13 @@ export namespace Shell {
|
||||
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM")
|
||||
await Bun.sleep(SIGKILL_TIMEOUT_MS)
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
}
|
||||
} catch (_e) {
|
||||
proc.kill("SIGTERM")
|
||||
await Bun.sleep(SIGKILL_TIMEOUT_MS)
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
proc.kill("SIGKILL")
|
||||
}
|
||||
@@ -39,7 +41,7 @@ export namespace Shell {
|
||||
function fallback() {
|
||||
if (process.platform === "win32") {
|
||||
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
|
||||
const git = Bun.which("git")
|
||||
const git = which("git")
|
||||
if (git) {
|
||||
// git.exe is typically at: C:\Program Files\Git\cmd\git.exe
|
||||
// bash.exe is at: C:\Program Files\Git\bin\bash.exe
|
||||
@@ -49,7 +51,7 @@ export namespace Shell {
|
||||
return process.env.COMSPEC || "cmd.exe"
|
||||
}
|
||||
if (process.platform === "darwin") return "/bin/zsh"
|
||||
const bash = Bun.which("bash")
|
||||
const bash = which("bash")
|
||||
if (bash) return bash
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||
import * as schema from "./schema"
|
||||
|
||||
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined
|
||||
|
||||
@@ -26,10 +25,8 @@ const log = Log.create({ service: "db" })
|
||||
|
||||
export namespace Database {
|
||||
export const Path = path.join(Global.Path.data, "opencode.db")
|
||||
type Schema = typeof schema
|
||||
export type Transaction = SQLiteTransaction<"sync", void, Schema>
|
||||
|
||||
type Client = SQLiteBunDatabase<Schema>
|
||||
type Client = SQLiteBunDatabase
|
||||
|
||||
type Journal = { sql: string; timestamp: number }[]
|
||||
|
||||
@@ -82,7 +79,7 @@ export namespace Database {
|
||||
sqlite.run("PRAGMA foreign_keys = ON")
|
||||
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
|
||||
const db = drizzle({ client: sqlite, schema })
|
||||
const db = drizzle({ client: sqlite })
|
||||
|
||||
// Apply schema migrations
|
||||
const entries =
|
||||
@@ -108,7 +105,7 @@ export namespace Database {
|
||||
Client.reset()
|
||||
}
|
||||
|
||||
export type TxOrDb = Transaction | Client
|
||||
export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
|
||||
|
||||
const ctx = Context.create<{
|
||||
tx: TxOrDb
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export { ControlAccountTable } from "../control/control.sql"
|
||||
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
|
||||
export { SessionShareTable } from "../share/share.sql"
|
||||
export { ProjectTable } from "../project/project.sql"
|
||||
export { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
@@ -17,72 +18,158 @@ import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import {
|
||||
HashlineEdit,
|
||||
applyHashlineEdits,
|
||||
hashlineOnlyCreates,
|
||||
parseHashlineContent,
|
||||
serializeHashlineContent,
|
||||
} from "./hashline"
|
||||
import { Config } from "../config/config"
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
const LEGACY_EDIT_MODE = "legacy"
|
||||
const HASHLINE_EDIT_MODE = "hashline"
|
||||
|
||||
const LegacyEditParams = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
})
|
||||
|
||||
const HashlineEditParams = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
edits: z.array(HashlineEdit).default([]),
|
||||
delete: z.boolean().optional(),
|
||||
rename: z.string().optional(),
|
||||
})
|
||||
|
||||
const EditParams = z
|
||||
.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().optional().describe("The text to replace"),
|
||||
newString: z.string().optional().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
edits: z.array(HashlineEdit).optional(),
|
||||
delete: z.boolean().optional(),
|
||||
rename: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
const legacy = value.oldString !== undefined || value.newString !== undefined || value.replaceAll !== undefined
|
||||
const hashline = value.edits !== undefined || value.delete !== undefined || value.rename !== undefined
|
||||
|
||||
if (legacy && hashline) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Do not mix legacy (oldString/newString) and hashline (edits/delete/rename) fields.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!legacy && !hashline) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Provide either legacy fields (oldString/newString) or hashline fields (edits/delete/rename).",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (legacy) {
|
||||
if (value.oldString === undefined || value.newString === undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Legacy payload requires both oldString and newString.",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (value.edits === undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Hashline payload requires edits (use [] when only delete is intended).",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type LegacyEditParams = z.infer<typeof LegacyEditParams>
|
||||
type HashlineEditParams = z.infer<typeof HashlineEditParams>
|
||||
type EditParams = z.infer<typeof EditParams>
|
||||
|
||||
function normalizeLineEndings(text: string): string {
|
||||
return text.replaceAll("\r\n", "\n")
|
||||
}
|
||||
|
||||
export const EditTool = Tool.define("edit", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
if (!params.filePath) {
|
||||
throw new Error("filePath is required")
|
||||
function isLegacyParams(params: EditParams): params is LegacyEditParams {
|
||||
return params.oldString !== undefined || params.newString !== undefined || params.replaceAll !== undefined
|
||||
}
|
||||
|
||||
async function withLocks(paths: string[], fn: () => Promise<void>) {
|
||||
const unique = Array.from(new Set(paths)).sort((a, b) => a.localeCompare(b))
|
||||
const recurse = async (idx: number): Promise<void> => {
|
||||
if (idx >= unique.length) return fn()
|
||||
await FileTime.withLock(unique[idx], () => recurse(idx + 1))
|
||||
}
|
||||
await recurse(0)
|
||||
}
|
||||
|
||||
function createFileDiff(file: string, before: string, after: string): Snapshot.FileDiff {
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
for (const change of diffLines(before, after)) {
|
||||
if (change.added) filediff.additions += change.count || 0
|
||||
if (change.removed) filediff.deletions += change.count || 0
|
||||
}
|
||||
return filediff
|
||||
}
|
||||
|
||||
async function diagnosticsOutput(filePath: string, output: string) {
|
||||
await LSP.touchFile(filePath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
const normalizedFilePath = Filesystem.normalizePath(filePath)
|
||||
const issues = diagnostics[normalizedFilePath] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length === 0) {
|
||||
return {
|
||||
output,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
if (params.oldString === params.newString) {
|
||||
throw new Error("No changes to apply: oldString and newString are identical.")
|
||||
}
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
return {
|
||||
output:
|
||||
output +
|
||||
`\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||
await assertExternalDirectory(ctx, filePath)
|
||||
async function executeLegacy(params: LegacyEditParams, ctx: Tool.Context) {
|
||||
if (params.oldString === params.newString) {
|
||||
throw new Error("No changes to apply: oldString and newString are identical.")
|
||||
}
|
||||
|
||||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
await FileTime.withLock(filePath, async () => {
|
||||
if (params.oldString === "") {
|
||||
const existed = await Filesystem.exists(filePath)
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
await Filesystem.write(filePath, params.newString)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
return
|
||||
}
|
||||
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||
await assertExternalDirectory(ctx, filePath)
|
||||
|
||||
const stats = Filesystem.stat(filePath)
|
||||
if (!stats) throw new Error(`File ${filePath} not found`)
|
||||
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
contentOld = await Filesystem.readText(filePath)
|
||||
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
|
||||
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
await FileTime.withLock(filePath, async () => {
|
||||
if (params.oldString === "") {
|
||||
const existed = await Filesystem.exists(filePath)
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
@@ -92,64 +179,319 @@ export const EditTool = Tool.define("edit", {
|
||||
diff,
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.write(filePath, contentNew)
|
||||
await Filesystem.write(filePath, params.newString)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
contentNew = await Filesystem.readText(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
})
|
||||
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file: filePath,
|
||||
before: contentOld,
|
||||
after: contentNew,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
for (const change of diffLines(contentOld, contentNew)) {
|
||||
if (change.added) filediff.additions += change.count || 0
|
||||
if (change.removed) filediff.deletions += change.count || 0
|
||||
return
|
||||
}
|
||||
|
||||
ctx.metadata({
|
||||
const stats = Filesystem.stat(filePath)
|
||||
if (!stats) throw new Error(`File ${filePath} not found`)
|
||||
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
contentOld = await Filesystem.readText(filePath)
|
||||
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
|
||||
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
diff,
|
||||
filediff,
|
||||
diagnostics: {},
|
||||
},
|
||||
})
|
||||
|
||||
let output = "Edit applied successfully."
|
||||
await LSP.touchFile(filePath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
const normalizedFilePath = Filesystem.normalizePath(filePath)
|
||||
const issues = diagnostics[normalizedFilePath] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length > 0) {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
await Filesystem.write(filePath, contentNew)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
})
|
||||
contentNew = await Filesystem.readText(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
})
|
||||
|
||||
const filediff = createFileDiff(filePath, contentOld, contentNew)
|
||||
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
diff,
|
||||
filediff,
|
||||
diagnostics: {},
|
||||
edit_mode: LEGACY_EDIT_MODE,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await diagnosticsOutput(filePath, "Edit applied successfully.")
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics: result.diagnostics,
|
||||
diff,
|
||||
filediff,
|
||||
edit_mode: LEGACY_EDIT_MODE,
|
||||
},
|
||||
title: `${path.relative(Instance.worktree, filePath)}`,
|
||||
output: result.output,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeHashline(
|
||||
params: HashlineEditParams,
|
||||
ctx: Tool.Context,
|
||||
autocorrect: boolean,
|
||||
aggressiveAutocorrect: boolean,
|
||||
) {
|
||||
const sourcePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||
const targetPath = params.rename
|
||||
? path.isAbsolute(params.rename)
|
||||
? params.rename
|
||||
: path.join(Instance.directory, params.rename)
|
||||
: sourcePath
|
||||
|
||||
await assertExternalDirectory(ctx, sourcePath)
|
||||
if (params.rename) {
|
||||
await assertExternalDirectory(ctx, targetPath)
|
||||
}
|
||||
|
||||
if (params.delete && params.edits.length > 0) {
|
||||
throw new Error("delete=true cannot be combined with edits")
|
||||
}
|
||||
if (params.delete && params.rename) {
|
||||
throw new Error("delete=true cannot be combined with rename")
|
||||
}
|
||||
|
||||
let diff = ""
|
||||
let before = ""
|
||||
let after = ""
|
||||
let noop = 0
|
||||
let deleted = false
|
||||
let changed = false
|
||||
let diagnostics: Awaited<ReturnType<typeof LSP.diagnostics>> = {}
|
||||
const paths = [sourcePath, targetPath]
|
||||
await withLocks(paths, async () => {
|
||||
const sourceStat = Filesystem.stat(sourcePath)
|
||||
if (sourceStat?.isDirectory()) throw new Error(`Path is a directory, not a file: ${sourcePath}`)
|
||||
const exists = Boolean(sourceStat)
|
||||
|
||||
if (params.rename && !exists) {
|
||||
throw new Error("rename requires an existing source file")
|
||||
}
|
||||
|
||||
if (params.delete) {
|
||||
if (!exists) {
|
||||
noop = 1
|
||||
return
|
||||
}
|
||||
await FileTime.assert(ctx.sessionID, sourcePath)
|
||||
before = await Filesystem.readText(sourcePath)
|
||||
after = ""
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)),
|
||||
)
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, sourcePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: sourcePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
await fs.rm(sourcePath, { force: true })
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: sourcePath,
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: sourcePath,
|
||||
event: "unlink",
|
||||
})
|
||||
deleted = true
|
||||
changed = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!exists && !hashlineOnlyCreates(params.edits)) {
|
||||
throw new Error("Missing file can only be created with append/prepend hashline edits")
|
||||
}
|
||||
if (exists) {
|
||||
await FileTime.assert(ctx.sessionID, sourcePath)
|
||||
}
|
||||
|
||||
const parsed = exists
|
||||
? parseHashlineContent(await Filesystem.readBytes(sourcePath))
|
||||
: {
|
||||
bom: false,
|
||||
eol: "\n",
|
||||
trailing: false,
|
||||
lines: [] as string[],
|
||||
text: "",
|
||||
raw: "",
|
||||
}
|
||||
|
||||
before = parsed.raw
|
||||
const next = applyHashlineEdits({
|
||||
lines: parsed.lines,
|
||||
trailing: parsed.trailing,
|
||||
edits: params.edits,
|
||||
autocorrect,
|
||||
aggressiveAutocorrect,
|
||||
})
|
||||
const output = serializeHashlineContent({
|
||||
lines: next.lines,
|
||||
trailing: next.trailing,
|
||||
eol: parsed.eol,
|
||||
bom: parsed.bom,
|
||||
})
|
||||
after = output.text
|
||||
|
||||
const noContentChange = before === after && sourcePath === targetPath
|
||||
if (noContentChange) {
|
||||
noop = 1
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(sourcePath, targetPath, normalizeLineEndings(before), normalizeLineEndings(after)),
|
||||
)
|
||||
const patterns = [path.relative(Instance.worktree, sourcePath)]
|
||||
if (sourcePath !== targetPath) patterns.push(path.relative(Instance.worktree, targetPath))
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: Array.from(new Set(patterns)),
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: sourcePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
|
||||
if (sourcePath === targetPath) {
|
||||
await Filesystem.write(sourcePath, output.bytes)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: sourcePath,
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: sourcePath,
|
||||
event: exists ? "change" : "add",
|
||||
})
|
||||
FileTime.read(ctx.sessionID, sourcePath)
|
||||
changed = true
|
||||
return
|
||||
}
|
||||
|
||||
const targetExists = await Filesystem.exists(targetPath)
|
||||
await Filesystem.write(targetPath, output.bytes)
|
||||
await fs.rm(sourcePath, { force: true })
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: sourcePath,
|
||||
})
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: targetPath,
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: sourcePath,
|
||||
event: "unlink",
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: targetPath,
|
||||
event: targetExists ? "change" : "add",
|
||||
})
|
||||
FileTime.read(ctx.sessionID, targetPath)
|
||||
changed = true
|
||||
})
|
||||
|
||||
const file = deleted ? sourcePath : targetPath
|
||||
const filediff = createFileDiff(file, before, after)
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
diff,
|
||||
filediff,
|
||||
diagnostics,
|
||||
edit_mode: HASHLINE_EDIT_MODE,
|
||||
noop,
|
||||
},
|
||||
})
|
||||
|
||||
if (!deleted && (changed || noop === 0)) {
|
||||
const result = await diagnosticsOutput(targetPath, noop > 0 ? "No changes applied." : "Edit applied successfully.")
|
||||
diagnostics = result.diagnostics
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics,
|
||||
diff,
|
||||
filediff,
|
||||
edit_mode: HASHLINE_EDIT_MODE,
|
||||
noop,
|
||||
},
|
||||
title: `${path.relative(Instance.worktree, filePath)}`,
|
||||
output,
|
||||
title: `${path.relative(Instance.worktree, targetPath)}`,
|
||||
output: result.output,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics,
|
||||
diff,
|
||||
filediff,
|
||||
edit_mode: HASHLINE_EDIT_MODE,
|
||||
noop,
|
||||
},
|
||||
title: `${path.relative(Instance.worktree, file)}`,
|
||||
output: deleted ? "Edit applied successfully." : "No changes applied.",
|
||||
}
|
||||
}
|
||||
|
||||
export const EditTool = Tool.define("edit", {
|
||||
description: DESCRIPTION,
|
||||
parameters: EditParams,
|
||||
async execute(params, ctx) {
|
||||
if (!params.filePath) {
|
||||
throw new Error("filePath is required")
|
||||
}
|
||||
|
||||
if (isLegacyParams(params)) {
|
||||
return executeLegacy(params, ctx)
|
||||
}
|
||||
|
||||
const config = await Config.get()
|
||||
if (config.experimental?.hashline_edit === false) {
|
||||
throw new Error(
|
||||
"Hashline edit payload is disabled. Set experimental.hashline_edit to true to use hashline operations.",
|
||||
)
|
||||
}
|
||||
|
||||
const hashlineParams: HashlineEditParams = {
|
||||
filePath: params.filePath,
|
||||
edits: params.edits ?? [],
|
||||
delete: params.delete,
|
||||
rename: params.rename,
|
||||
}
|
||||
|
||||
return executeHashline(
|
||||
hashlineParams,
|
||||
ctx,
|
||||
config.experimental?.hashline_autocorrect !== false || Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
|
||||
Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
Performs exact string replacements in files.
|
||||
Performs file edits with two supported payload schemas.
|
||||
|
||||
Usage:
|
||||
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
|
||||
- You must use your `Read` tool at least once before editing an existing file. This tool rejects stale edits when file contents changed since read.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
|
||||
- The edit will FAIL if `oldString` is found multiple times in the file with an error "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match." Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
|
||||
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
|
||||
Legacy schema (always supported):
|
||||
- `{ filePath, oldString, newString, replaceAll? }`
|
||||
- Exact replacement only.
|
||||
- The edit fails if `oldString` is not found.
|
||||
- The edit fails if `oldString` matches multiple locations and `replaceAll` is not true.
|
||||
- Use `replaceAll: true` for global replacements.
|
||||
|
||||
Hashline schema (default behavior):
|
||||
- `{ filePath, edits, delete?, rename? }`
|
||||
- Do not mix legacy fields (`oldString/newString/replaceAll`) with hashline fields (`edits/delete/rename`) in one call.
|
||||
- Use strict anchor references from `Read` output: `LINE#ID`.
|
||||
- Hashline mode can be turned off with `experimental.hashline_edit: false`.
|
||||
- Autocorrect cleanup is on by default and can be turned off with `experimental.hashline_autocorrect: false`.
|
||||
- Default autocorrect only strips copied `LINE#ID:`/`>>>` prefixes; set `OPENCODE_HL_AUTOCORRECT=1` to opt into heavier cleanup heuristics.
|
||||
- When `Read` returns `LINE#ID:<content>`, prefer hashline operations.
|
||||
- Operations:
|
||||
- `set_line { line, text }`
|
||||
- `replace_lines { start_line, end_line, text }`
|
||||
- `insert_after { line, text }`
|
||||
- `insert_before { line, text }`
|
||||
- `insert_between { after_line, before_line, text }`
|
||||
- `append { text }`
|
||||
- `prepend { text }`
|
||||
- `replace { old_text, new_text, all? }`
|
||||
- In hashline mode, provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and should be retried with the returned `retry with` anchors.
|
||||
- Fallback guidance: GPT-family models can use `apply_patch` as fallback; non-GPT models should fallback to legacy `oldString/newString` payloads.
|
||||
|
||||
@@ -9,6 +9,8 @@ import DESCRIPTION from "./grep.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import path from "path"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { Config } from "../config/config"
|
||||
import { hashlineRef } from "./hashline"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
@@ -116,6 +118,7 @@ export const GrepTool = Tool.define("grep", {
|
||||
}
|
||||
|
||||
const totalMatches = matches.length
|
||||
const useHashline = (await Config.get()).experimental?.hashline_edit !== false
|
||||
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
||||
|
||||
let currentFile = ""
|
||||
@@ -129,7 +132,11 @@ export const GrepTool = Tool.define("grep", {
|
||||
}
|
||||
const truncatedLineText =
|
||||
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
|
||||
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
|
||||
if (useHashline) {
|
||||
outputLines.push(` ${hashlineRef(match.lineNum, match.lineText)}:${truncatedLineText}`)
|
||||
} else {
|
||||
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
- Searches file contents using regular expressions
|
||||
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
|
||||
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
|
||||
- Returns file paths and line numbers with at least one match sorted by modification time
|
||||
- Returns file paths with matching lines sorted by modification time
|
||||
- Output format follows edit mode: `Line N:` when hashline mode is disabled, `N#ID:<content>` when hashline mode is enabled
|
||||
- Use this tool when you need to find files containing specific patterns
|
||||
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
|
||||
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
||||
|
||||
646
packages/opencode/src/tool/hashline.ts
Normal file
646
packages/opencode/src/tool/hashline.ts
Normal file
@@ -0,0 +1,646 @@
|
||||
// hashline autocorrect heuristics in this file are inspired by
|
||||
// https://github.com/can1357/oh-my-pi (mit license), adapted for opencode.
|
||||
|
||||
import z from "zod"
|
||||
|
||||
export const HASHLINE_ALPHABET = "ZPMQVRWSNKTXJBYH"
|
||||
|
||||
const HASHLINE_ID_LENGTH = 2
|
||||
const HASHLINE_ID_REGEX = new RegExp(`^[${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}}$`)
|
||||
const HASHLINE_REF_REGEX = new RegExp(`(\\d+)#([${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}})(?=$|\\s|:)`)
|
||||
const LOW_SIGNAL_CONTENT_RE = /^[^a-zA-Z0-9]+$/
|
||||
|
||||
type TextValue = string | string[]
|
||||
|
||||
export const HashlineText = z.union([z.string(), z.array(z.string())])
|
||||
|
||||
export const HashlineEdit = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("set_line"),
|
||||
line: z.string(),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("replace_lines"),
|
||||
start_line: z.string(),
|
||||
end_line: z.string(),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("insert_after"),
|
||||
line: z.string(),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("insert_before"),
|
||||
line: z.string(),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("insert_between"),
|
||||
after_line: z.string(),
|
||||
before_line: z.string(),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("append"),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("prepend"),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("replace"),
|
||||
old_text: z.string(),
|
||||
new_text: HashlineText,
|
||||
all: z.boolean().optional(),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
|
||||
export type HashlineEdit = z.infer<typeof HashlineEdit>
|
||||
|
||||
function isLowSignalContent(normalized: string) {
|
||||
if (normalized.length === 0) return true
|
||||
if (normalized.length <= 2) return true
|
||||
return LOW_SIGNAL_CONTENT_RE.test(normalized)
|
||||
}
|
||||
|
||||
export function hashlineID(lineNumber: number, line: string): string {
|
||||
let normalized = line
|
||||
if (normalized.endsWith("\r")) normalized = normalized.slice(0, -1)
|
||||
normalized = normalized.replace(/\s+/g, "")
|
||||
const seed = isLowSignalContent(normalized) ? `${normalized}:${lineNumber}` : normalized
|
||||
const hash = Bun.hash.xxHash32(seed) & 0xff
|
||||
const high = (hash >>> 4) & 0x0f
|
||||
const low = hash & 0x0f
|
||||
return `${HASHLINE_ALPHABET[high]}${HASHLINE_ALPHABET[low]}`
|
||||
}
|
||||
|
||||
export function hashlineRef(lineNumber: number, line: string): string {
|
||||
return `${lineNumber}#${hashlineID(lineNumber, line)}`
|
||||
}
|
||||
|
||||
export function hashlineLine(lineNumber: number, line: string): string {
|
||||
return `${hashlineRef(lineNumber, line)}:${line}`
|
||||
}
|
||||
|
||||
export function parseHashlineRef(input: string, label: string) {
|
||||
const match = input.match(HASHLINE_REF_REGEX)
|
||||
if (!match) {
|
||||
throw new Error(`${label} must contain a LINE#ID reference`)
|
||||
}
|
||||
|
||||
const line = Number.parseInt(match[1], 10)
|
||||
if (!Number.isInteger(line) || line < 1) {
|
||||
throw new Error(`${label} has invalid line number: ${match[1]}`)
|
||||
}
|
||||
|
||||
const id = match[2]
|
||||
if (!HASHLINE_ID_REGEX.test(id)) {
|
||||
throw new Error(`${label} has invalid hash id: ${id}`)
|
||||
}
|
||||
|
||||
return {
|
||||
raw: `${line}#${id}`,
|
||||
line,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
function toLines(text: TextValue) {
|
||||
if (Array.isArray(text)) return text
|
||||
return text.split(/\r?\n/)
|
||||
}
|
||||
|
||||
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[ZPMQVRWSNKTXJBYH]{2}:/
|
||||
const WRAPPER_PREFIX_RE = /^\s*(?:>>>|>>)\s?/
|
||||
|
||||
function stripByMajority(lines: string[], test: (line: string) => boolean, rewrite: (line: string) => string) {
|
||||
const nonEmpty = lines.filter((line) => line.length > 0)
|
||||
if (nonEmpty.length === 0) return lines
|
||||
|
||||
const matches = nonEmpty.filter(test).length
|
||||
if (matches === 0 || matches < nonEmpty.length * 0.5) return lines
|
||||
|
||||
return lines.map(rewrite)
|
||||
}
|
||||
|
||||
function stripNewLinePrefixes(lines: string[]) {
|
||||
const stripped = stripByMajority(
|
||||
lines,
|
||||
(line) => HASHLINE_PREFIX_RE.test(line),
|
||||
(line) => line.replace(HASHLINE_PREFIX_RE, ""),
|
||||
)
|
||||
return stripByMajority(
|
||||
stripped,
|
||||
(line) => WRAPPER_PREFIX_RE.test(line),
|
||||
(line) => line.replace(WRAPPER_PREFIX_RE, ""),
|
||||
)
|
||||
}
|
||||
|
||||
function equalsIgnoringWhitespace(a: string, b: string) {
|
||||
if (a === b) return true
|
||||
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "")
|
||||
}
|
||||
|
||||
function leadingWhitespace(line: string) {
|
||||
const match = line.match(/^\s*/)
|
||||
if (!match) return ""
|
||||
return match[0]
|
||||
}
|
||||
|
||||
function restoreLeadingIndent(template: string, line: string) {
|
||||
if (line.length === 0) return line
|
||||
const templateIndent = leadingWhitespace(template)
|
||||
if (templateIndent.length === 0) return line
|
||||
const indent = leadingWhitespace(line)
|
||||
if (indent.length > 0) return line
|
||||
return templateIndent + line
|
||||
}
|
||||
|
||||
function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]) {
|
||||
if (oldLines.length !== newLines.length) return newLines
|
||||
let changed = false
|
||||
const out = new Array<string>(newLines.length)
|
||||
for (let idx = 0; idx < newLines.length; idx++) {
|
||||
const restored = restoreLeadingIndent(oldLines[idx], newLines[idx])
|
||||
out[idx] = restored
|
||||
if (restored !== newLines[idx]) changed = true
|
||||
}
|
||||
if (changed) return out
|
||||
return newLines
|
||||
}
|
||||
|
||||
function stripAllWhitespace(s: string) {
|
||||
return s.replace(/\s+/g, "")
|
||||
}
|
||||
|
||||
function restoreOldWrappedLines(oldLines: string[], newLines: string[]) {
|
||||
if (oldLines.length === 0 || newLines.length < 2) return newLines
|
||||
|
||||
const canonToOld = new Map<string, { line: string; count: number }>()
|
||||
for (const line of oldLines) {
|
||||
const canon = stripAllWhitespace(line)
|
||||
const bucket = canonToOld.get(canon)
|
||||
if (bucket) bucket.count++
|
||||
if (!bucket) canonToOld.set(canon, { line, count: 1 })
|
||||
}
|
||||
|
||||
const candidates: Array<{ start: number; len: number; replacement: string; canon: string }> = []
|
||||
for (let start = 0; start < newLines.length; start++) {
|
||||
for (let len = 2; len <= 10 && start + len <= newLines.length; len++) {
|
||||
const canonSpan = stripAllWhitespace(newLines.slice(start, start + len).join(""))
|
||||
const old = canonToOld.get(canonSpan)
|
||||
if (old && old.count === 1 && canonSpan.length >= 6) {
|
||||
candidates.push({
|
||||
start,
|
||||
len,
|
||||
replacement: old.line,
|
||||
canon: canonSpan,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return newLines
|
||||
|
||||
const canonCounts = new Map<string, number>()
|
||||
for (const candidate of candidates) {
|
||||
canonCounts.set(candidate.canon, (canonCounts.get(candidate.canon) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const unique = candidates.filter((candidate) => (canonCounts.get(candidate.canon) ?? 0) === 1)
|
||||
if (unique.length === 0) return newLines
|
||||
|
||||
unique.sort((a, b) => b.start - a.start)
|
||||
const out = [...newLines]
|
||||
for (const candidate of unique) {
|
||||
out.splice(candidate.start, candidate.len, candidate.replacement)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function stripInsertAnchorEchoAfter(anchorLine: string, lines: string[]) {
|
||||
if (lines.length <= 1) return lines
|
||||
if (equalsIgnoringWhitespace(lines[0], anchorLine)) return lines.slice(1)
|
||||
return lines
|
||||
}
|
||||
|
||||
function stripInsertAnchorEchoBefore(anchorLine: string, lines: string[]) {
|
||||
if (lines.length <= 1) return lines
|
||||
if (equalsIgnoringWhitespace(lines[lines.length - 1], anchorLine)) return lines.slice(0, -1)
|
||||
return lines
|
||||
}
|
||||
|
||||
function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, lines: string[]) {
|
||||
let out = lines
|
||||
if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) out = out.slice(1)
|
||||
if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) out = out.slice(0, -1)
|
||||
return out
|
||||
}
|
||||
|
||||
function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, lines: string[]) {
|
||||
const count = endLine - startLine + 1
|
||||
if (lines.length <= 1 || lines.length <= count) return lines
|
||||
|
||||
let out = lines
|
||||
const beforeIdx = startLine - 2
|
||||
if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) {
|
||||
out = out.slice(1)
|
||||
}
|
||||
|
||||
const afterIdx = endLine
|
||||
if (
|
||||
afterIdx < fileLines.length &&
|
||||
out.length > 0 &&
|
||||
equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx])
|
||||
) {
|
||||
out = out.slice(0, -1)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function ensureText(text: TextValue, label: string) {
|
||||
const value = Array.isArray(text) ? text.join("") : text
|
||||
if (value.length > 0) return
|
||||
throw new Error(`${label} must be non-empty`)
|
||||
}
|
||||
|
||||
function applyReplace(content: string, oldText: string, newText: TextValue, all = false) {
|
||||
if (oldText.length === 0) throw new Error("replace.old_text must be non-empty")
|
||||
|
||||
const next = toLines(newText).join("\n")
|
||||
const first = content.indexOf(oldText)
|
||||
if (first < 0) throw new Error(`replace.old_text not found: ${JSON.stringify(oldText)}`)
|
||||
|
||||
if (all) return content.replaceAll(oldText, next)
|
||||
|
||||
const last = content.lastIndexOf(oldText)
|
||||
if (first !== last) {
|
||||
throw new Error("replace.old_text matched multiple times. Set all=true or provide a more specific old_text.")
|
||||
}
|
||||
|
||||
return content.slice(0, first) + next + content.slice(first + oldText.length)
|
||||
}
|
||||
|
||||
function mismatchContext(lines: string[], line: number) {
|
||||
if (lines.length === 0) return ">>> (file is empty)"
|
||||
const start = Math.max(1, line - 1)
|
||||
const end = Math.min(lines.length, line + 1)
|
||||
return Array.from({ length: end - start + 1 }, (_, idx) => start + idx)
|
||||
.map((num) => {
|
||||
const marker = num === line ? ">>>" : " "
|
||||
return `${marker} ${hashlineLine(num, lines[num - 1])}`
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function mismatchSummary(lines: string[], mismatch: { expected: string; line: number }) {
|
||||
if (mismatch.line < 1 || mismatch.line > lines.length) {
|
||||
return `- expected ${mismatch.expected} -> line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`
|
||||
}
|
||||
return `- expected ${mismatch.expected} -> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`
|
||||
}
|
||||
|
||||
function throwMismatch(lines: string[], mismatches: Array<{ expected: string; line: number }>) {
|
||||
const seen = new Set<string>()
|
||||
const unique = mismatches.filter((mismatch) => {
|
||||
const key = `${mismatch.expected}:${mismatch.line}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
const preview = unique.slice(0, 2).map((mismatch) => mismatchSummary(lines, mismatch))
|
||||
const hidden = unique.length - preview.length
|
||||
const count = unique.length
|
||||
const linesOut = [
|
||||
`Hashline edit rejected: ${count} anchor mismatch${count === 1 ? "" : "es"}. Re-read the file and retry with the updated anchors below.`,
|
||||
...preview,
|
||||
...(hidden > 0 ? [`- ... and ${hidden} more mismatches`] : []),
|
||||
]
|
||||
|
||||
if (Bun.env.OPENCODE_HL_MISMATCH_DEBUG === "1") {
|
||||
const body = unique
|
||||
.map((mismatch) => {
|
||||
if (mismatch.line < 1 || mismatch.line > lines.length) {
|
||||
return [
|
||||
`>>> expected ${mismatch.expected}`,
|
||||
`>>> current line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`,
|
||||
].join("\n")
|
||||
}
|
||||
return [
|
||||
`>>> expected ${mismatch.expected}`,
|
||||
mismatchContext(lines, mismatch.line),
|
||||
`>>> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`,
|
||||
].join("\n")
|
||||
})
|
||||
.join("\n\n")
|
||||
linesOut.push("", body)
|
||||
}
|
||||
|
||||
throw new Error(linesOut.join("\n"))
|
||||
}
|
||||
|
||||
function validateAnchors(lines: string[], refs: Array<{ raw: string; line: number; id: string }>) {
|
||||
const mismatches = refs
|
||||
.filter((ref) => {
|
||||
if (ref.line < 1 || ref.line > lines.length) return true
|
||||
return hashlineID(ref.line, lines[ref.line - 1]) !== ref.id
|
||||
})
|
||||
.map((ref) => ({ expected: ref.raw, line: ref.line }))
|
||||
|
||||
if (mismatches.length > 0) throwMismatch(lines, mismatches)
|
||||
}
|
||||
|
||||
function splitLines(text: string) {
|
||||
if (text === "") {
|
||||
return {
|
||||
lines: [] as string[],
|
||||
trailing: false,
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = text.endsWith("\n")
|
||||
const lines = text.split(/\r?\n/)
|
||||
if (trailing) lines.pop()
|
||||
|
||||
return { lines, trailing }
|
||||
}
|
||||
|
||||
export function parseHashlineContent(bytes: Buffer) {
|
||||
const raw = bytes.toString("utf8")
|
||||
let text = raw
|
||||
const bom = raw.startsWith("\uFEFF")
|
||||
if (bom) text = raw.slice(1)
|
||||
|
||||
const eol = text.includes("\r\n") ? "\r\n" : "\n"
|
||||
const { lines, trailing } = splitLines(text)
|
||||
|
||||
return {
|
||||
bom,
|
||||
eol,
|
||||
trailing,
|
||||
lines,
|
||||
text,
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeHashlineContent(input: { lines: string[]; bom: boolean; eol: string; trailing: boolean }) {
|
||||
let text = input.lines.join(input.eol)
|
||||
if (input.trailing && input.lines.length > 0) text += input.eol
|
||||
if (input.bom) text = `\uFEFF${text}`
|
||||
return {
|
||||
text,
|
||||
bytes: Buffer.from(text, "utf8"),
|
||||
}
|
||||
}
|
||||
|
||||
type Splice = {
|
||||
start: number
|
||||
del: number
|
||||
text: string[]
|
||||
order: number
|
||||
kind: "set_line" | "replace_lines" | "insert_after" | "insert_before" | "insert_between" | "append" | "prepend"
|
||||
sortLine: number
|
||||
precedence: number
|
||||
startLine?: number
|
||||
endLine?: number
|
||||
anchorLine?: number
|
||||
beforeLine?: number
|
||||
afterLine?: number
|
||||
}
|
||||
|
||||
export function applyHashlineEdits(input: {
|
||||
lines: string[]
|
||||
trailing: boolean
|
||||
edits: HashlineEdit[]
|
||||
autocorrect?: boolean
|
||||
aggressiveAutocorrect?: boolean
|
||||
}) {
|
||||
const lines = [...input.lines]
|
||||
const originalLines = [...input.lines]
|
||||
let trailing = input.trailing
|
||||
const refs: Array<{ raw: string; line: number; id: string }> = []
|
||||
const replaceOps: Array<Extract<HashlineEdit, { type: "replace" }>> = []
|
||||
const ops: Splice[] = []
|
||||
const autocorrect = input.autocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1"
|
||||
const aggressiveAutocorrect = input.aggressiveAutocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1"
|
||||
const parseText = (text: TextValue) => {
|
||||
const next = toLines(text)
|
||||
if (!autocorrect) return next
|
||||
return stripNewLinePrefixes(next)
|
||||
}
|
||||
|
||||
input.edits.forEach((edit, order) => {
|
||||
if (edit.type === "replace") {
|
||||
replaceOps.push(edit)
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "append") {
|
||||
ensureText(edit.text, "append.text")
|
||||
ops.push({
|
||||
start: lines.length,
|
||||
del: 0,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "append",
|
||||
sortLine: lines.length + 1,
|
||||
precedence: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "prepend") {
|
||||
ensureText(edit.text, "prepend.text")
|
||||
ops.push({
|
||||
start: 0,
|
||||
del: 0,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "prepend",
|
||||
sortLine: 0,
|
||||
precedence: 2,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "set_line") {
|
||||
const line = parseHashlineRef(edit.line, "set_line.line")
|
||||
refs.push(line)
|
||||
ops.push({
|
||||
start: line.line - 1,
|
||||
del: 1,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "set_line",
|
||||
sortLine: line.line,
|
||||
precedence: 0,
|
||||
startLine: line.line,
|
||||
endLine: line.line,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "replace_lines") {
|
||||
const start = parseHashlineRef(edit.start_line, "replace_lines.start_line")
|
||||
const end = parseHashlineRef(edit.end_line, "replace_lines.end_line")
|
||||
refs.push(start)
|
||||
refs.push(end)
|
||||
|
||||
if (start.line > end.line) {
|
||||
throw new Error("replace_lines.start_line must be less than or equal to replace_lines.end_line")
|
||||
}
|
||||
|
||||
ops.push({
|
||||
start: start.line - 1,
|
||||
del: end.line - start.line + 1,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "replace_lines",
|
||||
sortLine: end.line,
|
||||
precedence: 0,
|
||||
startLine: start.line,
|
||||
endLine: end.line,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "insert_after") {
|
||||
const line = parseHashlineRef(edit.line, "insert_after.line")
|
||||
ensureText(edit.text, "insert_after.text")
|
||||
refs.push(line)
|
||||
ops.push({
|
||||
start: line.line,
|
||||
del: 0,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "insert_after",
|
||||
sortLine: line.line,
|
||||
precedence: 1,
|
||||
anchorLine: line.line,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "insert_before") {
|
||||
const line = parseHashlineRef(edit.line, "insert_before.line")
|
||||
ensureText(edit.text, "insert_before.text")
|
||||
refs.push(line)
|
||||
ops.push({
|
||||
start: line.line - 1,
|
||||
del: 0,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "insert_before",
|
||||
sortLine: line.line,
|
||||
precedence: 2,
|
||||
anchorLine: line.line,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const after = parseHashlineRef(edit.after_line, "insert_between.after_line")
|
||||
const before = parseHashlineRef(edit.before_line, "insert_between.before_line")
|
||||
ensureText(edit.text, "insert_between.text")
|
||||
refs.push(after)
|
||||
refs.push(before)
|
||||
|
||||
if (after.line >= before.line) {
|
||||
throw new Error("insert_between.after_line must be less than insert_between.before_line")
|
||||
}
|
||||
|
||||
ops.push({
|
||||
start: after.line,
|
||||
del: 0,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "insert_between",
|
||||
sortLine: before.line,
|
||||
precedence: 3,
|
||||
afterLine: after.line,
|
||||
beforeLine: before.line,
|
||||
})
|
||||
})
|
||||
|
||||
validateAnchors(lines, refs)
|
||||
|
||||
const sorted = [...ops].sort((a, b) => {
|
||||
if (a.sortLine !== b.sortLine) return b.sortLine - a.sortLine
|
||||
if (a.precedence !== b.precedence) return a.precedence - b.precedence
|
||||
return a.order - b.order
|
||||
})
|
||||
|
||||
sorted.forEach((op) => {
|
||||
if (op.start < 0 || op.start > lines.length) {
|
||||
throw new Error(`line index ${op.start + 1} is out of range`)
|
||||
}
|
||||
|
||||
let text = op.text
|
||||
if (autocorrect && aggressiveAutocorrect) {
|
||||
if (op.kind === "set_line" || op.kind === "replace_lines") {
|
||||
const start = op.startLine ?? op.start + 1
|
||||
const end = op.endLine ?? start + op.del - 1
|
||||
const old = originalLines.slice(start - 1, end)
|
||||
text = stripRangeBoundaryEcho(originalLines, start, end, text)
|
||||
text = restoreOldWrappedLines(old, text)
|
||||
text = restoreIndentForPairedReplacement(old, text)
|
||||
}
|
||||
|
||||
if ((op.kind === "insert_after" || op.kind === "append") && op.anchorLine) {
|
||||
text = stripInsertAnchorEchoAfter(originalLines[op.anchorLine - 1], text)
|
||||
}
|
||||
|
||||
if ((op.kind === "insert_before" || op.kind === "prepend") && op.anchorLine) {
|
||||
text = stripInsertAnchorEchoBefore(originalLines[op.anchorLine - 1], text)
|
||||
}
|
||||
|
||||
if (op.kind === "insert_between" && op.afterLine && op.beforeLine) {
|
||||
text = stripInsertBoundaryEcho(originalLines[op.afterLine - 1], originalLines[op.beforeLine - 1], text)
|
||||
}
|
||||
}
|
||||
|
||||
lines.splice(op.start, op.del, ...text)
|
||||
})
|
||||
|
||||
if (replaceOps.length > 0) {
|
||||
const content = `${lines.join("\n")}${trailing && lines.length > 0 ? "\n" : ""}`
|
||||
const replaced = replaceOps.reduce(
|
||||
(acc, op) =>
|
||||
applyReplace(acc, op.old_text, autocorrect ? stripNewLinePrefixes(toLines(op.new_text)) : op.new_text, op.all),
|
||||
content,
|
||||
)
|
||||
const split = splitLines(replaced)
|
||||
lines.splice(0, lines.length, ...split.lines)
|
||||
trailing = split.trailing
|
||||
}
|
||||
|
||||
return {
|
||||
lines,
|
||||
trailing,
|
||||
}
|
||||
}
|
||||
|
||||
export function hashlineOnlyCreates(edits: HashlineEdit[]) {
|
||||
return edits.every((edit) => edit.type === "append" || edit.type === "prepend")
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { InstructionPrompt } from "../session/instruction"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { hashlineRef } from "./hashline"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -156,6 +158,7 @@ export const ReadTool = Tool.define("read", {
|
||||
const offset = params.offset ?? 1
|
||||
const start = offset - 1
|
||||
const raw: string[] = []
|
||||
const full: string[] = []
|
||||
let bytes = 0
|
||||
let lines = 0
|
||||
let truncatedByBytes = false
|
||||
@@ -179,6 +182,7 @@ export const ReadTool = Tool.define("read", {
|
||||
}
|
||||
|
||||
raw.push(line)
|
||||
full.push(text)
|
||||
bytes += size
|
||||
}
|
||||
} finally {
|
||||
@@ -190,8 +194,11 @@ export const ReadTool = Tool.define("read", {
|
||||
throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`)
|
||||
}
|
||||
|
||||
const useHashline = (await Config.get()).experimental?.hashline_edit !== false
|
||||
const content = raw.map((line, index) => {
|
||||
return `${index + offset}: ${line}`
|
||||
const lineNumber = index + offset
|
||||
if (useHashline) return `${hashlineRef(lineNumber, full[index])}:${line}`
|
||||
return `${lineNumber}: ${line}`
|
||||
})
|
||||
const preview = raw.slice(0, 20).join("\n")
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ Usage:
|
||||
- To read later sections, call this tool again with a larger offset.
|
||||
- Use the grep tool to find specific content in large files or files with long lines.
|
||||
- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.
|
||||
- Contents are returned with each line prefixed by its line number as `<line>: <content>`. For example, if a file has contents "foo\n", you will receive "1: foo\n". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
|
||||
- Contents are returned with a line prefix.
|
||||
- Default format: `LINE#ID:<content>` (example: `1#AB:foo`). Use these anchors for hashline edits.
|
||||
- Legacy format can be restored with `experimental.hashline_edit: false`: `<line>: <content>` (example: `1: foo`).
|
||||
- For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
|
||||
- Any line longer than 2000 characters is truncated.
|
||||
- Call this tool in parallel when you know there are multiple files you want to read.
|
||||
- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.
|
||||
|
||||
@@ -135,7 +135,11 @@ export namespace ToolRegistry {
|
||||
},
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const config = await Config.get()
|
||||
const tools = await all()
|
||||
const hashline = config.experimental?.hashline_edit !== false
|
||||
const usePatch =
|
||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||
const result = await Promise.all(
|
||||
tools
|
||||
.filter((t) => {
|
||||
@@ -144,9 +148,12 @@ export namespace ToolRegistry {
|
||||
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
|
||||
if (hashline) {
|
||||
if (t.id === "apply_patch") return usePatch
|
||||
return true
|
||||
}
|
||||
|
||||
// use apply tool in same format as codex
|
||||
const usePatch =
|
||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||
if (t.id === "apply_patch") return usePatch
|
||||
if (t.id === "edit" || t.id === "write") return !usePatch
|
||||
|
||||
|
||||
10
packages/opencode/src/util/which.ts
Normal file
10
packages/opencode/src/util/which.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import whichPkg from "which"
|
||||
|
||||
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
|
||||
const result = whichPkg.sync(cmd, {
|
||||
nothrow: true,
|
||||
path: env?.PATH,
|
||||
pathExt: env?.PATHEXT,
|
||||
})
|
||||
return typeof result === "string" ? result : null
|
||||
}
|
||||
@@ -38,7 +38,7 @@ test("build agent has correct default properties", async () => {
|
||||
expect(build).toBeDefined()
|
||||
expect(build?.mode).toBe("primary")
|
||||
expect(build?.native).toBe(true)
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
expect(evalPerm(build, "bash")).toBe("allow")
|
||||
},
|
||||
})
|
||||
@@ -217,8 +217,8 @@ test("agent permission config merges with defaults", async () => {
|
||||
expect(build).toBeDefined()
|
||||
// Specific pattern is denied
|
||||
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
||||
// Edit still allowed
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
// Edit still asks (default behavior)
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { resolvePluginProviders } from "../../src/cli/cmd/auth"
|
||||
import { resolvePluginProviders } from "../../src/cli/cmd/providers"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
|
||||
function hookWithAuth(provider: string): Hooks {
|
||||
|
||||
@@ -102,6 +102,28 @@ test("loads JSONC config file", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("parses experimental.hashline_edit and experimental.hashline_autocorrect", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
experimental: {
|
||||
hashline_edit: true,
|
||||
hashline_autocorrect: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.experimental?.hashline_edit).toBe(true)
|
||||
expect(config.experimental?.hashline_autocorrect).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("merges multiple config files with correct precedence", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { afterAll } from "bun:test"
|
||||
|
||||
// Set XDG env vars FIRST, before any src/ imports
|
||||
@@ -15,7 +16,7 @@ afterAll(async () => {
|
||||
typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY"
|
||||
const rm = async (left: number): Promise<void> => {
|
||||
Bun.gc(true)
|
||||
await Bun.sleep(100)
|
||||
await sleep(100)
|
||||
return fs.rm(dir, { recursive: true, force: true }).catch((error) => {
|
||||
if (!busy(error)) throw error
|
||||
if (left <= 1) throw error
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Pty } from "../../src/pty"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
describe("pty", () => {
|
||||
test("does not leak output when websocket objects are reused", async () => {
|
||||
@@ -43,7 +44,7 @@ describe("pty", () => {
|
||||
|
||||
// Output from a must never show up in b.
|
||||
Pty.write(a.id, "AAA\n")
|
||||
await Bun.sleep(100)
|
||||
await sleep(100)
|
||||
|
||||
expect(outB.join("")).not.toContain("AAA")
|
||||
} finally {
|
||||
@@ -88,7 +89,7 @@ describe("pty", () => {
|
||||
}
|
||||
|
||||
Pty.write(a.id, "AAA\n")
|
||||
await Bun.sleep(100)
|
||||
await sleep(100)
|
||||
|
||||
expect(outB.join("")).not.toContain("AAA")
|
||||
} finally {
|
||||
@@ -128,7 +129,7 @@ describe("pty", () => {
|
||||
ctx.connId = 2
|
||||
|
||||
Pty.write(a.id, "AAA\n")
|
||||
await Bun.sleep(100)
|
||||
await sleep(100)
|
||||
|
||||
expect(out.join("")).toContain("AAA")
|
||||
} finally {
|
||||
|
||||
85
packages/opencode/test/session/prompt-loop-exit.test.ts
Normal file
85
packages/opencode/test/session/prompt-loop-exit.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
|
||||
function makeUser(id: string): MessageV2.User {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
sessionID: "session-1",
|
||||
time: { created: Date.now() },
|
||||
agent: "default",
|
||||
model: { providerID: "openai", modelID: "gpt-4" },
|
||||
} as MessageV2.User
|
||||
}
|
||||
|
||||
function makeAssistant(
|
||||
id: string,
|
||||
parentID: string,
|
||||
finish?: string,
|
||||
): MessageV2.Assistant {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
sessionID: "session-1",
|
||||
parentID,
|
||||
mode: "default",
|
||||
agent: "default",
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: "gpt-4",
|
||||
providerID: "openai",
|
||||
time: { created: Date.now() },
|
||||
finish,
|
||||
} as MessageV2.Assistant
|
||||
}
|
||||
|
||||
describe("shouldExitLoop", () => {
|
||||
test("normal case: user ID < assistant ID, parentID matches, finish=end_turn → exits", () => {
|
||||
const user = makeUser("01AAA")
|
||||
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
|
||||
})
|
||||
|
||||
test("clock skew: user ID > assistant ID, parentID matches, finish=stop → exits", () => {
|
||||
// Simulates client clock ahead: user message ID sorts AFTER the assistant ID
|
||||
const user = makeUser("01ZZZ")
|
||||
const assistant = makeAssistant("01AAA", "01ZZZ", "stop")
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
|
||||
})
|
||||
|
||||
test("unfinished assistant: finish=tool-calls → does NOT exit", () => {
|
||||
const user = makeUser("01AAA")
|
||||
const assistant = makeAssistant("01BBB", "01AAA", "tool-calls")
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
|
||||
})
|
||||
|
||||
test("unfinished assistant: finish=unknown → does NOT exit", () => {
|
||||
const user = makeUser("01AAA")
|
||||
const assistant = makeAssistant("01BBB", "01AAA", "unknown")
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
|
||||
})
|
||||
|
||||
test("no assistant yet → does NOT exit", () => {
|
||||
const user = makeUser("01AAA")
|
||||
expect(SessionPrompt.shouldExitLoop(user, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test("assistant has no finish → does NOT exit", () => {
|
||||
const user = makeUser("01AAA")
|
||||
const assistant = makeAssistant("01BBB", "01AAA", undefined)
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
|
||||
})
|
||||
|
||||
test("parentID mismatch → does NOT exit", () => {
|
||||
const user = makeUser("01AAA")
|
||||
const assistant = makeAssistant("01BBB", "01OTHER", "end_turn")
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
|
||||
})
|
||||
|
||||
test("no user message → does NOT exit", () => {
|
||||
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
|
||||
expect(SessionPrompt.shouldExitLoop(undefined, assistant)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { NamedError } from "@opencode-ai/util/error"
|
||||
import { APICallError } from "ai"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { SessionRetry } from "../../src/session/retry"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
@@ -135,7 +136,7 @@ describe("session.message-v2.fromError", () => {
|
||||
new ReadableStream({
|
||||
async pull(controller) {
|
||||
controller.enqueue("Hello,")
|
||||
await Bun.sleep(10000)
|
||||
await sleep(10000)
|
||||
controller.enqueue(" World!")
|
||||
controller.close()
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EditTool } from "../../src/tool/edit"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { hashlineLine, hashlineRef } from "../../src/tool/hashline"
|
||||
|
||||
const ctx = {
|
||||
sessionID: "test-edit-session",
|
||||
@@ -493,4 +494,286 @@ describe("tool.edit", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("hashline payload", () => {
|
||||
test("replaces a single line in hashline mode", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: true,
|
||||
},
|
||||
},
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(2, "b"),
|
||||
text: "B",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("a\nB\nc")
|
||||
expect(result.metadata.edit_mode).toBe("hashline")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("applies hashline autocorrect prefixes through config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: true,
|
||||
hashline_autocorrect: true,
|
||||
},
|
||||
},
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(2, "b"),
|
||||
text: hashlineLine(2, "B"),
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("a\nB\nc")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("supports range replacement and insert modes", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: true,
|
||||
},
|
||||
},
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc\nd", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "replace_lines",
|
||||
start_line: hashlineRef(2, "b"),
|
||||
end_line: hashlineRef(3, "c"),
|
||||
text: ["B", "C"],
|
||||
},
|
||||
{
|
||||
type: "insert_before",
|
||||
line: hashlineRef(2, "b"),
|
||||
text: "x",
|
||||
},
|
||||
{
|
||||
type: "insert_after",
|
||||
line: hashlineRef(3, "c"),
|
||||
text: "y",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("a\nx\nB\nC\ny\nd")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("creates missing files from append/prepend operations", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "created.txt")
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "prepend",
|
||||
text: "start",
|
||||
},
|
||||
{
|
||||
type: "append",
|
||||
text: "end",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("start\nend")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects missing files for non-append/prepend edits", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "missing.txt")
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "replace",
|
||||
old_text: "a",
|
||||
new_text: "b",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("Missing file can only be created")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("supports delete and rename flows", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: true,
|
||||
},
|
||||
},
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "src.txt"), "a\nb", "utf-8")
|
||||
await fs.writeFile(path.join(dir, "delete.txt"), "delete me", "utf-8")
|
||||
},
|
||||
})
|
||||
const source = path.join(tmp.path, "src.txt")
|
||||
const target = path.join(tmp.path, "renamed.txt")
|
||||
const doomed = path.join(tmp.path, "delete.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
|
||||
FileTime.read(ctx.sessionID, source)
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: source,
|
||||
rename: target,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(2, "b"),
|
||||
text: "B",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("a\nB")
|
||||
await expect(fs.stat(source)).rejects.toThrow()
|
||||
|
||||
FileTime.read(ctx.sessionID, doomed)
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: doomed,
|
||||
delete: true,
|
||||
edits: [],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
await expect(fs.stat(doomed)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects hashline payload when experimental mode is disabled", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: false,
|
||||
},
|
||||
},
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "a", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "append",
|
||||
text: "b",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("Hashline edit payload is disabled")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,6 +37,62 @@ describe("tool.grep", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("hashline disabled keeps Line N format", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: false,
|
||||
},
|
||||
},
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await GrepTool.init()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "alpha",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.output).toContain("Line 1: alpha")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("hashline enabled emits N#ID anchor format", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: true,
|
||||
},
|
||||
},
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await GrepTool.init()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "alpha",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.output).toMatch(/\b1#[ZPMQVRWSNKTXJBYH]{2}:alpha\b/)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("no matches returns correct output", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
210
packages/opencode/test/tool/hashline.test.ts
Normal file
210
packages/opencode/test/tool/hashline.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { applyHashlineEdits, hashlineID, hashlineLine, hashlineRef, parseHashlineRef } from "../../src/tool/hashline"
|
||||
|
||||
function swapID(ref: string) {
|
||||
const [line, id] = ref.split("#")
|
||||
const next = id[0] === "Z" ? `P${id[1]}` : `Z${id[1]}`
|
||||
return `${line}#${next}`
|
||||
}
|
||||
|
||||
function errorMessage(run: () => void) {
|
||||
try {
|
||||
run()
|
||||
return ""
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
describe("tool.hashline", () => {
|
||||
test("hash computation is stable and 2-char alphabet encoded", () => {
|
||||
const a = hashlineID(1, " const x = 1")
|
||||
const b = hashlineID(1, "constx=1")
|
||||
const c = hashlineID(99, "constx=1")
|
||||
expect(a).toBe(b)
|
||||
expect(a).toBe(c)
|
||||
expect(a).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/)
|
||||
})
|
||||
|
||||
test("low-signal lines mix line index into hash id", () => {
|
||||
const a = hashlineID(1, "")
|
||||
const b = hashlineID(2, "")
|
||||
const c = hashlineID(1, "{}")
|
||||
const d = hashlineID(2, "{}")
|
||||
expect(a).not.toBe(b)
|
||||
expect(c).not.toBe(d)
|
||||
})
|
||||
|
||||
test("autocorrect strips copied hashline prefixes when enabled", () => {
|
||||
const old = Bun.env.OPENCODE_HL_AUTOCORRECT
|
||||
Bun.env.OPENCODE_HL_AUTOCORRECT = "1"
|
||||
try {
|
||||
const result = applyHashlineEdits({
|
||||
lines: ["a"],
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(1, "a"),
|
||||
text: hashlineLine(1, "a"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(result.lines).toEqual(["a"])
|
||||
} finally {
|
||||
if (old === undefined) delete Bun.env.OPENCODE_HL_AUTOCORRECT
|
||||
else Bun.env.OPENCODE_HL_AUTOCORRECT = old
|
||||
}
|
||||
})
|
||||
|
||||
test("default autocorrect does not rewrite non-prefix content", () => {
|
||||
const result = applyHashlineEdits({
|
||||
lines: ["a"],
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(1, "a"),
|
||||
text: "+a",
|
||||
},
|
||||
],
|
||||
autocorrect: true,
|
||||
aggressiveAutocorrect: false,
|
||||
})
|
||||
expect(result.lines).toEqual(["+a"])
|
||||
})
|
||||
|
||||
test("parses strict LINE#ID references with tolerant extraction", () => {
|
||||
const ref = parseHashlineRef(">>> 12#ZP:const value = 1", "line")
|
||||
expect(ref.line).toBe(12)
|
||||
expect(ref.id).toBe("ZP")
|
||||
expect(ref.raw).toBe("12#ZP")
|
||||
|
||||
expect(() => parseHashlineRef("12#ab", "line")).toThrow("LINE#ID")
|
||||
})
|
||||
|
||||
test("reports compact mismatch errors with retry anchors", () => {
|
||||
const lines = ["alpha", "beta", "gamma"]
|
||||
const wrong = swapID(hashlineRef(2, lines[1]))
|
||||
|
||||
const message = errorMessage(() =>
|
||||
applyHashlineEdits({
|
||||
lines,
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: wrong,
|
||||
text: "BETA",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(message).toContain("anchor mismatch")
|
||||
expect(message).toContain("retry with")
|
||||
expect(message).not.toContain(">>>")
|
||||
expect(message.length).toBeLessThan(260)
|
||||
})
|
||||
|
||||
test("applies batched line edits bottom-up for stable results", () => {
|
||||
const lines = ["a", "b", "c", "d"]
|
||||
const one = hashlineRef(1, lines[0])
|
||||
const two = hashlineRef(2, lines[1])
|
||||
const three = hashlineRef(3, lines[2])
|
||||
const four = hashlineRef(4, lines[3])
|
||||
|
||||
const result = applyHashlineEdits({
|
||||
lines,
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "replace_lines",
|
||||
start_line: two,
|
||||
end_line: three,
|
||||
text: ["B", "C"],
|
||||
},
|
||||
{
|
||||
type: "insert_after",
|
||||
line: one,
|
||||
text: "A1",
|
||||
},
|
||||
{
|
||||
type: "set_line",
|
||||
line: four,
|
||||
text: "D",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.lines).toEqual(["a", "A1", "B", "C", "D"])
|
||||
})
|
||||
|
||||
test("orders append and prepend deterministically on empty files", () => {
|
||||
const result = applyHashlineEdits({
|
||||
lines: [],
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "append",
|
||||
text: "end",
|
||||
},
|
||||
{
|
||||
type: "prepend",
|
||||
text: "start",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.lines).toEqual(["start", "end"])
|
||||
})
|
||||
|
||||
test("validates ranges, between constraints, and non-empty insert text", () => {
|
||||
const lines = ["a", "b", "c"]
|
||||
const one = hashlineRef(1, lines[0])
|
||||
const two = hashlineRef(2, lines[1])
|
||||
|
||||
expect(() =>
|
||||
applyHashlineEdits({
|
||||
lines,
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "replace_lines",
|
||||
start_line: two,
|
||||
end_line: one,
|
||||
text: "x",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("start_line")
|
||||
|
||||
expect(() =>
|
||||
applyHashlineEdits({
|
||||
lines,
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "insert_between",
|
||||
after_line: two,
|
||||
before_line: one,
|
||||
text: "x",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("insert_between.after_line")
|
||||
|
||||
expect(() =>
|
||||
applyHashlineEdits({
|
||||
lines,
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "append",
|
||||
text: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("append.text")
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { hashlineLine } from "../../src/tool/hashline"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
|
||||
@@ -269,10 +270,10 @@ describe("tool.read truncation", () => {
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
|
||||
expect(result.output).toContain("10: line10")
|
||||
expect(result.output).toContain("14: line14")
|
||||
expect(result.output).not.toContain("9: line10")
|
||||
expect(result.output).not.toContain("15: line15")
|
||||
expect(result.output).toContain(hashlineLine(10, "line10"))
|
||||
expect(result.output).toContain(hashlineLine(14, "line14"))
|
||||
expect(result.output).not.toContain(hashlineLine(9, "line9"))
|
||||
expect(result.output).not.toContain(hashlineLine(15, "line15"))
|
||||
expect(result.output).toContain("line10")
|
||||
expect(result.output).toContain("line14")
|
||||
expect(result.output).not.toContain("line0")
|
||||
@@ -443,6 +444,50 @@ root_type Monster;`
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.read hashline output", () => {
|
||||
test("returns LINE#ID prefixes by default", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "hashline.txt"), "foo\nbar")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "hashline.txt") }, ctx)
|
||||
expect(result.output).toContain(hashlineLine(1, "foo"))
|
||||
expect(result.output).toContain(hashlineLine(2, "bar"))
|
||||
expect(result.output).not.toContain("1: foo")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps legacy line prefixes when hashline mode is disabled", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: false,
|
||||
},
|
||||
},
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "legacy.txt"), "foo\nbar")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "legacy.txt") }, ctx)
|
||||
expect(result.output).toContain("1: foo")
|
||||
expect(result.output).toContain("2: bar")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.read loaded instructions", () => {
|
||||
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
100
packages/opencode/test/tool/registry-hashline.test.ts
Normal file
100
packages/opencode/test/tool/registry-hashline.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
|
||||
describe("tool.registry hashline routing", () => {
|
||||
test("hashline mode keeps edit and apply_patch for GPT models", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const tools = await ToolRegistry.tools({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
})
|
||||
const ids = tools.map((tool) => tool.id)
|
||||
expect(ids).toContain("edit")
|
||||
expect(ids).toContain("write")
|
||||
expect(ids).toContain("apply_patch")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("hashline mode keeps edit and removes apply_patch for non-GPT models", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const tools = await ToolRegistry.tools({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-3-7-sonnet",
|
||||
})
|
||||
const ids = tools.map((tool) => tool.id)
|
||||
expect(ids).toContain("edit")
|
||||
expect(ids).toContain("write")
|
||||
expect(ids).not.toContain("apply_patch")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps existing GPT apply_patch routing when hashline is explicitly disabled", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const tools = await ToolRegistry.tools({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
})
|
||||
const ids = tools.map((tool) => tool.id)
|
||||
expect(ids).toContain("apply_patch")
|
||||
expect(ids).not.toContain("edit")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps existing non-GPT routing when hashline is explicitly disabled", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_edit: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const tools = await ToolRegistry.tools({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-3-7-sonnet",
|
||||
})
|
||||
const ids = tools.map((tool) => tool.id)
|
||||
expect(ids).toContain("edit")
|
||||
expect(ids).not.toContain("apply_patch")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
82
packages/opencode/test/util/which.test.ts
Normal file
82
packages/opencode/test/util/which.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { which } from "../../src/util/which"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
async function cmd(dir: string, name: string, exec = true) {
|
||||
const ext = process.platform === "win32" ? ".cmd" : ""
|
||||
const file = path.join(dir, name + ext)
|
||||
const body = process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"
|
||||
await fs.writeFile(file, body)
|
||||
if (process.platform !== "win32") {
|
||||
await fs.chmod(file, exec ? 0o755 : 0o644)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
function env(PATH: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
PATH,
|
||||
PATHEXT: process.env["PATHEXT"],
|
||||
}
|
||||
}
|
||||
|
||||
function same(a: string | null, b: string) {
|
||||
if (process.platform === "win32") {
|
||||
expect(a?.toLowerCase()).toBe(b.toLowerCase())
|
||||
return
|
||||
}
|
||||
|
||||
expect(a).toBe(b)
|
||||
}
|
||||
|
||||
describe("util.which", () => {
|
||||
test("returns null when command is missing", () => {
|
||||
expect(which("opencode-missing-command-for-test")).toBeNull()
|
||||
})
|
||||
|
||||
test("finds a command from PATH override", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const bin = path.join(tmp.path, "bin")
|
||||
await fs.mkdir(bin)
|
||||
const file = await cmd(bin, "tool")
|
||||
|
||||
same(which("tool", env(bin)), file)
|
||||
})
|
||||
|
||||
test("uses first PATH match", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const a = path.join(tmp.path, "a")
|
||||
const b = path.join(tmp.path, "b")
|
||||
await fs.mkdir(a)
|
||||
await fs.mkdir(b)
|
||||
const first = await cmd(a, "dupe")
|
||||
await cmd(b, "dupe")
|
||||
|
||||
same(which("dupe", env([a, b].join(path.delimiter))), first)
|
||||
})
|
||||
|
||||
test("returns null for non-executable file on unix", async () => {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
const bin = path.join(tmp.path, "bin")
|
||||
await fs.mkdir(bin)
|
||||
await cmd(bin, "noexec", false)
|
||||
|
||||
expect(which("noexec", env(bin))).toBeNull()
|
||||
})
|
||||
|
||||
test("uses PATHEXT on windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
const bin = path.join(tmp.path, "bin")
|
||||
await fs.mkdir(bin)
|
||||
const file = path.join(bin, "pathext.CMD")
|
||||
await fs.writeFile(file, "@echo off\r\n")
|
||||
|
||||
expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file)
|
||||
})
|
||||
})
|
||||
@@ -1008,6 +1008,396 @@ export type GlobalEvent = {
|
||||
payload: Event
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom keybind configurations
|
||||
*/
|
||||
export type KeybindsConfig = {
|
||||
/**
|
||||
* Leader key for keybind combinations
|
||||
*/
|
||||
leader?: string
|
||||
/**
|
||||
* Exit the application
|
||||
*/
|
||||
app_exit?: string
|
||||
/**
|
||||
* Open external editor
|
||||
*/
|
||||
editor_open?: string
|
||||
/**
|
||||
* List available themes
|
||||
*/
|
||||
theme_list?: string
|
||||
/**
|
||||
* Toggle sidebar
|
||||
*/
|
||||
sidebar_toggle?: string
|
||||
/**
|
||||
* Toggle session scrollbar
|
||||
*/
|
||||
scrollbar_toggle?: string
|
||||
/**
|
||||
* Toggle username visibility
|
||||
*/
|
||||
username_toggle?: string
|
||||
/**
|
||||
* View status
|
||||
*/
|
||||
status_view?: string
|
||||
/**
|
||||
* Export session to editor
|
||||
*/
|
||||
session_export?: string
|
||||
/**
|
||||
* Create a new session
|
||||
*/
|
||||
session_new?: string
|
||||
/**
|
||||
* List all sessions
|
||||
*/
|
||||
session_list?: string
|
||||
/**
|
||||
* Show session timeline
|
||||
*/
|
||||
session_timeline?: string
|
||||
/**
|
||||
* Fork session from message
|
||||
*/
|
||||
session_fork?: string
|
||||
/**
|
||||
* Rename session
|
||||
*/
|
||||
session_rename?: string
|
||||
/**
|
||||
* Delete session
|
||||
*/
|
||||
session_delete?: string
|
||||
/**
|
||||
* Delete stash entry
|
||||
*/
|
||||
stash_delete?: string
|
||||
/**
|
||||
* Open provider list from model dialog
|
||||
*/
|
||||
model_provider_list?: string
|
||||
/**
|
||||
* Toggle model favorite status
|
||||
*/
|
||||
model_favorite_toggle?: string
|
||||
/**
|
||||
* Toggle showing all models
|
||||
*/
|
||||
model_show_all_toggle?: string
|
||||
/**
|
||||
* Share current session
|
||||
*/
|
||||
session_share?: string
|
||||
/**
|
||||
* Unshare current session
|
||||
*/
|
||||
session_unshare?: string
|
||||
/**
|
||||
* Interrupt current session
|
||||
*/
|
||||
session_interrupt?: string
|
||||
/**
|
||||
* Compact the session
|
||||
*/
|
||||
session_compact?: string
|
||||
/**
|
||||
* Scroll messages up by one page
|
||||
*/
|
||||
messages_page_up?: string
|
||||
/**
|
||||
* Scroll messages down by one page
|
||||
*/
|
||||
messages_page_down?: string
|
||||
/**
|
||||
* Scroll messages up by one line
|
||||
*/
|
||||
messages_line_up?: string
|
||||
/**
|
||||
* Scroll messages down by one line
|
||||
*/
|
||||
messages_line_down?: string
|
||||
/**
|
||||
* Scroll messages up by half page
|
||||
*/
|
||||
messages_half_page_up?: string
|
||||
/**
|
||||
* Scroll messages down by half page
|
||||
*/
|
||||
messages_half_page_down?: string
|
||||
/**
|
||||
* Navigate to first message
|
||||
*/
|
||||
messages_first?: string
|
||||
/**
|
||||
* Navigate to last message
|
||||
*/
|
||||
messages_last?: string
|
||||
/**
|
||||
* Navigate to next message
|
||||
*/
|
||||
messages_next?: string
|
||||
/**
|
||||
* Navigate to previous message
|
||||
*/
|
||||
messages_previous?: string
|
||||
/**
|
||||
* Navigate to last user message
|
||||
*/
|
||||
messages_last_user?: string
|
||||
/**
|
||||
* Copy message
|
||||
*/
|
||||
messages_copy?: string
|
||||
/**
|
||||
* Undo message
|
||||
*/
|
||||
messages_undo?: string
|
||||
/**
|
||||
* Redo message
|
||||
*/
|
||||
messages_redo?: string
|
||||
/**
|
||||
* Toggle code block concealment in messages
|
||||
*/
|
||||
messages_toggle_conceal?: string
|
||||
/**
|
||||
* Toggle tool details visibility
|
||||
*/
|
||||
tool_details?: string
|
||||
/**
|
||||
* List available models
|
||||
*/
|
||||
model_list?: string
|
||||
/**
|
||||
* Next recently used model
|
||||
*/
|
||||
model_cycle_recent?: string
|
||||
/**
|
||||
* Previous recently used model
|
||||
*/
|
||||
model_cycle_recent_reverse?: string
|
||||
/**
|
||||
* Next favorite model
|
||||
*/
|
||||
model_cycle_favorite?: string
|
||||
/**
|
||||
* Previous favorite model
|
||||
*/
|
||||
model_cycle_favorite_reverse?: string
|
||||
/**
|
||||
* List available commands
|
||||
*/
|
||||
command_list?: string
|
||||
/**
|
||||
* List agents
|
||||
*/
|
||||
agent_list?: string
|
||||
/**
|
||||
* Next agent
|
||||
*/
|
||||
agent_cycle?: string
|
||||
/**
|
||||
* Previous agent
|
||||
*/
|
||||
agent_cycle_reverse?: string
|
||||
/**
|
||||
* Toggle auto-accept mode for permissions
|
||||
*/
|
||||
permission_auto_accept_toggle?: string
|
||||
/**
|
||||
* Cycle model variants
|
||||
*/
|
||||
variant_cycle?: string
|
||||
/**
|
||||
* Clear input field
|
||||
*/
|
||||
input_clear?: string
|
||||
/**
|
||||
* Paste from clipboard
|
||||
*/
|
||||
input_paste?: string
|
||||
/**
|
||||
* Submit input
|
||||
*/
|
||||
input_submit?: string
|
||||
/**
|
||||
* Insert newline in input
|
||||
*/
|
||||
input_newline?: string
|
||||
/**
|
||||
* Move cursor left in input
|
||||
*/
|
||||
input_move_left?: string
|
||||
/**
|
||||
* Move cursor right in input
|
||||
*/
|
||||
input_move_right?: string
|
||||
/**
|
||||
* Move cursor up in input
|
||||
*/
|
||||
input_move_up?: string
|
||||
/**
|
||||
* Move cursor down in input
|
||||
*/
|
||||
input_move_down?: string
|
||||
/**
|
||||
* Select left in input
|
||||
*/
|
||||
input_select_left?: string
|
||||
/**
|
||||
* Select right in input
|
||||
*/
|
||||
input_select_right?: string
|
||||
/**
|
||||
* Select up in input
|
||||
*/
|
||||
input_select_up?: string
|
||||
/**
|
||||
* Select down in input
|
||||
*/
|
||||
input_select_down?: string
|
||||
/**
|
||||
* Move to start of line in input
|
||||
*/
|
||||
input_line_home?: string
|
||||
/**
|
||||
* Move to end of line in input
|
||||
*/
|
||||
input_line_end?: string
|
||||
/**
|
||||
* Select to start of line in input
|
||||
*/
|
||||
input_select_line_home?: string
|
||||
/**
|
||||
* Select to end of line in input
|
||||
*/
|
||||
input_select_line_end?: string
|
||||
/**
|
||||
* Move to start of visual line in input
|
||||
*/
|
||||
input_visual_line_home?: string
|
||||
/**
|
||||
* Move to end of visual line in input
|
||||
*/
|
||||
input_visual_line_end?: string
|
||||
/**
|
||||
* Select to start of visual line in input
|
||||
*/
|
||||
input_select_visual_line_home?: string
|
||||
/**
|
||||
* Select to end of visual line in input
|
||||
*/
|
||||
input_select_visual_line_end?: string
|
||||
/**
|
||||
* Move to start of buffer in input
|
||||
*/
|
||||
input_buffer_home?: string
|
||||
/**
|
||||
* Move to end of buffer in input
|
||||
*/
|
||||
input_buffer_end?: string
|
||||
/**
|
||||
* Select to start of buffer in input
|
||||
*/
|
||||
input_select_buffer_home?: string
|
||||
/**
|
||||
* Select to end of buffer in input
|
||||
*/
|
||||
input_select_buffer_end?: string
|
||||
/**
|
||||
* Delete line in input
|
||||
*/
|
||||
input_delete_line?: string
|
||||
/**
|
||||
* Delete to end of line in input
|
||||
*/
|
||||
input_delete_to_line_end?: string
|
||||
/**
|
||||
* Delete to start of line in input
|
||||
*/
|
||||
input_delete_to_line_start?: string
|
||||
/**
|
||||
* Backspace in input
|
||||
*/
|
||||
input_backspace?: string
|
||||
/**
|
||||
* Delete character in input
|
||||
*/
|
||||
input_delete?: string
|
||||
/**
|
||||
* Undo in input
|
||||
*/
|
||||
input_undo?: string
|
||||
/**
|
||||
* Redo in input
|
||||
*/
|
||||
input_redo?: string
|
||||
/**
|
||||
* Move word forward in input
|
||||
*/
|
||||
input_word_forward?: string
|
||||
/**
|
||||
* Move word backward in input
|
||||
*/
|
||||
input_word_backward?: string
|
||||
/**
|
||||
* Select word forward in input
|
||||
*/
|
||||
input_select_word_forward?: string
|
||||
/**
|
||||
* Select word backward in input
|
||||
*/
|
||||
input_select_word_backward?: string
|
||||
/**
|
||||
* Delete word forward in input
|
||||
*/
|
||||
input_delete_word_forward?: string
|
||||
/**
|
||||
* Delete word backward in input
|
||||
*/
|
||||
input_delete_word_backward?: string
|
||||
/**
|
||||
* Previous history item
|
||||
*/
|
||||
history_previous?: string
|
||||
/**
|
||||
* Next history item
|
||||
*/
|
||||
history_next?: string
|
||||
/**
|
||||
* Next child session
|
||||
*/
|
||||
session_child_cycle?: string
|
||||
/**
|
||||
* Previous child session
|
||||
*/
|
||||
session_child_cycle_reverse?: string
|
||||
/**
|
||||
* Go to parent session
|
||||
*/
|
||||
session_parent?: string
|
||||
/**
|
||||
* Suspend terminal
|
||||
*/
|
||||
terminal_suspend?: string
|
||||
/**
|
||||
* Toggle terminal title
|
||||
*/
|
||||
terminal_title_toggle?: string
|
||||
/**
|
||||
* Toggle tips on home screen
|
||||
*/
|
||||
tips_toggle?: string
|
||||
/**
|
||||
* Toggle thinking blocks visibility
|
||||
*/
|
||||
display_thinking?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Log level
|
||||
*/
|
||||
|
||||
@@ -7,12 +7,8 @@ import { MetaProvider } from "@solidjs/meta"
|
||||
import { addons } from "storybook/preview-api"
|
||||
import { GLOBALS_UPDATED } from "storybook/internal/core-events"
|
||||
import { createJSXDecorator, definePreview } from "storybook-solidjs-vite"
|
||||
import { Code } from "@opencode-ai/ui/code"
|
||||
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { Diff } from "@opencode-ai/ui/diff"
|
||||
import { ThemeProvider, useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
|
||||
@@ -58,20 +54,16 @@ const frame = createJSXDecorator((Story, context) => {
|
||||
<Scheme value={scheme} />
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
<CodeComponentProvider component={Code}>
|
||||
<div
|
||||
style={{
|
||||
"min-height": "100vh",
|
||||
padding: "24px",
|
||||
"background-color": "var(--background-base)",
|
||||
color: "var(--text-base)",
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</CodeComponentProvider>
|
||||
</DiffComponentProvider>
|
||||
<div
|
||||
style={{
|
||||
"min-height": "100vh",
|
||||
padding: "24px",
|
||||
"background-color": "var(--background-base)",
|
||||
color: "var(--text-base)",
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@storybook/addon-a11y": "^10.2.10",
|
||||
"@storybook/addon-docs": "^10.2.10",
|
||||
"@storybook/addon-links": "^10.2.10",
|
||||
"@storybook/addon-onboarding": "^10.2.10",
|
||||
"@storybook/addon-vitest": "^10.2.10",
|
||||
"@storybook/addon-a11y": "^10.2.13",
|
||||
"@storybook/addon-docs": "^10.2.13",
|
||||
"@storybook/addon-links": "^10.2.13",
|
||||
"@storybook/addon-onboarding": "^10.2.13",
|
||||
"@storybook/addon-vitest": "^10.2.13",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "18.0.25",
|
||||
"react": "18.2.0",
|
||||
"solid-js": "catalog:",
|
||||
"storybook": "^10.2.10",
|
||||
"storybook": "^10.2.13",
|
||||
"storybook-solidjs-vite": "^10.0.9",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user