diff --git a/AGENTS.md b/AGENTS.md index c82ab65f..40d39ce3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,9 +152,9 @@ Backend: `packages/ui/src/lib/gitApi.ts` + `packages/web/server/index.js` (simpl ### Terminal `packages/ui/src/components/views/`: TerminalView -`packages/ui/src/components/terminal/`: TerminalViewport (Xterm.js with FitAddon) +`packages/ui/src/components/terminal/`: TerminalViewport (ghostty-web with FitAddon) `packages/ui/src/stores/`: useTerminalStore -Backend: `packages/web/server/index.js` (node-pty wrapper with SSE) +Backend: `packages/web/server/index.js` (bun-pty wrapper with SSE) ### Theme System `packages/ui/src/lib/theme/`: themes (2 definitions), cssGenerator, syntaxThemeGenerator diff --git a/bun.lock b/bun.lock index c29f406d..7ac28cdc 100644 --- a/bun.lock +++ b/bun.lock @@ -23,17 +23,17 @@ "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", "@types/react-syntax-highlighter": "^15.5.13", - "@xterm/addon-fit": "^0.10.0", - "@xterm/xterm": "^5.3.0", + "bun-pty": "^0.4.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "electron-context-menu": "^4.1.1", "electron-store": "^11.0.2", "express": "^5.1.0", + "ghostty-web": "0.3.0", "http-proxy-middleware": "^3.0.5", "next-themes": "^0.4.6", - "node-pty": "^1.0.0", + "node-pty": "^1.1.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", @@ -64,6 +64,7 @@ "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", "nodemon": "^3.1.7", + "patch-package": "^8.0.0", "tailwindcss": "^4.0.0", "tsx": "^4.20.6", "tw-animate-css": "^1.3.8", @@ -115,17 +116,15 @@ "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", "@types/react-syntax-highlighter": "^15.5.13", - "@xterm/addon-fit": "^0.10.0", - "@xterm/xterm": "^5.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", + "ghostty-web": "0.3.0", "heic2any": "^0.0.4", "http-proxy-middleware": "^3.0.5", "motion": "^12.23.24", "next-themes": "^0.4.6", - "node-pty": "^1.0.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-syntax-highlighter": "^15.6.6", @@ -206,15 +205,15 @@ "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", "@types/react-syntax-highlighter": "^15.5.13", - "@xterm/addon-fit": "^0.10.0", - "@xterm/xterm": "^5.3.0", + "bun-pty": "^0.4.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "express": "^5.1.0", + "ghostty-web": "0.3.0", "http-proxy-middleware": "^3.0.5", "next-themes": "^0.4.6", - "node-pty": "^1.0.0", + "node-pty": "^1.1.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", @@ -969,9 +968,7 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], - "@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="], - - "@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="], + "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -1071,6 +1068,8 @@ "builder-util-runtime": ["builder-util-runtime@9.2.4", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA=="], + "bun-pty": ["bun-pty@0.4.5", "", {}, "sha512-r8NL1C+z0Dicl9gyi0QV0DAPEBgoKO5CJuecbeS8fpfEkxBHy8XrJ7ibVBS+YRLWjcky3EKl8BY7nY+l4Jv8DQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1079,6 +1078,8 @@ "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -1469,6 +1470,8 @@ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="], + "flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -1519,6 +1522,8 @@ "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -1649,7 +1654,7 @@ "is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="], - "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -1669,9 +1674,9 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], @@ -1697,6 +1702,8 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="], + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], @@ -1707,6 +1714,8 @@ "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + "jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -1721,6 +1730,8 @@ "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], + "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], @@ -2001,7 +2012,7 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], "openchamber": ["openchamber@workspace:packages/vscode"], @@ -2035,6 +2046,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "patch-package": ["patch-package@8.0.1", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^10.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.2.4", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw=="], + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -2227,6 +2240,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -2257,7 +2272,7 @@ "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], - "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + "slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="], "slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], @@ -2529,6 +2544,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@azure/identity/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2679,6 +2696,8 @@ "globby/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], @@ -2697,6 +2716,8 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], @@ -2773,6 +2794,8 @@ "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + "wsl-utils/is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], + "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -2801,6 +2824,8 @@ "app-builder-lib/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "archiver-utils/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], @@ -2837,6 +2862,8 @@ "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], + "lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], diff --git a/package.json b/package.json index 0a981055..21be985d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "lint:ui": "bun run --cwd packages/ui lint", "lint:desktop": "bun run --cwd packages/desktop lint", "clean": "bun run --filter '*' clean", + "postinstall": "patch-package", "dev:web": "bun run --cwd packages/web build:watch", "dev:web:server": "bun run --cwd packages/web dev:server:watch", "dev:web:full": "concurrently -n \"api,build\" -c \"cyan,magenta\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\"", @@ -74,8 +75,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", "@types/react-syntax-highlighter": "^15.5.13", - "@xterm/addon-fit": "^0.10.0", - "@xterm/xterm": "^5.3.0", + "ghostty-web": "0.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -84,7 +84,8 @@ "express": "^5.1.0", "http-proxy-middleware": "^3.0.5", "next-themes": "^0.4.6", - "node-pty": "^1.0.0", + "bun-pty": "^0.4.5", + "node-pty": "^1.1.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", @@ -115,6 +116,7 @@ "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", "nodemon": "^3.1.7", + "patch-package": "^8.0.0", "tailwindcss": "^4.0.0", "tsx": "^4.20.6", "tw-animate-css": "^1.3.8", diff --git a/packages/desktop/src-tauri/src/assistant_notifications.rs b/packages/desktop/src-tauri/src/assistant_notifications.rs index 2fc1bb6c..4af6d3f9 100644 --- a/packages/desktop/src-tauri/src/assistant_notifications.rs +++ b/packages/desktop/src-tauri/src/assistant_notifications.rs @@ -73,11 +73,13 @@ async fn run_once( let prefix = opencode.api_prefix(); let mut url = format!("http://127.0.0.1:{port}{}/event", prefix); - if let Some(dir) = opencode.get_working_directory().to_str().map(|s| s.to_string()) { + if let Some(dir) = opencode + .get_working_directory() + .to_str() + .map(|s| s.to_string()) + { let mut parsed = reqwest::Url::parse(&url)?; - parsed - .query_pairs_mut() - .append_pair("directory", &dir); + parsed.query_pairs_mut().append_pair("directory", &dir); url = parsed.to_string(); } diff --git a/packages/desktop/src-tauri/src/commands/files.rs b/packages/desktop/src-tauri/src/commands/files.rs index f4afbb4e..a77d8c04 100644 --- a/packages/desktop/src-tauri/src/commands/files.rs +++ b/packages/desktop/src-tauri/src/commands/files.rs @@ -1,5 +1,5 @@ -use crate::{DesktopRuntime, SettingsStore}; use crate::path_utils::expand_tilde_path; +use crate::{DesktopRuntime, SettingsStore}; use serde::Serialize; use std::{ collections::{HashSet, VecDeque}, @@ -233,7 +233,11 @@ pub async fn search_files( let match_all = normalized_query.is_empty(); // Collect more candidates for fuzzy matching, then sort and trim - let collect_limit = if match_all { limit } else { (limit * 3).max(200) }; + let collect_limit = if match_all { + limit + } else { + (limit * 3).max(200) + }; let mut candidates: Vec = Vec::new(); let mut queue = VecDeque::new(); @@ -314,16 +318,14 @@ pub async fn search_files( // Sort by score descending, then by path length, then alphabetically if !match_all { - candidates.sort_by(|a, b| { - match b.score.cmp(&a.score) { - std::cmp::Ordering::Equal => { - match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) { - std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path), - other => other, - } + candidates.sort_by(|a, b| match b.score.cmp(&a.score) { + std::cmp::Ordering::Equal => { + match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) { + std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path), + other => other, } - other => other, } + other => other, }); } @@ -497,7 +499,11 @@ fn fuzzy_match_score(query: &str, candidate: &str) -> Option { continue; } - let search_start = if last_index < 0 { 0 } else { (last_index + 1) as usize }; + let search_start = if last_index < 0 { + 0 + } else { + (last_index + 1) as usize + }; let idx = c[search_start..].iter().position(|&c_char| c_char == *ch); match idx { @@ -520,7 +526,8 @@ fn fuzzy_match_score(query: &str, candidate: &str) -> Option { if idx == 0 { score += 12; } else if let Some(prev) = c.get(idx - 1) { - if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' { + if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' + { score += 10; } } diff --git a/packages/desktop/src-tauri/src/commands/git.rs b/packages/desktop/src-tauri/src/commands/git.rs index c816de7d..8f9da93d 100644 --- a/packages/desktop/src-tauri/src/commands/git.rs +++ b/packages/desktop/src-tauri/src/commands/git.rs @@ -1,5 +1,5 @@ -use crate::{DesktopRuntime, SettingsStore}; use crate::path_utils::expand_tilde_path; +use crate::{DesktopRuntime, SettingsStore}; use anyhow::{anyhow, Context, Result}; use log::{error, info, warn}; use regex::Regex; @@ -435,7 +435,10 @@ async fn resolve_git_paths(root: &Path, path_str: &str) -> (PathBuf, PathBuf, St input_path.to_path_buf() } else { let from_root = root.join(input_path); - if metadata_with_timeout(&from_root, GIT_FILE_DIFF_TIMEOUT_MS).await.is_ok() { + if metadata_with_timeout(&from_root, GIT_FILE_DIFF_TIMEOUT_MS) + .await + .is_ok() + { from_root } else { repo_root.join(input_path) @@ -832,13 +835,10 @@ pub async fn get_git_status( let mut selected_base: Option = None; for candidate in base_candidates { - let verified = run_git_with_allowed_exit( - &["rev-parse", "--verify", &candidate], - &path, - &[1], - ) - .await - .unwrap_or_default(); + let verified = + run_git_with_allowed_exit(&["rev-parse", "--verify", &candidate], &path, &[1]) + .await + .unwrap_or_default(); if !verified.trim().is_empty() { selected_base = Some(candidate); @@ -916,7 +916,9 @@ pub async fn get_git_diff( Ok(output) } -const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp", "avif"]; +const IMAGE_EXTENSIONS: &[&str] = &[ + "png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp", "avif", +]; fn is_image_file(path: &str) -> bool { if let Some(ext) = path.rsplit('.').next() { @@ -970,7 +972,11 @@ fn cap_ipc_payload(value: String) -> String { }; } - truncate_string_to_char_boundary(value, GIT_FILE_IPC_MAX_CHARS, "\n…(truncated for desktop)\n") + truncate_string_to_char_boundary( + value, + GIT_FILE_IPC_MAX_CHARS, + "\n…(truncated for desktop)\n", + ) } async fn run_git_binary(args: &[&str], cwd: &Path) -> Result> { @@ -983,8 +989,8 @@ pub async fn get_git_file_diff( path_str: String, state: State<'_, DesktopRuntime>, ) -> Result<(String, String), String> { + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use tokio::fs; - use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; let root = validate_git_path(&directory, state.settings()) .await @@ -992,7 +998,11 @@ pub async fn get_git_file_diff( let (repo_root, full_path, relative_path) = resolve_path_for_git_show(&root, &path_str).await; let is_image = is_image_file(&relative_path); - let mime_type = if is_image { get_image_mime_type(&relative_path) } else { "" }; + let mime_type = if is_image { + get_image_mime_type(&relative_path) + } else { + "" + }; // Original from HEAD let original = if is_image { @@ -1035,47 +1045,50 @@ pub async fn get_git_file_diff( }; // Modified from working tree (if file exists) - let modified = if let Ok(metadata) = metadata_with_timeout(&full_path, GIT_FILE_DIFF_TIMEOUT_MS).await { - if metadata.is_file() { - if is_image { - // For images, read as binary and convert to data URL - if metadata.len() > GIT_FILE_IMAGE_MAX_BYTES { - String::new() + let modified = + if let Ok(metadata) = metadata_with_timeout(&full_path, GIT_FILE_DIFF_TIMEOUT_MS).await { + if metadata.is_file() { + if is_image { + // For images, read as binary and convert to data URL + if metadata.len() > GIT_FILE_IMAGE_MAX_BYTES { + String::new() + } else { + match tokio::time::timeout( + std::time::Duration::from_millis(GIT_FILE_DIFF_TIMEOUT_MS), + fs::read(&full_path), + ) + .await + { + Ok(Ok(bytes)) => { + format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)) + } + _ => String::new(), + } + } } else { - match tokio::time::timeout( - std::time::Duration::from_millis(GIT_FILE_DIFF_TIMEOUT_MS), - fs::read(&full_path), + match read_file_bytes_limited_with_timeout( + &full_path, + GIT_FILE_TEXT_MAX_BYTES, + GIT_FILE_DIFF_TIMEOUT_MS, ) .await { - Ok(Ok(bytes)) => format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)), - _ => String::new(), + Ok((bytes, truncated)) => { + let mut text = String::from_utf8_lossy(&bytes).to_string(); + if truncated { + text.push_str("\n…(truncated)\n"); + } + text + } + Err(_) => String::new(), } } } else { - match read_file_bytes_limited_with_timeout( - &full_path, - GIT_FILE_TEXT_MAX_BYTES, - GIT_FILE_DIFF_TIMEOUT_MS, - ) - .await - { - Ok((bytes, truncated)) => { - let mut text = String::from_utf8_lossy(&bytes).to_string(); - if truncated { - text.push_str("\n…(truncated)\n"); - } - text - } - Err(_) => String::new(), - } + String::new() } } else { String::new() - } - } else { - String::new() - }; + }; Ok((cap_ipc_payload(original), cap_ipc_payload(modified))) } @@ -1144,31 +1157,32 @@ pub async fn get_git_branches( .map_err(|e| e.to_string())?; // Discover actual remote heads so we can drop stale remote-tracking refs - let allowed_remote_heads: Option> = match run_git_bytes_with_allowed_exit_timeout( - &["ls-remote", "--heads", "origin"], - &root, - &[0], - GIT_LS_REMOTE_TIMEOUT_MS, - ) - .await - { - Ok(bytes) => { - let ls_remote = String::from_utf8_lossy(&bytes); - let mut set = HashSet::new(); - for line in ls_remote.lines() { - if let Some((_, ref_name)) = line.split_once('\t') { - if let Some(stripped) = ref_name.trim().strip_prefix("refs/heads/") { - set.insert(stripped.to_string()); + let allowed_remote_heads: Option> = + match run_git_bytes_with_allowed_exit_timeout( + &["ls-remote", "--heads", "origin"], + &root, + &[0], + GIT_LS_REMOTE_TIMEOUT_MS, + ) + .await + { + Ok(bytes) => { + let ls_remote = String::from_utf8_lossy(&bytes); + let mut set = HashSet::new(); + for line in ls_remote.lines() { + if let Some((_, ref_name)) = line.split_once('\t') { + if let Some(stripped) = ref_name.trim().strip_prefix("refs/heads/") { + set.insert(stripped.to_string()); + } } } + Some(set) } - Some(set) - } - Err(err) => { - warn!("Failed to list remote heads: {}", err); - None - } - }; + Err(err) => { + warn!("Failed to list remote heads: {}", err); + None + } + }; // Structured for-each-ref output so we can mark remotes consistently with the web runtime let output = run_git( @@ -1518,21 +1532,15 @@ pub async fn git_push( let remote_key = format!("branch.{}.remote", branch_name); let merge_key = format!("branch.{}.merge", branch_name); - let upstream_remote = run_git_with_allowed_exit( - &["config", "--get", &remote_key], - &root, - &[1], - ) - .await - .unwrap_or_default(); + let upstream_remote = + run_git_with_allowed_exit(&["config", "--get", &remote_key], &root, &[1]) + .await + .unwrap_or_default(); - let upstream_merge = run_git_with_allowed_exit( - &["config", "--get", &merge_key], - &root, - &[1], - ) - .await - .unwrap_or_default(); + let upstream_merge = + run_git_with_allowed_exit(&["config", "--get", &merge_key], &root, &[1]) + .await + .unwrap_or_default(); if upstream_remote.trim().is_empty() || upstream_merge.trim().is_empty() { args.push("--set-upstream".to_string()); @@ -1904,7 +1912,10 @@ pub async fn get_commit_files( file.path.clone() }; - if let Some(status) = status_map.get(&base_path).or_else(|| status_map.get(&file.path)) { + if let Some(status) = status_map + .get(&base_path) + .or_else(|| status_map.get(&file.path)) + { file.change_type = status.clone(); } } diff --git a/packages/desktop/src-tauri/src/commands/mod.rs b/packages/desktop/src-tauri/src/commands/mod.rs index f877e819..3f612e9d 100644 --- a/packages/desktop/src-tauri/src/commands/mod.rs +++ b/packages/desktop/src-tauri/src/commands/mod.rs @@ -1,7 +1,7 @@ pub mod files; pub mod git; pub mod logs; +pub mod notifications; pub mod permissions; pub mod settings; pub mod terminal; -pub mod notifications; diff --git a/packages/desktop/src-tauri/src/commands/permissions.rs b/packages/desktop/src-tauri/src/commands/permissions.rs index 754cc8d0..bda222e0 100644 --- a/packages/desktop/src-tauri/src/commands/permissions.rs +++ b/packages/desktop/src-tauri/src/commands/permissions.rs @@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize}; use tauri::AppHandle; use tauri::State; -use crate::DesktopRuntime; use crate::path_utils::expand_tilde_path; +use crate::DesktopRuntime; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs index fbc7dd9b..bf8511d4 100644 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ b/packages/desktop/src-tauri/src/commands/settings.rs @@ -3,8 +3,8 @@ use serde_json::{json, Value}; use std::collections::HashSet; use tauri::State; -use crate::DesktopRuntime; use crate::path_utils::expand_tilde_path; +use crate::DesktopRuntime; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -160,7 +160,10 @@ fn sanitize_settings_update(payload: &Value) -> Value { if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") { let parsed = n .as_u64() - .or_else(|| n.as_i64().and_then(|value| if value >= 0 { Some(value as u64) } else { None })) + .or_else(|| { + n.as_i64() + .and_then(|value| if value >= 0 { Some(value as u64) } else { None }) + }) .or_else(|| n.as_f64().map(|value| value.round().max(0.0) as u64)); if let Some(value) = parsed { let clamped = value.max(1).min(365); @@ -198,13 +201,31 @@ fn sanitize_settings_update(payload: &Value) -> Value { let mut catalogs: Vec = vec![]; for entry in arr { - let Some(obj) = entry.as_object() else { continue }; + let Some(obj) = entry.as_object() else { + continue; + }; let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim(); - let label = obj.get("label").and_then(|v| v.as_str()).unwrap_or("").trim(); - let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("").trim(); - let subpath = obj.get("subpath").and_then(|v| v.as_str()).unwrap_or("").trim(); - let git_identity_id = obj.get("gitIdentityId").and_then(|v| v.as_str()).unwrap_or("").trim(); + let label = obj + .get("label") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let source = obj + .get("source") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let subpath = obj + .get("subpath") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let git_identity_id = obj + .get("gitIdentityId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); if id.is_empty() || label.is_empty() || source.is_empty() { continue; diff --git a/packages/desktop/src-tauri/src/commands/terminal.rs b/packages/desktop/src-tauri/src/commands/terminal.rs index 0aa30146..a8866ae9 100644 --- a/packages/desktop/src-tauri/src/commands/terminal.rs +++ b/packages/desktop/src-tauri/src/commands/terminal.rs @@ -1,4 +1,5 @@ use log::error; +use parking_lot::Mutex; use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; use serde::{Deserialize, Serialize}; use std::{ @@ -6,8 +7,9 @@ use std::{ env, io::{Read, Write}, path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::Arc, thread, + time::Duration, }; use tauri::{Emitter, State, Window}; @@ -18,6 +20,10 @@ const DEFAULT_LOCALE: &str = "en_US.UTF-8"; const TERM_PROGRAM_NAME: &str = "OpenChamber"; const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION"); +// Emit at most ~60fps and avoid tiny payload spam. +const EMIT_INTERVAL: Duration = Duration::from_millis(16); +const EMIT_MAX_BUFFER_BYTES: usize = 64 * 1024; + pub struct TerminalSession { pub master: Box, pub writer: Arc>>, @@ -94,7 +100,7 @@ pub async fn create_terminal_session( let child = Arc::new(Mutex::new(child)); let session_id = uuid::Uuid::new_v4().to_string(); - state.sessions.lock().unwrap().insert( + state.sessions.lock().insert( session_id.clone(), TerminalSession { master, @@ -115,21 +121,18 @@ pub async fn send_terminal_input( data: String, state: State<'_, TerminalState>, ) -> Result<(), String> { - let sessions = state.sessions.lock().unwrap(); - let Some(session) = sessions.get(&session_id) else { - return Err("Terminal session not found".to_string()); + let writer = { + let sessions = state.sessions.lock(); + let Some(session) = sessions.get(&session_id) else { + return Err("Terminal session not found".to_string()); + }; + session.writer.clone() }; - let mut writer = session - .writer - .lock() - .map_err(|_| "Terminal busy".to_string())?; - writer + let mut guard = writer.lock(); + guard .write_all(data.as_bytes()) .map_err(|e| format!("Failed to write to terminal: {e}"))?; - writer - .flush() - .map_err(|e| format!("Failed to flush terminal input: {e}"))?; Ok(()) } @@ -140,7 +143,7 @@ pub async fn resize_terminal( rows: u16, state: State<'_, TerminalState>, ) -> Result<(), String> { - let mut sessions = state.sessions.lock().unwrap(); + let mut sessions = state.sessions.lock(); let Some(session) = sessions.get_mut(&session_id) else { return Err("Terminal session not found".to_string()); }; @@ -154,6 +157,7 @@ pub async fn resize_terminal( pixel_height: 0, }) .map_err(|e| format!("Failed to resize terminal: {e}"))?; + Ok(()) } @@ -162,15 +166,10 @@ pub async fn close_terminal( session_id: String, state: State<'_, TerminalState>, ) -> Result<(), String> { - let session = { - let mut sessions = state.sessions.lock().unwrap(); - sessions.remove(&session_id) - }; + let session = { state.sessions.lock().remove(&session_id) }; if let Some(session) = session { - if let Ok(mut child) = session.child.lock() { - let _ = child.kill(); - } + let _ = session.child.lock().kill(); } Ok(()) @@ -191,11 +190,9 @@ pub async fn restart_terminal_session( window: Window, ) -> Result { { - let mut sessions = state.sessions.lock().unwrap(); - if let Some(session) = sessions.remove(&payload.session_id) { - if let Ok(mut child) = session.child.lock() { - let _ = child.kill(); - } + let session = state.sessions.lock().remove(&payload.session_id); + if let Some(session) = session { + let _ = session.child.lock().kill(); } } @@ -239,7 +236,7 @@ pub async fn restart_terminal_session( let child = Arc::new(Mutex::new(child)); let session_id = uuid::Uuid::new_v4().to_string(); - state.sessions.lock().unwrap().insert( + state.sessions.lock().insert( session_id.clone(), TerminalSession { master, @@ -265,65 +262,145 @@ pub async fn force_kill_terminal( payload: ForceKillPayload, state: State<'_, TerminalState>, ) -> Result<(), String> { - let mut sessions = state.sessions.lock().unwrap(); + let mut sessions = state.sessions.lock(); if let Some(session_id) = payload.session_id { - // Kill by session_id if let Some(session) = sessions.remove(&session_id) { - if let Ok(mut child) = session.child.lock() { - let _ = child.kill(); - } + let _ = session.child.lock().kill(); } - } else if let Some(cwd) = payload.cwd { - let ids: Vec = sessions.keys().cloned().collect(); - for id in ids { - if let Some(session) = sessions.remove(&id) { - if let Ok(mut child) = session.child.lock() { - let _ = child.kill(); - } - } - } - let _ = cwd; - } else { - let ids: Vec = sessions.keys().cloned().collect(); - for id in ids { - if let Some(session) = sessions.remove(&id) { - if let Ok(mut child) = session.child.lock() { - let _ = child.kill(); - } - } + return Ok(()); + } + + // Current API ignores cwd; keep behavior but avoid holding poisoned locks. + let _ = payload.cwd; + + let ids: Vec = sessions.keys().cloned().collect(); + for id in ids { + if let Some(session) = sessions.remove(&id) { + let _ = session.child.lock().kill(); } } Ok(()) } -fn spawn_reader_thread(mut reader: Box, window: Window, session_id: String) { +fn spawn_reader_thread(reader: Box, window: Window, session_id: String) { thread::spawn(move || { - let mut buffer = [0u8; 16384]; - let event_name = format!("terminal://{}", session_id); - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(n) => { - let data = String::from_utf8_lossy(&buffer[..n]).to_string(); - if data.is_empty() { - continue; - } + use std::sync::mpsc; + + let event_name = format!("terminal://{}", session_id); + let (tx, rx) = mpsc::channel::>(); + + // Dedicated blocking reader thread. + let reader_handle = thread::spawn(move || { + let mut reader = reader; + let mut buffer = [0u8; 16384]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(n) => { + if tx.send(buffer[..n].to_vec()).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let mut pending = String::new(); + let mut pending_bytes: Vec = Vec::new(); + + let flush = |pending: &mut String| -> bool { + if pending.is_empty() { + return true; + } + + let payload_data = std::mem::take(pending); + let payload = serde_json::json!({ "type": "data", "data": payload_data }); + + match window.emit(&event_name, payload) { + Ok(_) => true, + Err(error) => { + error!("Failed to emit terminal data: {error}"); + false + } + } + }; + + let decode_pending = |pending_bytes: &mut Vec, pending: &mut String| { + loop { + match std::str::from_utf8(pending_bytes) { + Ok(text) => { + if !text.is_empty() { + pending.push_str(text); + } + pending_bytes.clear(); + break; + } + Err(error) => { + let valid = error.valid_up_to(); + if valid > 0 { + let text = std::str::from_utf8(&pending_bytes[..valid]).unwrap_or(""); + if !text.is_empty() { + pending.push_str(text); + } + pending_bytes.drain(..valid); + continue; + } + + // Incomplete UTF-8 at end; wait for more bytes. + if error.error_len().is_none() { + break; + } + + // Invalid leading byte; consume 1 byte and replace. + if !pending_bytes.is_empty() { + pending_bytes.drain(..1); + pending.push('\u{FFFD}'); + continue; + } - if let Err(error) = - window.emit(&event_name, serde_json::json!({ "type": "data", "data": data })) - { - error!("Failed to emit terminal data: {error}"); break; } } - Err(error) => { - error!("Terminal read error: {error}"); + } + }; + + loop { + match rx.recv_timeout(EMIT_INTERVAL) { + Ok(bytes) => { + pending_bytes.extend_from_slice(&bytes); + decode_pending(&mut pending_bytes, &mut pending); + + if pending.len() >= EMIT_MAX_BUFFER_BYTES { + if !flush(&mut pending) { + break; + } + } + } + Err(mpsc::RecvTimeoutError::Timeout) => { + // Flush any buffered output even if the PTY is idle. + if !pending_bytes.is_empty() { + pending.push_str(&String::from_utf8_lossy(&pending_bytes)); + pending_bytes.clear(); + } + if !flush(&mut pending) { + break; + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + if !pending_bytes.is_empty() { + pending.push_str(&String::from_utf8_lossy(&pending_bytes)); + pending_bytes.clear(); + } + let _ = flush(&mut pending); break; } } } + + let _ = reader_handle.join(); }); } @@ -334,10 +411,7 @@ fn spawn_exit_watcher( session_id: String, ) { thread::spawn(move || { - let status = { - let mut guard = child.lock().expect("terminal child poisoned"); - guard.wait() - }; + let status = { child.lock().wait() }; let (exit_code, signal) = match status { Ok(status) => ( @@ -358,8 +432,7 @@ fn spawn_exit_watcher( }); let _ = window.emit(&event_name, payload); - let mut sessions = sessions.lock().unwrap(); - sessions.remove(&session_id); + sessions.lock().remove(&session_id); }); } @@ -388,9 +461,7 @@ fn shell_accepts_login_flag(shell_path: &str) -> bool { } fn resolve_working_directory(input: Option<&str>) -> Result { - let maybe_path = input - .map(|value| PathBuf::from(value)) - .or_else(|| dirs::home_dir()); + let maybe_path = input.map(PathBuf::from).or_else(|| dirs::home_dir()); let Some(path) = maybe_path else { return Err("Unable to determine working directory".to_string()); diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index e69de29b..8b137891 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -0,0 +1 @@ + diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index d74dff80..3569d521 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1,19 +1,25 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod assistant_notifications; mod commands; mod logging; -mod assistant_notifications; -mod session_activity; mod opencode_auth; mod opencode_config; mod opencode_manager; -mod window_state; mod path_utils; +mod session_activity; mod skills_catalog; +mod window_state; -use std::{collections::HashMap, path::PathBuf, sync::Arc, time::{Duration, Instant}}; +use std::{ + collections::HashMap, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; use anyhow::{anyhow, Result}; +use assistant_notifications::spawn_assistant_notifications; use axum::{ body::{to_bytes, Body}, extract::{OriginalUri, State}, @@ -22,23 +28,22 @@ use axum::{ routing::{any, get, post}, Json, Router, }; -use assistant_notifications::spawn_assistant_notifications; -use session_activity::spawn_session_activity_tracker; use commands::files::{create_directory, list_directory, search_files}; use commands::git::{ add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch, - ensure_openchamber_ignored, generate_commit_message, get_commit_files, get_current_git_identity, - get_git_branches, get_git_diff, get_git_file_diff, get_git_identities, get_git_log, get_git_status, - git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree, - revert_git_file, set_git_identity, update_git_identity, + ensure_openchamber_ignored, generate_commit_message, get_commit_files, + get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff, + get_git_identities, get_git_log, get_git_status, git_fetch, git_pull, git_push, + is_linked_worktree, list_git_worktrees, remove_git_worktree, revert_git_file, set_git_identity, + update_git_identity, }; use commands::logs::fetch_desktop_logs; +use commands::notifications::desktop_notify; use commands::permissions::{ pick_directory, process_directory_selection, request_directory_access, restore_bookmarks_on_startup, start_accessing_directory, stop_accessing_directory, }; -use commands::notifications::desktop_notify; use commands::settings::{load_settings, restart_opencode, save_settings}; use commands::terminal::{ close_terminal, create_terminal_session, force_kill_terminal, resize_terminal, @@ -47,13 +52,15 @@ use commands::terminal::{ use futures_util::StreamExt as FuturesStreamExt; use log::{error, info, warn}; use opencode_manager::OpenCodeManager; +use path_utils::expand_tilde_path; use portpicker::pick_unused_port; use reqwest::{header, Body as ReqwestBody, Client}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tauri::{Emitter, Manager}; +use session_activity::spawn_session_activity_tracker; #[cfg(feature = "devtools")] use tauri::WebviewWindow; +use tauri::{Emitter, Manager}; use tauri_plugin_dialog::init as dialog_plugin; use tauri_plugin_fs::init as fs_plugin; use tauri_plugin_log::{Target, TargetKind}; @@ -66,7 +73,6 @@ use tokio::{ }; use tower_http::cors::CorsLayer; use window_state::{load_window_state, persist_window_state, WindowStateManager}; -use path_utils::expand_tilde_path; #[cfg(target_os = "macos")] use std::sync::atomic::{AtomicBool, Ordering}; @@ -134,8 +140,10 @@ const MENU_ITEM_HELP_DIALOG_ID: &str = "openchamber_help_dialog"; #[cfg(target_os = "macos")] const MENU_ITEM_DOWNLOAD_LOGS_ID: &str = "openchamber_download_logs"; -const GITHUB_BUG_REPORT_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml"; -const GITHUB_FEATURE_REQUEST_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml"; +const GITHUB_BUG_REPORT_URL: &str = + "https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml"; +const GITHUB_FEATURE_REQUEST_URL: &str = + "https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml"; const DISCORD_INVITE_URL: &str = "https://discord.gg/ZYRSdnwwKA"; #[derive(Clone)] @@ -149,7 +157,9 @@ pub(crate) struct DesktopRuntime { impl DesktopRuntime { fn initialize_sync() -> Result { let settings = Arc::new(SettingsStore::new()?); - let initial_dir = tauri::async_runtime::block_on(settings.last_directory()).ok().flatten(); + let initial_dir = tauri::async_runtime::block_on(settings.last_directory()) + .ok() + .flatten(); let opencode = Arc::new(OpenCodeManager::new_with_directory(initial_dir.clone())); let client = Client::builder().build()?; @@ -265,7 +275,13 @@ struct ServerInfoPayload { async fn desktop_server_info( state: tauri::State<'_, DesktopRuntime>, ) -> Result { - let has_last_directory = state.settings().last_directory().await.ok().flatten().is_some(); + let has_last_directory = state + .settings() + .last_directory() + .await + .ok() + .flatten() + .is_some(); Ok(ServerInfoPayload { server_port: state.server_port, opencode_port: state.opencode.current_port(), @@ -300,10 +316,14 @@ fn get_macos_major_version() -> isize { } #[cfg(target_os = "macos")] -fn adjust_traffic_lights_position(window: &tauri::WebviewWindow, x: f64, y: f64) { - use objc2::runtime::AnyObject; +fn adjust_traffic_lights_position( + window: &tauri::WebviewWindow, + x: f64, + y: f64, +) { use objc2::msg_send; - use objc2_foundation::{NSRect, NSPoint}; + use objc2::runtime::AnyObject; + use objc2_foundation::{NSPoint, NSRect}; if let Ok(ns_window) = window.ns_window() { unsafe { @@ -338,8 +358,12 @@ fn prevent_app_nap() { } #[cfg(target_os = "macos")] -fn build_macos_menu(app: &tauri::AppHandle) -> tauri::Result> { - use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID}; +fn build_macos_menu( + app: &tauri::AppHandle, +) -> tauri::Result> { + use tauri::menu::{ + Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID, + }; let pkg_info = app.package_info(); @@ -360,13 +384,7 @@ fn build_macos_menu(app: &tauri::AppHandle) -> tauri::Resu )?; // App menu items - let settings = MenuItem::with_id( - app, - MENU_ITEM_SETTINGS_ID, - "Settings", - true, - Some("Cmd+,"), - )?; + let settings = MenuItem::with_id(app, MENU_ITEM_SETTINGS_ID, "Settings", true, Some("Cmd+,"))?; let command_palette = MenuItem::with_id( app, @@ -402,13 +420,8 @@ fn build_macos_menu(app: &tauri::AppHandle) -> tauri::Resu )?; // View menu items - let open_git_tab = MenuItem::with_id( - app, - MENU_ITEM_OPEN_GIT_TAB_ID, - "Git", - true, - Some("Ctrl+G"), - )?; + let open_git_tab = + MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Ctrl+G"))?; let open_diff_tab = MenuItem::with_id( app, @@ -664,12 +677,21 @@ fn main() { info!("[macos] Detected macOS version: {}", macos_version); let corner_radius = if macos_version >= 26 { 24.0 } else { 10.0 }; - if let Err(error) = - apply_vibrancy(&window, NSVisualEffectMaterial::Sidebar, None, Some(corner_radius)) - { - warn!("[desktop:vibrancy] Failed to apply macOS vibrancy: {}", error); + if let Err(error) = apply_vibrancy( + &window, + NSVisualEffectMaterial::Sidebar, + None, + Some(corner_radius), + ) { + warn!( + "[desktop:vibrancy] Failed to apply macOS vibrancy: {}", + error + ); } else { - info!("[desktop:vibrancy] Applied macOS Sidebar vibrancy with radius {}", corner_radius); + info!( + "[desktop:vibrancy] Applied macOS Sidebar vibrancy with radius {}", + corner_radius + ); } if macos_version < 26 { @@ -691,7 +713,11 @@ fn main() { let app_handle = app.app_handle().clone(); let runtime_clone = runtime.clone(); - let has_initial_dir = tauri::async_runtime::block_on(runtime.settings().last_directory()).ok().flatten().is_some(); + let has_initial_dir = + tauri::async_runtime::block_on(runtime.settings().last_directory()) + .ok() + .flatten() + .is_some(); tauri::async_runtime::spawn(async move { // Only start opencode if we have a saved directory, otherwise frontend will prompt if has_initial_dir { @@ -700,7 +726,9 @@ fn main() { info!("[desktop] No saved directory - waiting for user to select one"); } - if let Err(e) = restore_bookmarks_on_startup(app_handle.state::().clone()).await { + if let Err(e) = + restore_bookmarks_on_startup(app_handle.state::().clone()).await + { warn!("Failed to restore bookmarks on startup: {}", e); } @@ -728,8 +756,12 @@ fn main() { Ok(false) => { let _ = app_handle.emit("server.instance.disposed", ()); if runtime.opencode_manager().is_cli_available() { - if let Err(err) = runtime.opencode_manager().ensure_running().await { - warn!("[desktop:watchdog] Failed to restart OpenCode: {err}"); + if let Err(err) = + runtime.opencode_manager().ensure_running().await + { + warn!( + "[desktop:watchdog] Failed to restart OpenCode: {err}" + ); } else { backoff_ms = 1000; } @@ -779,10 +811,12 @@ fn main() { }; let changed = match &last_snapshot { - Some(prev) => prev.ok != snapshot.ok - || prev.port != snapshot.port - || prev.api_prefix != snapshot.api_prefix - || prev.cli_available != snapshot.cli_available, + Some(prev) => { + prev.ok != snapshot.ok + || prev.port != snapshot.port + || prev.api_prefix != snapshot.api_prefix + || prev.cli_available != snapshot.cli_available + } None => true, }; @@ -990,7 +1024,9 @@ fn main() { tauri::WindowEvent::Focused(true) => { // Clear dock badge and underlying badge state when the window gains focus let _ = window.set_badge_count(None); - let _ = window.app_handle().emit("openchamber:clear-badge-sessions", ()); + let _ = window + .app_handle() + .emit("openchamber:clear-badge-sessions", ()); } tauri::WindowEvent::Moved(position) => { let is_maximized = window.is_maximized().unwrap_or(false); @@ -1037,7 +1073,6 @@ fn main() { app.run(|_app_handle, _event| {}); } - fn spawn_http_server(port: u16, state: ServerState, shutdown_rx: broadcast::Receiver<()>) { tauri::async_runtime::spawn(async move { if let Err(error) = run_http_server(port, state, shutdown_rx).await { @@ -1053,7 +1088,10 @@ async fn run_http_server( ) -> Result<()> { let router = Router::new() .route("/health", get(health_handler)) - .route("/api/openchamber/models-metadata", get(models_metadata_handler)) + .route( + "/api/openchamber/models-metadata", + get(models_metadata_handler), + ) .route("/api/opencode/directory", post(change_directory_handler)) .route("/api", any(proxy_to_opencode)) .route("/api/{*rest}", any(proxy_to_opencode)) @@ -1084,7 +1122,9 @@ async fn health_handler(State(state): State) -> Json) -> Result, StatusCode> { +async fn models_metadata_handler( + State(state): State, +) -> Result, StatusCode> { let now = Instant::now(); let cached_payload: Option = { let cache = state.models_metadata_cache.lock().await; @@ -1150,12 +1190,17 @@ fn json_response(status: StatusCode, payload: T) -> Response } fn config_error_response(status: StatusCode, message: impl Into) -> Response { - json_response(status, ConfigErrorResponse { - error: message.into(), - }) + json_response( + status, + ConfigErrorResponse { + error: message.into(), + }, + ) } -async fn parse_request_payload(req: Request) -> Result, Response> { +async fn parse_request_payload( + req: Request, +) -> Result, Response> { let (_, body) = req.into_parts(); let body_bytes = to_bytes(body, PROXY_BODY_LIMIT) .await @@ -1174,14 +1219,12 @@ async fn refresh_opencode_after_config_change( reason: &str, ) -> Result<(), Response> { info!("[desktop:config] Restarting OpenCode after {}", reason); - state - .opencode - .restart() - .await - .map_err(|err| config_error_response( + state.opencode.restart().await.map_err(|err| { + config_error_response( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to restart OpenCode: {}", err), - ))?; + ) + })?; Ok(()) } @@ -1193,7 +1236,7 @@ async fn handle_agent_route( ) -> Result, StatusCode> { // Get working directory for project-level agent detection let working_directory = state.opencode.get_working_directory(); - + match method { Method::GET => { match opencode_config::get_agent_sources(&name, Some(&working_directory)).await { @@ -1226,9 +1269,10 @@ async fn handle_agent_route( Ok(data) => data, Err(resp) => return Ok(resp), }; - + // Extract scope from payload if present - let scope = payload.get("scope") + let scope = payload + .get("scope") .and_then(|v| v.as_str()) .and_then(|s| match s { "project" => Some(opencode_config::AgentScope::Project), @@ -1236,7 +1280,9 @@ async fn handle_agent_route( _ => None, }); - match opencode_config::create_agent(&name, &payload, Some(&working_directory), scope).await { + match opencode_config::create_agent(&name, &payload, Some(&working_directory), scope) + .await + { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "agent creation").await @@ -1302,35 +1348,37 @@ async fn handle_agent_route( } } } - Method::DELETE => match opencode_config::delete_agent(&name, Some(&working_directory)).await { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "agent deletion").await - { - return Ok(resp); - } + Method::DELETE => { + match opencode_config::delete_agent(&name, Some(&working_directory)).await { + Ok(()) => { + if let Err(resp) = + refresh_opencode_after_config_change(state, "agent deletion").await + { + return Ok(resp); + } - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Agent {} deleted successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) + Ok(json_response( + StatusCode::OK, + ConfigActionResponse { + success: true, + requires_reload: true, + message: format!( + "Agent {} deleted successfully. Reloading interface...", + name + ), + reload_delay_ms: CLIENT_RELOAD_DELAY_MS, + }, + )) + } + Err(err) => { + error!("[desktop:config] Failed to delete agent {}: {}", name, err); + Ok(config_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.to_string(), + )) + } } - Err(err) => { - error!("[desktop:config] Failed to delete agent {}: {}", name, err); - Ok(config_error_response( - StatusCode::INTERNAL_SERVER_ERROR, - err.to_string(), - )) - } - }, + } _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), } } @@ -1366,12 +1414,10 @@ struct SkillFileResponse { content: String, } -async fn handle_skill_list_route( - state: &ServerState, -) -> Result, StatusCode> { +async fn handle_skill_list_route(state: &ServerState) -> Result, StatusCode> { let working_directory = state.opencode.get_working_directory(); let discovered = opencode_config::discover_skills(Some(&working_directory)); - + let mut skills = Vec::new(); for skill in discovered { match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await { @@ -1385,12 +1431,18 @@ async fn handle_skill_list_route( }); } Err(err) => { - error!("[desktop:config] Failed to get skill sources for {}: {}", skill.name, err); + error!( + "[desktop:config] Failed to get skill sources for {}: {}", + skill.name, err + ); } } } - - Ok(json_response(StatusCode::OK, serde_json::json!({ "skills": skills }))) + + Ok(json_response( + StatusCode::OK, + serde_json::json!({ "skills": skills }), + )) } async fn handle_skill_route( @@ -1401,7 +1453,7 @@ async fn handle_skill_route( file_path: Option, ) -> Result, StatusCode> { let working_directory = state.opencode.get_working_directory(); - + // Handle file operations: /api/config/skills/:name/files/* if let Some(ref fp) = file_path { match method { @@ -1410,17 +1462,37 @@ async fn handle_skill_route( match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { Ok(sources) => { if !sources.md.exists { - return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found")); + return Ok(config_error_response( + StatusCode::NOT_FOUND, + "Skill not found", + )); } let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - match opencode_config::read_skill_supporting_file(std::path::Path::new(&skill_dir), fp).await { - Ok(content) => Ok(json_response(StatusCode::OK, SkillFileResponse { path: fp.clone(), content })), - Err(_) => Ok(config_error_response(StatusCode::NOT_FOUND, "File not found")), + match opencode_config::read_skill_supporting_file( + std::path::Path::new(&skill_dir), + fp, + ) + .await + { + Ok(content) => Ok(json_response( + StatusCode::OK, + SkillFileResponse { + path: fp.clone(), + content, + }, + )), + Err(_) => Ok(config_error_response( + StatusCode::NOT_FOUND, + "File not found", + )), } } Err(err) => { error!("[desktop:config] Failed to read skill sources: {}", err); - Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to read skill")) + Ok(config_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to read skill", + )) } } } @@ -1430,25 +1502,46 @@ async fn handle_skill_route( Ok(data) => data, Err(resp) => return Ok(resp), }; - let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or(""); - + let content = payload + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or(""); + match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { Ok(sources) => { if !sources.md.exists { - return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found")); + return Ok(config_error_response( + StatusCode::NOT_FOUND, + "Skill not found", + )); } let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - match opencode_config::write_skill_supporting_file(std::path::Path::new(&skill_dir), fp, content).await { - Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse { - success: true, - requires_reload: false, - message: format!("File {} saved successfully", fp), - reload_delay_ms: 0, - })), - Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), + match opencode_config::write_skill_supporting_file( + std::path::Path::new(&skill_dir), + fp, + content, + ) + .await + { + Ok(()) => Ok(json_response( + StatusCode::OK, + ConfigActionResponse { + success: true, + requires_reload: false, + message: format!("File {} saved successfully", fp), + reload_delay_ms: 0, + }, + )), + Err(err) => Ok(config_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.to_string(), + )), } } - Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), + Err(err) => Ok(config_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.to_string(), + )), } } Method::DELETE => { @@ -1456,20 +1549,37 @@ async fn handle_skill_route( match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { Ok(sources) => { if !sources.md.exists { - return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found")); + return Ok(config_error_response( + StatusCode::NOT_FOUND, + "Skill not found", + )); } let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - match opencode_config::delete_skill_supporting_file(std::path::Path::new(&skill_dir), fp).await { - Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse { - success: true, - requires_reload: false, - message: format!("File {} deleted successfully", fp), - reload_delay_ms: 0, - })), - Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), + match opencode_config::delete_skill_supporting_file( + std::path::Path::new(&skill_dir), + fp, + ) + .await + { + Ok(()) => Ok(json_response( + StatusCode::OK, + ConfigActionResponse { + success: true, + requires_reload: false, + message: format!("File {} deleted successfully", fp), + reload_delay_ms: 0, + }, + )), + Err(err) => Ok(config_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.to_string(), + )), } } - Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), + Err(err) => Ok(config_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.to_string(), + )), } } _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), @@ -1507,8 +1617,9 @@ async fn handle_skill_route( Ok(data) => data, Err(resp) => return Ok(resp), }; - - let scope = payload.get("scope") + + let scope = payload + .get("scope") .and_then(|v| v.as_str()) .and_then(|s| match s { "project" => Some(opencode_config::SkillScope::Project), @@ -1516,7 +1627,14 @@ async fn handle_skill_route( _ => None, }); - match opencode_config::create_skill(&name, &payload, Some(&working_directory), scope).await { + match opencode_config::create_skill( + &name, + &payload, + Some(&working_directory), + scope, + ) + .await + { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "skill creation").await @@ -1552,7 +1670,8 @@ async fn handle_skill_route( Err(resp) => return Ok(resp), }; - match opencode_config::update_skill(&name, &payload, Some(&working_directory)).await { + match opencode_config::update_skill(&name, &payload, Some(&working_directory)).await + { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "skill update").await @@ -1582,37 +1701,39 @@ async fn handle_skill_route( } } } - Method::DELETE => match opencode_config::delete_skill(&name, Some(&working_directory)).await { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "skill deletion").await - { - return Ok(resp); - } + Method::DELETE => { + match opencode_config::delete_skill(&name, Some(&working_directory)).await { + Ok(()) => { + if let Err(resp) = + refresh_opencode_after_config_change(state, "skill deletion").await + { + return Ok(resp); + } - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Skill {} deleted successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) + Ok(json_response( + StatusCode::OK, + ConfigActionResponse { + success: true, + requires_reload: true, + message: format!( + "Skill {} deleted successfully. Reloading interface...", + name + ), + reload_delay_ms: CLIENT_RELOAD_DELAY_MS, + }, + )) + } + Err(err) => { + error!("[desktop:config] Failed to delete skill {}: {}", name, err); + let status = if err.to_string().contains("not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + Ok(config_error_response(status, err.to_string())) + } } - Err(err) => { - error!("[desktop:config] Failed to delete skill {}: {}", name, err); - let status = if err.to_string().contains("not found") { - StatusCode::NOT_FOUND - } else { - StatusCode::INTERNAL_SERVER_ERROR - }; - Ok(config_error_response(status, err.to_string())) - } - }, + } _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), } } @@ -1626,7 +1747,7 @@ async fn handle_command_route( ) -> Result, StatusCode> { // Get working directory for project-level command detection let working_directory = state.opencode.get_working_directory(); - + match method { Method::GET => { match opencode_config::get_command_sources(&name, Some(&working_directory)).await { @@ -1659,9 +1780,10 @@ async fn handle_command_route( Ok(data) => data, Err(resp) => return Ok(resp), }; - + // Extract scope from payload if present - let scope = payload.get("scope") + let scope = payload + .get("scope") .and_then(|v| v.as_str()) .and_then(|s| match s { "project" => Some(opencode_config::CommandScope::Project), @@ -1669,7 +1791,9 @@ async fn handle_command_route( _ => None, }); - match opencode_config::create_command(&name, &payload, Some(&working_directory), scope).await { + match opencode_config::create_command(&name, &payload, Some(&working_directory), scope) + .await + { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "command creation").await @@ -1691,7 +1815,10 @@ async fn handle_command_route( )) } Err(err) => { - error!("[desktop:config] Failed to create command {}: {}", name, err); + error!( + "[desktop:config] Failed to create command {}: {}", + name, err + ); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), @@ -1727,7 +1854,10 @@ async fn handle_command_route( )) } Err(err) => { - error!("[desktop:config] Failed to update command {}: {}", name, err); + error!( + "[desktop:config] Failed to update command {}: {}", + name, err + ); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), @@ -1735,37 +1865,42 @@ async fn handle_command_route( } } } - Method::DELETE => match opencode_config::delete_command(&name, Some(&working_directory)).await { - Ok(()) => { - if let Err(resp) = - refresh_opencode_after_config_change(state, "command deletion").await - { - return Ok(resp); - } + Method::DELETE => { + match opencode_config::delete_command(&name, Some(&working_directory)).await { + Ok(()) => { + if let Err(resp) = + refresh_opencode_after_config_change(state, "command deletion").await + { + return Ok(resp); + } - Ok(json_response( - StatusCode::OK, - ConfigActionResponse { - success: true, - requires_reload: true, - message: format!( - "Command {} deleted successfully. Reloading interface...", - name - ), - reload_delay_ms: CLIENT_RELOAD_DELAY_MS, - }, - )) + Ok(json_response( + StatusCode::OK, + ConfigActionResponse { + success: true, + requires_reload: true, + message: format!( + "Command {} deleted successfully. Reloading interface...", + name + ), + reload_delay_ms: CLIENT_RELOAD_DELAY_MS, + }, + )) + } + Err(err) => { + error!( + "[desktop:config] Failed to delete command {}: {}", + name, err + ); + let status = if err.to_string().contains("not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + Ok(config_error_response(status, err.to_string())) + } } - Err(err) => { - error!("[desktop:config] Failed to delete command {}: {}", name, err); - let status = if err.to_string().contains("not found") { - StatusCode::NOT_FOUND - } else { - StatusCode::INTERNAL_SERVER_ERROR - }; - Ok(config_error_response(status, err.to_string())) - } - }, + } _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), } } @@ -1818,25 +1953,26 @@ async fn handle_config_routes( }; let payload_value = serde_json::Value::Object(payload_map.into_iter().collect()); - let scan_request = match serde_json::from_value::(payload_value) { - Ok(v) => v, - Err(_) => { - return Ok(json_response( - StatusCode::BAD_REQUEST, - skills_catalog::SkillsRepoScanResponse { - ok: false, - items: None, - error: Some(skills_catalog::SkillsRepoError { - kind: "invalidSource".to_string(), - message: "Malformed scan request".to_string(), - ssh_only: None, - identities: None, - conflicts: None, - }), - }, - )) - } - }; + let scan_request = + match serde_json::from_value::(payload_value) { + Ok(v) => v, + Err(_) => { + return Ok(json_response( + StatusCode::BAD_REQUEST, + skills_catalog::SkillsRepoScanResponse { + ok: false, + items: None, + error: Some(skills_catalog::SkillsRepoError { + kind: "invalidSource".to_string(), + message: "Malformed scan request".to_string(), + ssh_only: None, + identities: None, + conflicts: None, + }), + }, + )) + } + }; let response = skills_catalog::scan_repository(scan_request).await; let status = if response.ok { @@ -1857,26 +1993,27 @@ async fn handle_config_routes( }; let payload_value = serde_json::Value::Object(payload_map.into_iter().collect()); - let install_request = match serde_json::from_value::(payload_value) { - Ok(v) => v, - Err(_) => { - return Ok(json_response( - StatusCode::BAD_REQUEST, - skills_catalog::SkillsInstallResponse { - ok: false, - installed: None, - skipped: None, - error: Some(skills_catalog::SkillsRepoError { - kind: "invalidSource".to_string(), - message: "Malformed install request".to_string(), - ssh_only: None, - identities: None, - conflicts: None, - }), - }, - )) - } - }; + let install_request = + match serde_json::from_value::(payload_value) { + Ok(v) => v, + Err(_) => { + return Ok(json_response( + StatusCode::BAD_REQUEST, + skills_catalog::SkillsInstallResponse { + ok: false, + installed: None, + skipped: None, + error: Some(skills_catalog::SkillsRepoError { + kind: "invalidSource".to_string(), + message: "Malformed install request".to_string(), + ssh_only: None, + identities: None, + conflicts: None, + }), + }, + )) + } + }; let working_directory = state.opencode.get_working_directory(); let response = skills_catalog::install_skills(&working_directory, install_request).await; @@ -1904,7 +2041,7 @@ async fn handle_config_routes( if let Some(files_start) = rest.find("/files/") { let name = &rest[..files_start]; let file_path_encoded = &rest[files_start + 7..]; // Skip "/files/" - // Decode URL-encoded path (e.g., "docs%2Foptimization.md" -> "docs/optimization.md") + // Decode URL-encoded path (e.g., "docs%2Foptimization.md" -> "docs/optimization.md") let file_path = urlencoding::decode(file_path_encoded) .map(|s| s.into_owned()) .unwrap_or_else(|_| file_path_encoded.to_string()); @@ -1914,9 +2051,10 @@ async fn handle_config_routes( "Skill name is required", )); } - return handle_skill_route(&state, method, req, name.to_string(), Some(file_path)).await; + return handle_skill_route(&state, method, req, name.to_string(), Some(file_path)) + .await; } - + let trimmed = rest.trim(); if trimmed.is_empty() { return Ok(config_error_response( @@ -1939,8 +2077,7 @@ async fn handle_config_routes( ConfigActionResponse { success: true, requires_reload: true, - message: "Configuration reloaded successfully. Refreshing interface..." - .to_string(), + message: "Configuration reloaded successfully. Refreshing interface...".to_string(), reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )); @@ -2063,12 +2200,12 @@ async fn change_directory_handler( .set_working_directory(resolved_path.clone()) .await .map_err(|e| { - error!( - "[desktop:http] ERROR: Failed to set working directory: {}", - e - ); - StatusCode::INTERNAL_SERVER_ERROR - })?; + error!( + "[desktop:http] ERROR: Failed to set working directory: {}", + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; state.opencode.restart().await.map_err(|e| { error!("[desktop:http] ERROR: Failed to restart OpenCode: {}", e); diff --git a/packages/desktop/src-tauri/src/opencode_auth.rs b/packages/desktop/src-tauri/src/opencode_auth.rs index 633ab363..bd843c61 100644 --- a/packages/desktop/src-tauri/src/opencode_auth.rs +++ b/packages/desktop/src-tauri/src/opencode_auth.rs @@ -94,5 +94,3 @@ pub async fn remove_provider_auth(provider_id: &str) -> Result { Ok(true) } - - diff --git a/packages/desktop/src-tauri/src/opencode_config.rs b/packages/desktop/src-tauri/src/opencode_config.rs index 0df3c7db..f61e0281 100644 --- a/packages/desktop/src-tauri/src/opencode_config.rs +++ b/packages/desktop/src-tauri/src/opencode_config.rs @@ -199,7 +199,11 @@ struct JsonEntrySource { section: Option, } -fn get_json_entry_source(layers: &ConfigLayers, section_key: &str, entry_name: &str) -> JsonEntrySource { +fn get_json_entry_source( + layers: &ConfigLayers, + section_key: &str, + entry_name: &str, +) -> JsonEntrySource { if let Some(ref custom_path) = layers.paths.custom { if let Some(section) = layers.custom.get(section_key).and_then(|v| v.as_object()) { if let Some(value) = section.get(entry_name) { @@ -308,30 +312,37 @@ async fn ensure_project_agent_dir(working_directory: &Path) -> Result { } /// Determine agent scope based on where the .md file exists -pub fn get_agent_scope(agent_name: &str, working_directory: Option<&Path>) -> (Option, Option) { +pub fn get_agent_scope( + agent_name: &str, + working_directory: Option<&Path>, +) -> (Option, Option) { if let Some(wd) = working_directory { let project_path = get_project_agent_path(wd, agent_name); if project_path.exists() { return (Some(AgentScope::Project), Some(project_path)); } } - + let user_path = get_user_agent_path(agent_name); if user_path.exists() { return (Some(AgentScope::User), Some(user_path)); } - + (None, None) } /// Get the path where an agent should be written based on scope -fn get_agent_write_path(agent_name: &str, working_directory: Option<&Path>, requested_scope: Option) -> (AgentScope, PathBuf) { +fn get_agent_write_path( + agent_name: &str, + working_directory: Option<&Path>, + requested_scope: Option, +) -> (AgentScope, PathBuf) { // For updates: check existing location first (project takes precedence) let (existing_scope, existing_path) = get_agent_scope(agent_name, working_directory); if let Some(path) = existing_path { return (existing_scope.unwrap(), path); } - + // For new agents or built-in overrides: use requested scope or default to user let scope = requested_scope.unwrap_or(AgentScope::User); if scope == AgentScope::Project { @@ -339,7 +350,7 @@ fn get_agent_write_path(agent_name: &str, working_directory: Option<&Path>, requ return (AgentScope::Project, get_project_agent_path(wd, agent_name)); } } - + (AgentScope::User, get_user_agent_path(agent_name)) } @@ -368,38 +379,48 @@ async fn ensure_project_command_dir(working_directory: &Path) -> Result } /// Determine command scope based on where the .md file exists -pub fn get_command_scope(command_name: &str, working_directory: Option<&Path>) -> (Option, Option) { +pub fn get_command_scope( + command_name: &str, + working_directory: Option<&Path>, +) -> (Option, Option) { if let Some(wd) = working_directory { let project_path = get_project_command_path(wd, command_name); if project_path.exists() { return (Some(CommandScope::Project), Some(project_path)); } } - + let user_path = get_user_command_path(command_name); if user_path.exists() { return (Some(CommandScope::User), Some(user_path)); } - + (None, None) } /// Get the path where a command should be written based on scope -fn get_command_write_path(command_name: &str, working_directory: Option<&Path>, requested_scope: Option) -> (CommandScope, PathBuf) { +fn get_command_write_path( + command_name: &str, + working_directory: Option<&Path>, + requested_scope: Option, +) -> (CommandScope, PathBuf) { // For updates: check existing location first (project takes precedence) let (existing_scope, existing_path) = get_command_scope(command_name, working_directory); if let Some(path) = existing_path { return (existing_scope.unwrap(), path); } - + // For new commands or built-in overrides: use requested scope or default to user let scope = requested_scope.unwrap_or(CommandScope::User); if scope == CommandScope::Project { if let Some(wd) = working_directory { - return (CommandScope::Project, get_project_command_path(wd, command_name)); + return ( + CommandScope::Project, + get_project_command_path(wd, command_name), + ); } } - + (CommandScope::User, get_user_command_path(command_name)) } @@ -606,17 +627,20 @@ async fn write_md_file( } /// Get information about where agent configuration is stored -pub async fn get_agent_sources(agent_name: &str, working_directory: Option<&Path>) -> Result { +pub async fn get_agent_sources( + agent_name: &str, + working_directory: Option<&Path>, +) -> Result { ensure_dirs().await?; // Check project level first (takes precedence) let project_path = working_directory.map(|wd| get_project_agent_path(wd, agent_name)); let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false); - + // Then check user level let user_path = get_user_agent_path(agent_name); let user_exists = user_path.exists(); - + // Determine which md file to use (project takes precedence) let (md_path, md_exists, md_scope) = if project_exists { (project_path.clone(), true, Some(Scope::Project)) @@ -684,10 +708,10 @@ pub async fn get_agent_sources(agent_name: &str, working_directory: Option<&Path /// Create new agent as .md file pub async fn create_agent( - agent_name: &str, + agent_name: &str, config: &HashMap, working_directory: Option<&Path>, - scope: Option + scope: Option, ) -> Result<()> { ensure_dirs().await?; @@ -701,7 +725,7 @@ pub async fn create_agent( )); } } - + let user_path = get_user_agent_path(agent_name); if user_path.exists() { return Err(anyhow!( @@ -741,7 +765,12 @@ pub async fn create_agent( // Write .md file write_md_file(&target_path, &frontmatter, &prompt).await?; - info!("Created new agent: {} (scope: {:?}, path: {})", agent_name, target_scope, target_path.display()); + info!( + "Created new agent: {} (scope: {:?}, path: {})", + agent_name, + target_scope, + target_path.display() + ); Ok(()) } @@ -757,7 +786,7 @@ pub async fn update_agent( // Determine correct path: project level takes precedence let (scope, md_path) = get_agent_write_path(agent_name, working_directory, None); let md_exists = md_path.exists(); - + // Check if agent exists in opencode.json across all config layers let mut layers = read_config_layers(working_directory).await?; let json_source = get_json_entry_source(&layers, "agent", agent_name); @@ -783,11 +812,11 @@ pub async fn update_agent( get_json_write_target(&layers, preferred_scope) }; let config = get_config_for_path(&mut layers, &json_target_path); - + // Determine if we should create a new md file: // Only for built-in agents (no md file AND no json config) let is_builtin_override = !md_exists && !had_json_fields; - + let target_path = if !md_exists && is_builtin_override { // Built-in agent override - create at user level get_user_agent_path(agent_name) @@ -799,11 +828,14 @@ pub async fn update_agent( Some(parse_md_file(&md_path).await?) } else if is_builtin_override { // Only create new md data for built-in overrides - Some(MdData { frontmatter: HashMap::new(), body: String::new() }) + Some(MdData { + frontmatter: HashMap::new(), + body: String::new(), + }) } else { None }; - + // Only create new md if it's a built-in override let creating_new_md = is_builtin_override; @@ -836,8 +868,7 @@ pub async fn update_agent( md_modified = true; } continue; - } else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str()) - { + } else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str()) { if is_prompt_file_reference(prompt_ref) { if let Some(prompt_file_path) = resolve_prompt_file_path(prompt_ref) { write_prompt_file(&prompt_file_path, &normalized_value).await?; @@ -941,7 +972,10 @@ pub async fn delete_agent(agent_name: &str, working_directory: Option<&Path>) -> let project_path = get_project_agent_path(wd, agent_name); if project_path.exists() { fs::remove_file(&project_path).await?; - info!("Deleted project-level agent .md file: {}", project_path.display()); + info!( + "Deleted project-level agent .md file: {}", + project_path.display() + ); deleted = true; } } @@ -1004,17 +1038,20 @@ pub async fn delete_agent(agent_name: &str, working_directory: Option<&Path>) -> } /// Get information about where command configuration is stored -pub async fn get_command_sources(command_name: &str, working_directory: Option<&Path>) -> Result { +pub async fn get_command_sources( + command_name: &str, + working_directory: Option<&Path>, +) -> Result { ensure_dirs().await?; // Check project level first (takes precedence) let project_path = working_directory.map(|wd| get_project_command_path(wd, command_name)); let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false); - + // Then check user level let user_path = get_user_command_path(command_name); let user_exists = user_path.exists(); - + // Determine which md file to use (project takes precedence) let (md_path, md_exists, md_scope) = if project_exists { (project_path.clone(), true, Some(Scope::Project)) @@ -1082,10 +1119,10 @@ pub async fn get_command_sources(command_name: &str, working_directory: Option<& /// Create new command as .md file pub async fn create_command( - command_name: &str, + command_name: &str, config: &HashMap, working_directory: Option<&Path>, - scope: Option + scope: Option, ) -> Result<()> { ensure_dirs().await?; @@ -1099,7 +1136,7 @@ pub async fn create_command( )); } } - + let user_path = get_user_command_path(command_name); if user_path.exists() { return Err(anyhow!( @@ -1121,7 +1158,10 @@ pub async fn create_command( let (target_scope, target_path) = if scope == Some(CommandScope::Project) { if let Some(wd) = working_directory { ensure_project_command_dir(wd).await?; - (CommandScope::Project, get_project_command_path(wd, command_name)) + ( + CommandScope::Project, + get_project_command_path(wd, command_name), + ) } else { (CommandScope::User, user_path) } @@ -1139,7 +1179,12 @@ pub async fn create_command( // Write .md file write_md_file(&target_path, &frontmatter, &template).await?; - info!("Created new command: {} (scope: {:?}, path: {})", command_name, target_scope, target_path.display()); + info!( + "Created new command: {} (scope: {:?}, path: {})", + command_name, + target_scope, + target_path.display() + ); Ok(()) } @@ -1194,11 +1239,14 @@ pub async fn update_command( let mut md_data = if md_exists { Some(parse_md_file(&md_path).await?) } else if is_builtin_override { - Some(MdData { frontmatter: HashMap::new(), body: String::new() }) + Some(MdData { + frontmatter: HashMap::new(), + body: String::new(), + }) } else { None }; - + let creating_new_md = is_builtin_override; let mut md_modified = false; @@ -1230,7 +1278,9 @@ pub async fn update_command( md_modified = true; } continue; - } else if let Some(template_ref) = existing_command.get("template").and_then(|v| v.as_str()) { + } else if let Some(template_ref) = + existing_command.get("template").and_then(|v| v.as_str()) + { if is_prompt_file_reference(template_ref) { if let Some(template_file_path) = resolve_prompt_file_path(template_ref) { write_prompt_file(&template_file_path, &normalized_value).await?; @@ -1334,7 +1384,10 @@ pub async fn delete_command(command_name: &str, working_directory: Option<&Path> let project_path = get_project_command_path(wd, command_name); if project_path.exists() { fs::remove_file(&project_path).await?; - info!("Deleted project-level command .md file: {}", project_path.display()); + info!( + "Deleted project-level command .md file: {}", + project_path.display() + ); deleted = true; } } @@ -1343,7 +1396,10 @@ pub async fn delete_command(command_name: &str, working_directory: Option<&Path> let user_path = get_user_command_path(command_name); if user_path.exists() { fs::remove_file(&user_path).await?; - info!("Deleted user-level command .md file: {}", user_path.display()); + info!( + "Deleted user-level command .md file: {}", + user_path.display() + ); deleted = true; } @@ -1471,7 +1527,10 @@ fn get_user_skill_path(skill_name: &str) -> PathBuf { /// Get project-level skill directory (.opencode/skill/) fn get_project_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf { - working_directory.join(".opencode").join("skill").join(skill_name) + working_directory + .join(".opencode") + .join("skill") + .join(skill_name) } /// Get project-level skill SKILL.md path @@ -1481,7 +1540,10 @@ fn get_project_skill_path(working_directory: &Path, skill_name: &str) -> PathBuf /// Get Claude-compatible skill directory (.claude/skills/) fn get_claude_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf { - working_directory.join(".claude").join("skills").join(skill_name) + working_directory + .join(".claude") + .join("skills") + .join(skill_name) } /// Get Claude-compatible skill SKILL.md path @@ -1504,46 +1566,62 @@ async fn ensure_project_skill_dir(working_directory: &Path, skill_name: &str) -> } /// Determine skill scope based on where the SKILL.md file exists -pub fn get_skill_scope(skill_name: &str, working_directory: Option<&Path>) -> (Option, Option, Option) { +pub fn get_skill_scope( + skill_name: &str, + working_directory: Option<&Path>, +) -> (Option, Option, Option) { if let Some(wd) = working_directory { // Check .opencode/skill first let project_path = get_project_skill_path(wd, skill_name); if project_path.exists() { - return (Some(SkillScope::Project), Some(project_path), Some(SkillSource::Opencode)); + return ( + Some(SkillScope::Project), + Some(project_path), + Some(SkillSource::Opencode), + ); } - + // Check .claude/skills (claude-compat) let claude_path = get_claude_skill_path(wd, skill_name); if claude_path.exists() { - return (Some(SkillScope::Project), Some(claude_path), Some(SkillSource::Claude)); + return ( + Some(SkillScope::Project), + Some(claude_path), + Some(SkillSource::Claude), + ); } } - + let user_path = get_user_skill_path(skill_name); if user_path.exists() { - return (Some(SkillScope::User), Some(user_path), Some(SkillSource::Opencode)); + return ( + Some(SkillScope::User), + Some(user_path), + Some(SkillSource::Opencode), + ); } - + (None, None, None) } /// List supporting files in a skill directory (excluding SKILL.md) fn list_supporting_files(skill_dir: &Path) -> Vec { let mut files = Vec::new(); - + fn walk_dir(dir: &Path, relative_base: &Path, files: &mut Vec) { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); let file_name = entry.file_name().to_string_lossy().to_string(); - + if path.is_dir() { walk_dir(&path, relative_base, files); } else if file_name != "SKILL.md" { - let relative_path = path.strip_prefix(relative_base) + let relative_path = path + .strip_prefix(relative_base) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| file_name.clone()); - + files.push(SupportingFile { name: file_name, path: relative_path, @@ -1553,27 +1631,31 @@ fn list_supporting_files(skill_dir: &Path) -> Vec { } } } - + walk_dir(skill_dir, skill_dir, &mut files); files } /// Discover all skills from all sources pub fn discover_skills(working_directory: Option<&Path>) -> Vec { - let mut skills: std::collections::HashMap = std::collections::HashMap::new(); - + let mut skills: std::collections::HashMap = + std::collections::HashMap::new(); + // Helper to add skill if not already found let mut add_skill = |name: String, path: PathBuf, scope: Scope, source: SkillSource| { if !skills.contains_key(&name) { - skills.insert(name.clone(), DiscoveredSkill { - name, - path: path.display().to_string(), - scope, - source, - }); + skills.insert( + name.clone(), + DiscoveredSkill { + name, + path: path.display().to_string(), + scope, + source, + }, + ); } }; - + // 1. Project level .opencode/skill/ (highest priority) if let Some(wd) = working_directory { let project_skill_dir = wd.join(".opencode").join("skill"); @@ -1590,7 +1672,7 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec } } } - + // 2. Claude-compatible .claude/skills/ let claude_skill_dir = wd.join(".claude").join("skills"); if claude_skill_dir.exists() { @@ -1607,7 +1689,7 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec } } } - + // 3. User level ~/.config/opencode/skill/ let user_skill_dir = get_skill_dir(); if user_skill_dir.exists() { @@ -1623,53 +1705,90 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec } } } - + skills.into_values().collect() } /// Get information about where skill configuration is stored -pub async fn get_skill_sources(skill_name: &str, working_directory: Option<&Path>) -> Result { +pub async fn get_skill_sources( + skill_name: &str, + working_directory: Option<&Path>, +) -> Result { ensure_skill_dirs().await?; - + // Check all possible locations let project_path = working_directory.map(|wd| get_project_skill_path(wd, skill_name)); let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false); - let project_dir = project_exists.then(|| working_directory.map(|wd| get_project_skill_dir(wd, skill_name))).flatten(); - + let project_dir = project_exists + .then(|| working_directory.map(|wd| get_project_skill_dir(wd, skill_name))) + .flatten(); + let claude_path = working_directory.map(|wd| get_claude_skill_path(wd, skill_name)); let claude_exists = claude_path.as_ref().map(|p| p.exists()).unwrap_or(false); - let claude_dir = claude_exists.then(|| working_directory.map(|wd| get_claude_skill_dir(wd, skill_name))).flatten(); - + let claude_dir = claude_exists + .then(|| working_directory.map(|wd| get_claude_skill_dir(wd, skill_name))) + .flatten(); + let user_path = get_user_skill_path(skill_name); let user_exists = user_path.exists(); - let user_dir = if user_exists { Some(get_user_skill_dir(skill_name)) } else { None }; - + let user_dir = if user_exists { + Some(get_user_skill_dir(skill_name)) + } else { + None + }; + // Determine which md file to use (priority: project > claude > user) let (md_path, md_exists, md_scope, md_source, md_dir) = if project_exists { - (project_path.clone(), true, Some(Scope::Project), Some(SkillSource::Opencode), project_dir.clone()) + ( + project_path.clone(), + true, + Some(Scope::Project), + Some(SkillSource::Opencode), + project_dir.clone(), + ) } else if claude_exists { - (claude_path.clone(), true, Some(Scope::Project), Some(SkillSource::Claude), claude_dir.clone()) + ( + claude_path.clone(), + true, + Some(Scope::Project), + Some(SkillSource::Claude), + claude_dir.clone(), + ) } else if user_exists { - (Some(user_path.clone()), true, Some(Scope::User), Some(SkillSource::Opencode), user_dir.clone()) + ( + Some(user_path.clone()), + true, + Some(Scope::User), + Some(SkillSource::Opencode), + user_dir.clone(), + ) } else { (None, false, None, None, None) }; - + let mut md_fields = Vec::new(); let mut supporting_files = Vec::new(); let mut md_name: Option = None; let mut md_description: Option = None; let mut md_instructions: Option = None; - + if md_exists { if let Some(ref path) = md_path { let md_data = parse_md_file(path).await?; md_fields.extend(md_data.frontmatter.keys().cloned()); - + // Extract actual content values - md_name = md_data.frontmatter.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); - md_description = md_data.frontmatter.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - + md_name = md_data + .frontmatter + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + md_description = md_data + .frontmatter + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if !md_data.body.trim().is_empty() { md_fields.push("instructions".to_string()); md_instructions = Some(md_data.body.clone()); @@ -1679,7 +1798,7 @@ pub async fn get_skill_sources(skill_name: &str, working_directory: Option<&Path supporting_files = list_supporting_files(dir); } } - + Ok(SkillConfigSources { md: SkillSourceInfo { exists: md_exists, @@ -1719,7 +1838,11 @@ pub async fn read_skill_supporting_file(skill_dir: &Path, relative_path: &str) - } /// Write a supporting file -pub async fn write_skill_supporting_file(skill_dir: &Path, relative_path: &str, content: &str) -> Result<()> { +pub async fn write_skill_supporting_file( + skill_dir: &Path, + relative_path: &str, + content: &str, +) -> Result<()> { let full_path = skill_dir.join(relative_path); if let Some(parent) = full_path.parent() { fs::create_dir_all(parent).await?; @@ -1735,7 +1858,7 @@ pub async fn delete_skill_supporting_file(skill_dir: &Path, relative_path: &str) if full_path.exists() { fs::remove_file(&full_path).await?; info!("Deleted supporting file: {}", full_path.display()); - + // Clean up empty parent directories let mut parent = full_path.parent(); while let Some(p) = parent { @@ -1778,13 +1901,13 @@ pub async fn create_skill( ) -> Result<()> { ensure_skill_dirs().await?; validate_skill_name(skill_name)?; - + // Check if skill already exists let (_existing_scope, existing_path, _) = get_skill_scope(skill_name, working_directory); if existing_path.is_some() { return Err(anyhow!("Skill {} already exists", skill_name)); } - + // Determine target directory let (target_scope, target_dir) = if scope == Some(SkillScope::Project) { if let Some(wd) = working_directory { @@ -1800,9 +1923,9 @@ pub async fn create_skill( fs::create_dir_all(&dir).await?; (SkillScope::User, dir) }; - + let target_path = target_dir.join("SKILL.md"); - + // Extract fields let mut frontmatter = config.clone(); let instructions = frontmatter @@ -1811,7 +1934,7 @@ pub async fn create_skill( .unwrap_or_default(); frontmatter.remove("scope"); frontmatter.remove("supportingFiles"); - + // Ensure required fields if !frontmatter.contains_key("name") { frontmatter.insert("name".to_string(), Value::String(skill_name.to_string())); @@ -1819,9 +1942,9 @@ pub async fn create_skill( if !frontmatter.contains_key("description") { return Err(anyhow!("Skill description is required")); } - + write_md_file(&target_path, &frontmatter, &instructions).await?; - + // Write supporting files if provided if let Some(supporting_files) = config.get("supportingFiles").and_then(|v| v.as_array()) { for file in supporting_files { @@ -1833,8 +1956,13 @@ pub async fn create_skill( } } } - - info!("Created new skill: {} (scope: {:?}, path: {})", skill_name, target_scope, target_path.display()); + + info!( + "Created new skill: {} (scope: {:?}, path: {})", + skill_name, + target_scope, + target_path.display() + ); Ok(()) } @@ -1846,23 +1974,25 @@ pub async fn update_skill( ) -> Result<()> { let (_, existing_path, _) = get_skill_scope(skill_name, working_directory); let md_path = existing_path.ok_or_else(|| anyhow!("Skill \"{}\" not found", skill_name))?; - let md_dir = md_path.parent().ok_or_else(|| anyhow!("Invalid skill path"))?; - + let md_dir = md_path + .parent() + .ok_or_else(|| anyhow!("Invalid skill path"))?; + let mut md_data = parse_md_file(&md_path).await?; let mut md_modified = false; - + for (field, value) in updates.iter() { if field == "scope" { continue; } - + if field == "instructions" { let normalized = value.as_str().unwrap_or("").to_string(); md_data.body = normalized; md_modified = true; continue; } - + if field == "supportingFiles" { if let Some(files) = value.as_array() { for file in files { @@ -1880,42 +2010,52 @@ pub async fn update_skill( } continue; } - + md_data.frontmatter.insert(field.clone(), value.clone()); md_modified = true; } - + if md_modified { write_md_file(&md_path, &md_data.frontmatter, &md_data.body).await?; } - - info!("Updated skill: {} (path: {})", skill_name, md_path.display()); + + info!( + "Updated skill: {} (path: {})", + skill_name, + md_path.display() + ); Ok(()) } /// Delete skill pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) -> Result<()> { let mut deleted = false; - + // Check and delete from all locations if let Some(wd) = working_directory { // Project level .opencode/skill/ let project_dir = get_project_skill_dir(wd, skill_name); if project_dir.exists() { fs::remove_dir_all(&project_dir).await?; - info!("Deleted project-level skill directory: {}", project_dir.display()); + info!( + "Deleted project-level skill directory: {}", + project_dir.display() + ); deleted = true; } - + // Claude-compat .claude/skills/ let claude_dir = get_claude_skill_dir(wd, skill_name); if claude_dir.exists() { fs::remove_dir_all(&claude_dir).await?; - info!("Deleted claude-compat skill directory: {}", claude_dir.display()); + info!( + "Deleted claude-compat skill directory: {}", + claude_dir.display() + ); deleted = true; } } - + // User level let user_dir = get_user_skill_dir(skill_name); if user_dir.exists() { @@ -1923,10 +2063,10 @@ pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) -> info!("Deleted user-level skill directory: {}", user_dir.display()); deleted = true; } - + if !deleted { return Err(anyhow!("Skill \"{}\" not found", skill_name)); } - + Ok(()) } diff --git a/packages/desktop/src-tauri/src/opencode_manager.rs b/packages/desktop/src-tauri/src/opencode_manager.rs index bcd3f0e2..078d202e 100644 --- a/packages/desktop/src-tauri/src/opencode_manager.rs +++ b/packages/desktop/src-tauri/src/opencode_manager.rs @@ -268,14 +268,12 @@ impl OpenCodeManager { } async fn spawn_process(&self) -> Result { - let binary = self.binary.as_ref().ok_or_else(|| { - anyhow!("Cannot spawn process: OpenCode CLI is not available") - })?; + let binary = self + .binary + .as_ref() + .ok_or_else(|| anyhow!("Cannot spawn process: OpenCode CLI is not available"))?; - info!( - "[desktop:opencode] launching {} {:?}", - binary, self.args - ); + info!("[desktop:opencode] launching {} {:?}", binary, self.args); let working_dir = self.working_dir.read().clone(); let mut cmd = Command::new(binary); @@ -520,7 +518,10 @@ fn resolve_opencode_binary() -> Option { if let Ok(value) = std::env::var("OPENCODE_BINARY") { if !value.is_empty() && Path::new(&value).exists() { - info!("[desktop:opencode] using binary from OPENCODE_BINARY env: {}", value); + info!( + "[desktop:opencode] using binary from OPENCODE_BINARY env: {}", + value + ); return Some(value); } } @@ -529,7 +530,10 @@ fn resolve_opencode_binary() -> Option { if let Some(ref binary) = shell_env.opencode_binary { if Path::new(binary).exists() { - info!("[desktop:opencode] using binary from shell OPENCODE_BINARY: {}", binary); + info!( + "[desktop:opencode] using binary from shell OPENCODE_BINARY: {}", + binary + ); return Some(binary.clone()); } } @@ -547,7 +551,10 @@ fn resolve_opencode_binary() -> Option { if let Some(home) = dirs::home_dir() { let fallback = home.join(".opencode/bin/opencode"); if fallback.exists() { - info!("[desktop:opencode] found binary in fallback location: {:?}", fallback); + info!( + "[desktop:opencode] found binary in fallback location: {:?}", + fallback + ); return Some(fallback.to_string_lossy().to_string()); } } @@ -590,8 +597,8 @@ struct ShellEnv { fn get_user_shell() -> Option { use std::process::Command; - let username = dirs::home_dir() - .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?; + let username = + dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?; let output = Command::new("dscl") .args([".", "-read", &format!("/Users/{}", username), "UserShell"]) @@ -663,7 +670,10 @@ fn detect_shell_env() -> ShellEnv { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - warn!("[desktop:opencode] shell env detection failed for {}, stderr: {}", shell, stderr); + warn!( + "[desktop:opencode] shell env detection failed for {}, stderr: {}", + shell, stderr + ); return ShellEnv::default(); } @@ -683,7 +693,10 @@ fn detect_shell_env() -> ShellEnv { } } - info!("[desktop:opencode] parsed path exists: {}", env.path.is_some()); + info!( + "[desktop:opencode] parsed path exists: {}", + env.path.is_some() + ); env } } diff --git a/packages/desktop/src-tauri/src/path_utils.rs b/packages/desktop/src-tauri/src/path_utils.rs index 0dadc2c7..13ea7f22 100644 --- a/packages/desktop/src-tauri/src/path_utils.rs +++ b/packages/desktop/src-tauri/src/path_utils.rs @@ -18,4 +18,3 @@ pub fn expand_tilde_path(value: &str) -> PathBuf { PathBuf::from(trimmed) } - diff --git a/packages/desktop/src-tauri/src/session_activity.rs b/packages/desktop/src-tauri/src/session_activity.rs index e7cdd762..9f357990 100644 --- a/packages/desktop/src-tauri/src/session_activity.rs +++ b/packages/desktop/src-tauri/src/session_activity.rs @@ -1,8 +1,4 @@ -use std::{ - collections::HashMap, - sync::Arc, - time::Duration, -}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use anyhow::Result; use futures_util::TryStreamExt; @@ -57,7 +53,10 @@ pub fn spawn_session_activity_tracker( let mut shutdown_rx = runtime.subscribe_shutdown(); let phases = Arc::new(Mutex::new(HashMap::::new())); - let cooldowns = Arc::new(Mutex::new(HashMap::>::new())); + let cooldowns = Arc::new(Mutex::new(HashMap::< + String, + tauri::async_runtime::JoinHandle<()>, + >::new())); loop { tokio::select! { @@ -112,7 +111,11 @@ async fn run_once( loop { buf.clear(); - let bytes_read = match tokio::time::timeout(Duration::from_secs(2), reader.read_until(b'\n', &mut buf)).await + let bytes_read = match tokio::time::timeout( + Duration::from_secs(2), + reader.read_until(b'\n', &mut buf), + ) + .await { Ok(Ok(n)) => n, Ok(Err(err)) => { @@ -155,7 +158,9 @@ async fn run_once( data_lines.clear(); match parse_event_envelope(&raw) { - Ok((event, _directory)) => handle_event(app, event, phases.clone(), cooldowns.clone()).await, + Ok((event, _directory)) => { + handle_event(app, event, phases.clone(), cooldowns.clone()).await + } Err(err) => warn!("[desktop:activity] Failed to parse SSE data: {err}; raw={raw}"), }; continue; @@ -214,7 +219,9 @@ async fn connect_activity_sse( let working_dir = opencode.get_working_directory(); let directory = working_dir.to_string_lossy().to_string(); let mut parsed = reqwest::Url::parse(&event_url)?; - parsed.query_pairs_mut().append_pair("directory", &directory); + parsed + .query_pairs_mut() + .append_pair("directory", &directory); let directory_url = parsed.to_string(); let response = try_connect_sse(client, &directory_url, "[desktop:activity]").await?; @@ -222,7 +229,11 @@ async fn connect_activity_sse( Ok((response, SseScope::Directory(working_dir))) } -async fn try_connect_sse(client: &Client, url: &str, log_prefix: &str) -> Result { +async fn try_connect_sse( + client: &Client, + url: &str, + log_prefix: &str, +) -> Result { debug!("{log_prefix} Connecting SSE: {url}"); let response = client @@ -280,7 +291,14 @@ async fn handle_event( .and_then(Value::as_str) .map(|s| s.to_string()); if let Some(id) = session_id { - set_phase(app, &id, ActivityPhase::Idle, phases.clone(), cooldowns.clone()).await; + set_phase( + app, + &id, + ActivityPhase::Idle, + phases.clone(), + cooldowns.clone(), + ) + .await; } } "message.updated" => { @@ -326,7 +344,14 @@ async fn handle_event( // Mark session busy when we see assistant parts streaming (covers cases where session.status is missing). if is_streaming_assistant_part(&event.properties) { - set_phase(app, &id, ActivityPhase::Busy, phases.clone(), cooldowns.clone()).await; + set_phase( + app, + &id, + ActivityPhase::Busy, + phases.clone(), + cooldowns.clone(), + ) + .await; } // Derive cooldown from info.finish === 'stop' when present. diff --git a/packages/desktop/src-tauri/src/skills_catalog.rs b/packages/desktop/src-tauri/src/skills_catalog.rs index d4f35a36..b511caf5 100644 --- a/packages/desktop/src-tauri/src/skills_catalog.rs +++ b/packages/desktop/src-tauri/src/skills_catalog.rs @@ -11,9 +11,8 @@ use uuid::Uuid; use crate::opencode_config; -static SKILL_NAME_RE: Lazy = Lazy::new(|| { - Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").expect("valid skill name regex") -}); +static SKILL_NAME_RE: Lazy = + Lazy::new(|| Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").expect("valid skill name regex")); static AUTH_ERROR_RE: Lazy = Lazy::new(|| { Regex::new(r"(?i)(permission denied|publickey|could not read from remote repository|authentication failed)") @@ -188,7 +187,10 @@ fn list_identities() -> Vec { wrapper .profiles .into_iter() - .map(|p| IdentitySummary { id: p.id, name: p.name }) + .map(|p| IdentitySummary { + id: p.id, + name: p.name, + }) .collect() } @@ -314,7 +316,12 @@ fn parse_skill_md_frontmatter(contents: &str) -> (Option, Option (name, description, warnings) } -async fn run_git(args: &[String], cwd: &Path, ssh_key: Option<&str>, timeout: Duration) -> Result<(String, String)> { +async fn run_git( + args: &[String], + cwd: &Path, + ssh_key: Option<&str>, + timeout: Duration, +) -> Result<(String, String)> { let mut cmd = Command::new("git"); if let Some(key) = ssh_key { @@ -324,7 +331,8 @@ async fn run_git(args: &[String], cwd: &Path, ssh_key: Option<&str>, timeout: Du "ssh -i {} -o BatchMode=yes -o StrictHostKeyChecking=accept-new", key ); - cmd.arg("-c").arg(format!("core.sshCommand={}", ssh_command)); + cmd.arg("-c") + .arg(format!("core.sshCommand={}", ssh_command)); } } @@ -404,7 +412,10 @@ async fn clone_repo(clone_url: &str, target_dir: &Path, ssh_key: Option<&str>) - let cwd = std::env::temp_dir(); - if run_git(&preferred, &cwd, ssh_key, Duration::from_secs(60)).await.is_ok() { + if run_git(&preferred, &cwd, ssh_key, Duration::from_secs(60)) + .await + .is_ok() + { return Ok(()); } @@ -421,7 +432,18 @@ async fn scan_repo_items( subpath: Option<&str>, default_subpath: Option<&str>, ssh_key: Option<&str>, -) -> Result<(String, Option, Vec<(String, String, Option, Option, Vec, bool)>)> { +) -> Result<( + String, + Option, + Vec<( + String, + String, + Option, + Option, + Vec, + bool, + )>, +)> { let parsed = parse_repo_source(source, subpath)?; let effective_subpath = parsed .effective_subpath @@ -435,7 +457,10 @@ async fn scan_repo_items( parsed.clone_https.clone() }; - let temp_base = std::env::temp_dir().join(format!("openchamber-desktop-skills-scan-{}", Uuid::new_v4())); + let temp_base = std::env::temp_dir().join(format!( + "openchamber-desktop-skills-scan-{}", + Uuid::new_v4() + )); // Clone into temp_base (directory must not exist for git clone target) let _ = tokio::fs::remove_dir_all(&temp_base).await; @@ -482,7 +507,13 @@ async fn scan_repo_items( ]; set_args.extend(patterns.clone()); - let sparse_set = run_git(&set_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(30)).await; + let sparse_set = run_git( + &set_args, + &std::env::temp_dir(), + ssh_key, + Duration::from_secs(30), + ) + .await; if sparse_set.is_ok() { let checkout = run_git( &vec![ @@ -539,7 +570,13 @@ async fn scan_repo_items( list_args.push(sp.clone()); } - let list_out = run_git(&list_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(30)).await; + let list_out = run_git( + &list_args, + &std::env::temp_dir(), + ssh_key, + Duration::from_secs(30), + ) + .await; let stdout = match list_out { Ok((out, _)) => out, Err(_) => { @@ -605,7 +642,14 @@ async fn scan_repo_items( format!("HEAD:{}", skill_md_repo_path), ]; - match run_git(&show_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(15)).await { + match run_git( + &show_args, + &std::env::temp_dir(), + ssh_key, + Duration::from_secs(15), + ) + .await + { Ok((out, _)) => out, Err(_) => { warnings.push("Failed to read SKILL.md".to_string()); @@ -615,7 +659,8 @@ async fn scan_repo_items( } }; - let (frontmatter_name, description, mut fm_warnings) = parse_skill_md_frontmatter(&contents); + let (frontmatter_name, description, mut fm_warnings) = + parse_skill_md_frontmatter(&contents); warnings.append(&mut fm_warnings); let installable = validate_skill_name(&skill_name); @@ -644,7 +689,8 @@ struct CacheEntry { items: Vec, } -static CATALOG_CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +static CATALOG_CACHE: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<&str>) -> String { format!( @@ -656,13 +702,12 @@ fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<& } fn load_custom_catalog_sources() -> Vec { - let settings_path = dirs::home_dir() - .map(|mut home| { - home.push(".config"); - home.push("openchamber"); - home.push("settings.json"); - home - }); + let settings_path = dirs::home_dir().map(|mut home| { + home.push(".config"); + home.push("openchamber"); + home.push("settings.json"); + home + }); let Some(path) = settings_path else { return vec![]; @@ -684,13 +729,31 @@ fn load_custom_catalog_sources() -> Vec { let mut seen = std::collections::HashSet::new(); for entry in arr { - let Some(obj) = entry.as_object() else { continue }; + let Some(obj) = entry.as_object() else { + continue; + }; let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim(); - let label = obj.get("label").and_then(|v| v.as_str()).unwrap_or("").trim(); - let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("").trim(); - let subpath = obj.get("subpath").and_then(|v| v.as_str()).unwrap_or("").trim(); - let git_identity_id = obj.get("gitIdentityId").and_then(|v| v.as_str()).unwrap_or("").trim(); + let label = obj + .get("label") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let source = obj + .get("source") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let subpath = obj + .get("subpath") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let git_identity_id = obj + .get("gitIdentityId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); if id.is_empty() || label.is_empty() || source.is_empty() { continue; @@ -706,8 +769,16 @@ fn load_custom_catalog_sources() -> Vec { label: label.to_string(), description: Some(source.to_string()), source: source.to_string(), - default_subpath: if subpath.is_empty() { None } else { Some(subpath.to_string()) }, - git_identity_id: if git_identity_id.is_empty() { None } else { Some(git_identity_id.to_string()) }, + default_subpath: if subpath.is_empty() { + None + } else { + Some(subpath.to_string()) + }, + git_identity_id: if git_identity_id.is_empty() { + None + } else { + Some(git_identity_id.to_string()) + }, }); } @@ -732,8 +803,10 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal let sources = get_curated_sources().await; let discovered = opencode_config::discover_skills(Some(working_directory)); - let installed_by_name: HashMap = - discovered.into_iter().map(|s| (s.name.clone(), s)).collect(); + let installed_by_name: HashMap = discovered + .into_iter() + .map(|s| (s.name.clone(), s)) + .collect(); let mut items_by_source: HashMap> = HashMap::new(); @@ -752,7 +825,11 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal .or(parsed.effective_subpath.as_deref()) .unwrap_or(""); - let key = cache_key(&parsed.normalized_repo, Some(effective_subpath), src.git_identity_id.as_deref()); + let key = cache_key( + &parsed.normalized_repo, + Some(effective_subpath), + src.git_identity_id.as_deref(), + ); let maybe_cached = if refresh { None @@ -773,7 +850,13 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal items } else { let ssh_key = resolve_identity_ssh_key(src.git_identity_id.as_deref()); - let scan = scan_repo_items(&src.source, None, src.default_subpath.as_deref(), ssh_key.as_deref()).await; + let scan = scan_repo_items( + &src.source, + None, + src.default_subpath.as_deref(), + ssh_key.as_deref(), + ) + .await; let (_, _, raw_items) = match scan { Ok(v) => v, @@ -804,7 +887,11 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal frontmatter_name: fm_name, description: desc, installable, - warnings: if warnings.is_empty() { None } else { Some(warnings) }, + warnings: if warnings.is_empty() { + None + } else { + Some(warnings) + }, installed: SkillsCatalogInstalledBadge { is_installed: installed.is_some(), scope: installed.map(|s| match s.scope { @@ -865,7 +952,14 @@ pub struct SkillsScanRequest { pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse { let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref()); - match scan_repo_items(&req.source, req.subpath.as_deref(), None, ssh_key.as_deref()).await { + match scan_repo_items( + &req.source, + req.subpath.as_deref(), + None, + ssh_key.as_deref(), + ) + .await + { Ok((_normalized, effective_subpath, raw_items)) => { let mut items = vec![]; for (repo_source, skill_dir, fm_name, desc, warnings, installable) in raw_items { @@ -886,8 +980,15 @@ pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse { frontmatter_name: fm_name, description: desc, installable, - warnings: if warnings.is_empty() { None } else { Some(warnings) }, - installed: SkillsCatalogInstalledBadge { is_installed: false, scope: None }, + warnings: if warnings.is_empty() { + None + } else { + Some(warnings) + }, + installed: SkillsCatalogInstalledBadge { + is_installed: false, + scope: None, + }, }); } items.sort_by(|a, b| a.skill_name.cmp(&b.skill_name)); @@ -903,7 +1004,9 @@ pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse { return SkillsRepoScanResponse { ok: false, items: None, - error: Some(auth_required_error("Authentication required to access this repository")), + error: Some(auth_required_error( + "Authentication required to access this repository", + )), }; } @@ -948,7 +1051,10 @@ fn target_skill_dir(scope: &str, working_directory: &Path, skill_name: &str) -> } if scope == "project" { - return Ok(working_directory.join(".opencode").join("skill").join(skill_name)); + return Ok(working_directory + .join(".opencode") + .join("skill") + .join(skill_name)); } Err(anyhow!("Invalid scope")) @@ -1017,7 +1123,10 @@ async fn copy_dir_no_symlinks(src: &Path, dst: &Path) -> Result<()> { Ok(()) } -pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) -> SkillsInstallResponse { +pub async fn install_skills( + working_directory: &Path, + req: SkillsInstallRequest, +) -> SkillsInstallResponse { let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref()); let selections: Vec = req @@ -1032,7 +1141,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) ok: false, installed: None, skipped: None, - error: Some(simple_error("invalidSource", "No skills selected for installation")), + error: Some(simple_error( + "invalidSource", + "No skills selected for installation", + )), }; } @@ -1072,7 +1184,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) let auto = req.conflict_policy.as_deref().unwrap_or("prompt"); if decision.is_none() && auto != "skipAll" && auto != "overwriteAll" { - conflicts.push(SkillConflict { skill_name, scope: req.scope.clone() }); + conflicts.push(SkillConflict { + skill_name, + scope: req.scope.clone(), + }); } } } @@ -1105,7 +1220,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) parsed.clone_https.clone() }; - let temp_base = std::env::temp_dir().join(format!("openchamber-desktop-skills-install-{}", Uuid::new_v4())); + let temp_base = std::env::temp_dir().join(format!( + "openchamber-desktop-skills-install-{}", + Uuid::new_v4() + )); let _ = tokio::fs::remove_dir_all(&temp_base).await; let clone_res = clone_repo(&clone_url, &temp_base, ssh_key.as_deref()).await; @@ -1116,7 +1234,9 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) ok: false, installed: None, skipped: None, - error: Some(auth_required_error("Authentication required to access this repository")), + error: Some(auth_required_error( + "Authentication required to access this repository", + )), }; } @@ -1136,7 +1256,13 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) "init".to_string(), "--cone".to_string(), ]; - let _ = run_git(&init_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(15)).await; + let _ = run_git( + &init_args, + &std::env::temp_dir(), + ssh_key.as_deref(), + Duration::from_secs(15), + ) + .await; let mut set_args = vec![ "-C".to_string(), @@ -1148,7 +1274,14 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) set_args.push(dir.clone()); } - if let Err(err) = run_git(&set_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(30)).await { + if let Err(err) = run_git( + &set_args, + &std::env::temp_dir(), + ssh_key.as_deref(), + Duration::from_secs(30), + ) + .await + { safe_rm(&temp_base).await; return SkillsInstallResponse { ok: false, @@ -1166,7 +1299,14 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) "HEAD".to_string(), ]; - if let Err(err) = run_git(&checkout_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(60)).await { + if let Err(err) = run_git( + &checkout_args, + &std::env::temp_dir(), + ssh_key.as_deref(), + Duration::from_secs(60), + ) + .await + { safe_rm(&temp_base).await; return SkillsInstallResponse { ok: false, @@ -1188,21 +1328,30 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) .to_string(); if !validate_skill_name(&skill_name) { - skipped.push(SkippedSkill { skill_name, reason: "Invalid skill name (directory basename)".to_string() }); + skipped.push(SkippedSkill { + skill_name, + reason: "Invalid skill name (directory basename)".to_string(), + }); continue; } let src_dir = repo_path_to_fs(&temp_base, &skill_dir); let skill_md = src_dir.join("SKILL.md"); if !skill_md.exists() { - skipped.push(SkippedSkill { skill_name, reason: "SKILL.md not found in selected directory".to_string() }); + skipped.push(SkippedSkill { + skill_name, + reason: "SKILL.md not found in selected directory".to_string(), + }); continue; } let target_dir = match target_skill_dir(&req.scope, working_directory, &skill_name) { Ok(p) => p, Err(err) => { - skipped.push(SkippedSkill { skill_name, reason: err.to_string() }); + skipped.push(SkippedSkill { + skill_name, + reason: err.to_string(), + }); continue; } }; @@ -1234,7 +1383,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) } if exists && decision.as_deref() == Some("skip") { - skipped.push(SkippedSkill { skill_name, reason: "Already installed (skipped)".to_string() }); + skipped.push(SkippedSkill { + skill_name, + reason: "Already installed (skipped)".to_string(), + }); continue; } @@ -1248,11 +1400,17 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) if let Err(err) = copy_dir_no_symlinks(&src_dir, &target_dir).await { let _ = tokio::fs::remove_dir_all(&target_dir).await; - skipped.push(SkippedSkill { skill_name, reason: err.to_string() }); + skipped.push(SkippedSkill { + skill_name, + reason: err.to_string(), + }); continue; } - installed.push(InstalledSkill { skill_name, scope: req.scope.clone() }); + installed.push(InstalledSkill { + skill_name, + scope: req.scope.clone(), + }); } safe_rm(&temp_base).await; diff --git a/packages/desktop/src/api/terminal.ts b/packages/desktop/src/api/terminal.ts index e2f3e6c2..176f2b73 100644 --- a/packages/desktop/src/api/terminal.ts +++ b/packages/desktop/src/api/terminal.ts @@ -57,7 +57,9 @@ export const createDesktopTerminalAPI = (): TerminalAPI => ({ const startListening = async () => { try { - const unlisten = await safeListen(`terminal://${sessionId}`, (event) => { + const unlisten = await safeListen( + `terminal://${sessionId}`, + (event) => { if (cancelled) { return; } @@ -67,7 +69,12 @@ export const createDesktopTerminalAPI = (): TerminalAPI => ({ if (event.payload?.type === 'exit') { stopListening(); } - }); + }, + { + // Terminal streams are long-lived; never auto-expire this listener. + timeout: 0, + } + ); if (cancelled) { unlisten(); diff --git a/packages/desktop/src/lib/tauriCallbackManager.ts b/packages/desktop/src/lib/tauriCallbackManager.ts index 0e706cb6..4b6d00e2 100644 --- a/packages/desktop/src/lib/tauriCallbackManager.ts +++ b/packages/desktop/src/lib/tauriCallbackManager.ts @@ -9,6 +9,7 @@ interface PendingCallback { type: 'invoke' | 'listen'; cleanup?: () => void; timeout?: NodeJS.Timeout; + timeoutMs?: number; } interface CallbackManagerConfig { @@ -51,10 +52,17 @@ class TauriCallbackManager { this.callbacks.set(callback.id, fullCallback); - if (callback.type === 'listen' && this.config.listenTimeout > 0) { + const timeoutMs = + typeof fullCallback.timeoutMs === 'number' + ? fullCallback.timeoutMs + : callback.type === 'listen' + ? this.config.listenTimeout + : 0; + + if (timeoutMs > 0) { const timeout = setTimeout(() => { this.cleanupCallback(callback.id, 'timeout'); - }, this.config.listenTimeout); + }, timeoutMs); fullCallback.timeout = timeout; } @@ -119,6 +127,9 @@ class TauriCallbackManager { const expiredCallbacks: string[] = []; this.callbacks.forEach((callback, id) => { + if (callback.type !== 'invoke') { + return; + } const age = now - callback.timestamp; if (age > this.config.maxCallbackAge) { expiredCallbacks.push(id); @@ -261,11 +272,12 @@ export async function safeListen( const callbackId = `listen:${event}:${Date.now()}:${Math.random().toString(36).slice(2)}`; try { - + manager.register({ id: callbackId, type: 'listen', cleanup: options?.onCancel, + timeoutMs: options?.timeout, }); const unlisten = await listen(event, (event) => { diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts index 93cd137c..ce81a1c6 100644 --- a/packages/desktop/vite.config.ts +++ b/packages/desktop/vite.config.ts @@ -19,6 +19,9 @@ export default defineConfig({ '@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'), }, }, + worker: { + format: 'es', + }, define: { 'process.env': {}, 'process.platform': JSON.stringify('darwin'), diff --git a/packages/ui/package.json b/packages/ui/package.json index e5e97f21..99e49275 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -27,8 +27,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", "@types/react-syntax-highlighter": "^15.5.13", - "@xterm/addon-fit": "^0.10.0", - "@xterm/xterm": "^5.3.0", + "ghostty-web": "0.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -37,7 +36,6 @@ "http-proxy-middleware": "^3.0.5", "motion": "^12.23.24", "next-themes": "^0.4.6", - "node-pty": "^1.0.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-syntax-highlighter": "^15.6.6", diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index 961e9214..70ec43b1 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -1,14 +1,51 @@ import React from 'react'; -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import '@xterm/xterm/css/xterm.css'; +import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web'; import type { TerminalTheme } from '@/lib/terminalTheme'; -import { getTerminalOptions } from '@/lib/terminalTheme'; +import { getGhosttyTerminalOptions } from '@/lib/terminalTheme'; import type { TerminalChunk } from '@/stores/useTerminalStore'; import { cn } from '@/lib/utils'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; +let ghosttyPromise: Promise | null = null; + +function getGhostty(): Promise { + if (!ghosttyPromise) { + ghosttyPromise = Ghostty.load(); + } + return ghosttyPromise; +} + +function findScrollableViewport(container: HTMLElement): HTMLElement | null { + if (typeof window === 'undefined') { + return null; + } + + const candidates = [container, ...Array.from(container.querySelectorAll('*'))]; + let fallback: HTMLElement | null = null; + + for (const element of candidates) { + const style = window.getComputedStyle(element); + const overflowY = style.overflowY; + if (overflowY !== 'auto' && overflowY !== 'scroll') { + continue; + } + + // Prefer an element that is currently scrollable. + if (element.scrollHeight - element.clientHeight > 2) { + return element; + } + + // Otherwise keep the first overflow container as a fallback so we can + // attach touch scroll before scrollback grows. + if (!fallback) { + fallback = element; + } + } + + return fallback; +} + type TerminalController = { focus: () => void; clear: () => void; @@ -34,25 +71,65 @@ const TerminalViewport = React.forwardRef { const containerRef = React.useRef(null); const viewportRef = React.useRef(null); - const terminalRef = React.useRef(null); + const terminalRef = React.useRef(null); const fitAddonRef = React.useRef(null); const inputHandlerRef = React.useRef<(data: string) => void>(onInput); const resizeHandlerRef = React.useRef<(cols: number, rows: number) => void>(onResize); - const writeQueueRef = React.useRef([]); + const lastReportedSizeRef = React.useRef<{ cols: number; rows: number } | null>(null); + const pendingWriteRef = React.useRef(''); + const writeScheduledRef = React.useRef(null); const isWritingRef = React.useRef(false); - const processedCountRef = React.useRef(0); - const firstChunkIdRef = React.useRef(null); + const lastProcessedChunkIdRef = React.useRef(null); const touchScrollCleanupRef = React.useRef<(() => void) | null>(null); + const viewportDiscoveryTimeoutRef = React.useRef(null); + const viewportDiscoveryAttemptsRef = React.useRef(0); + const hiddenInputRef = React.useRef(null); const [, forceRender] = React.useReducer((x) => x + 1, 0); + const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0); inputHandlerRef.current = onInput; resizeHandlerRef.current = onResize; + const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => { + const input = hiddenInputRef.current; + const container = containerRef.current; + if (!input || !container) { + return; + } + + // Position the input near the user's tap/cursor so the global keyboard + // avoidance logic can decide whether anything is actually obscured. + const rect = container.getBoundingClientRect(); + const fallbackX = rect.left + rect.width / 2; + const fallbackY = rect.top + rect.height - 12; + const x = typeof clientX === 'number' ? clientX : fallbackX; + const y = typeof clientY === 'number' ? clientY : fallbackY; + + const padding = 8; + const left = Math.max(padding, Math.min(rect.width - padding, x - rect.left)); + const top = Math.max(padding, Math.min(rect.height - padding, y - rect.top)); + + input.style.left = `${left}px`; + input.style.top = `${top}px`; + input.style.bottom = ''; + + try { + input.focus({ preventScroll: true }); + } catch { + try { + input.focus(); + } catch { /* ignored */ } + } + }, []); + const resetWriteState = React.useCallback(() => { - writeQueueRef.current = []; + pendingWriteRef.current = ''; + if (writeScheduledRef.current !== null && typeof window !== 'undefined') { + window.cancelAnimationFrame(writeScheduledRef.current); + } + writeScheduledRef.current = null; isWritingRef.current = false; - processedCountRef.current = 0; - firstChunkIdRef.current = null; + lastProcessedChunkIdRef.current = null; }, []); const fitTerminal = React.useCallback(() => { @@ -68,61 +145,85 @@ const TerminalViewport = React.forwardRef { + const flushWrites = React.useCallback(() => { if (isWritingRef.current) { return; } - const consumeNext = () => { - const term = terminalRef.current; - if (!term) { - resetWriteState(); - return; - } + const term = terminalRef.current; + if (!term) { + resetWriteState(); + return; + } - const chunk = writeQueueRef.current.shift(); - if (chunk === undefined) { - isWritingRef.current = false; - return; - } + if (!pendingWriteRef.current) { + return; + } - isWritingRef.current = true; - term.write(chunk, () => { - isWritingRef.current = false; - if (writeQueueRef.current.length > 0) { - if (typeof window !== 'undefined') { - window.setTimeout(consumeNext, 0); - } else { - consumeNext(); - } + const chunk = pendingWriteRef.current; + pendingWriteRef.current = ''; + + isWritingRef.current = true; + term.write(chunk, () => { + isWritingRef.current = false; + if (pendingWriteRef.current) { + if (typeof window !== 'undefined') { + writeScheduledRef.current = window.requestAnimationFrame(() => { + writeScheduledRef.current = null; + flushWrites(); + }); + } else { + flushWrites(); } - }); - }; - - consumeNext(); + } + }); }, [resetWriteState]); + const scheduleFlushWrites = React.useCallback(() => { + if (writeScheduledRef.current !== null) { + return; + } + if (typeof window !== 'undefined') { + writeScheduledRef.current = window.requestAnimationFrame(() => { + writeScheduledRef.current = null; + flushWrites(); + }); + } else { + flushWrites(); + } + }, [flushWrites]); + const enqueueWrite = React.useCallback( (data: string) => { if (!data) { return; } - writeQueueRef.current = [data]; - isWritingRef.current = false; - flushWriteQueue(); + pendingWriteRef.current += data; + scheduleFlushWrites(); }, - [flushWriteQueue] + [scheduleFlushWrites] ); const setupTouchScroll = React.useCallback(() => { touchScrollCleanupRef.current?.(); touchScrollCleanupRef.current = null; + if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(viewportDiscoveryTimeoutRef.current); + viewportDiscoveryTimeoutRef.current = null; + } + if (!enableTouchScroll) { + viewportDiscoveryAttemptsRef.current = 0; return; } @@ -131,11 +232,15 @@ const TerminalViewport = React.forwardRef (typeof performance !== 'undefined' ? performance.now() : Date.now()); - const getMaxScrollTop = () => Math.max(0, viewport.scrollHeight - viewport.clientHeight); - - const setScrollTop = (nextScrollTop: number) => { - const maxScrollTop = getMaxScrollTop(); - viewport.scrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop)); - }; + const lineHeightPx = Math.max(12, Math.round(fontSize * 1.35)); + let remainderPx = 0; const scrollByPixels = (deltaPixels: number) => { if (!deltaPixels) { - return; + return false; } - const previous = viewport.scrollTop; - setScrollTop(previous + deltaPixels); - return viewport.scrollTop !== previous; + + const before = terminal.getViewportY(); + + const total = remainderPx + deltaPixels; + const lines = Math.trunc(total / lineHeightPx); + remainderPx = total - lines * lineHeightPx; + + if (lines !== 0) { + // Touch delta is in pixels, convert to lines. + // Natural mobile scrolling: finger up scrolls down. + terminal.scrollLines(lines); + } + + const after = terminal.getViewportY(); + return after !== before; }; const stopKinetic = () => { @@ -176,11 +292,18 @@ const TerminalViewport = React.forwardRef { if (event.pointerType !== 'touch') { @@ -188,6 +311,9 @@ const TerminalViewport = React.forwardRef= TAP_MOVE_THRESHOLD_PX) { + stateWithPointerId.moved = true; + } + } + if (stateWithPointerId.lastY === null) { stateWithPointerId.lastY = event.clientY; stateWithPointerId.lastTime = nowMs(); @@ -230,10 +364,14 @@ const TerminalViewport = React.forwardRef { stopKinetic(); + if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(viewportDiscoveryTimeoutRef.current); + viewportDiscoveryTimeoutRef.current = null; + } + viewportDiscoveryAttemptsRef.current = 0; container.removeEventListener('pointerdown', handlePointerDown, listenerOptions); container.removeEventListener('pointermove', handlePointerMove, listenerOptions); container.removeEventListener('pointerup', handlePointerUp, listenerOptions); @@ -301,6 +455,8 @@ const TerminalViewport = React.forwardRef { if (event.touches.length !== 1) { return; @@ -309,6 +465,9 @@ const TerminalViewport = React.forwardRef { @@ -316,11 +475,24 @@ const TerminalViewport = React.forwardRef= TAP_MOVE_THRESHOLD_PX) { + state.didMove = true; + } + } + if (state.lastY === null) { state.lastY = currentY; state.lastTime = nowMs(); @@ -350,20 +522,37 @@ const TerminalViewport = React.forwardRef { + const handleTouchEnd = (event: TouchEvent) => { + const wasTap = !state.didMove; + + state.lastY = null; state.lastTime = null; + const velocity = state.velocity; + state.startX = null; + state.startY = null; + state.didMove = false; + + if (wasTap) { + const point = event.changedTouches?.[0]; + focusHiddenInput(point?.clientX, point?.clientY); + return; + } + if (typeof window === 'undefined') { return; } - if (Math.abs(state.velocity) < minVelocity) { + if (Math.abs(velocity) < minVelocity) { state.velocity = 0; return; } @@ -394,77 +583,114 @@ const TerminalViewport = React.forwardRef { stopKinetic(); + if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(viewportDiscoveryTimeoutRef.current); + viewportDiscoveryTimeoutRef.current = null; + } + viewportDiscoveryAttemptsRef.current = 0; container.removeEventListener('touchstart', handleTouchStart, listenerOptions); container.removeEventListener('touchmove', handleTouchMove, listenerOptions); - container.removeEventListener('touchend', handleTouchEnd, listenerOptions); - container.removeEventListener('touchcancel', handleTouchEnd, listenerOptions); + container.removeEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions); + container.removeEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions); container.style.touchAction = previousTouchAction; }; - }, [enableTouchScroll]); + }, [enableTouchScroll, focusHiddenInput, fontSize]); React.useEffect(() => { - const terminal = new Terminal(getTerminalOptions(fontFamily, fontSize, theme)); - const fitAddon = new FitAddon(); - - terminalRef.current = terminal; - fitAddonRef.current = fitAddon; - terminal.loadAddon(fitAddon); + let disposed = false; + let localTerminal: GhosttyTerminal | null = null; + let localResizeObserver: ResizeObserver | null = null; + let localDisposables: Array<{ dispose: () => void }> = []; const container = containerRef.current; - if (container) { - terminal.open(container); - const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null; - if (viewport) { - viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container'); - viewportRef.current = viewport; - forceRender(); - } - fitTerminal(); - terminal.focus(); - } - - const disposables = [ - terminal.onData((data) => { - inputHandlerRef.current(data); - }), - ]; - - const resizeObserver = new ResizeObserver(() => { - fitTerminal(); - }); - if (container) { - resizeObserver.observe(container); - } - - return () => { - touchScrollCleanupRef.current?.(); - touchScrollCleanupRef.current = null; - disposables.forEach((disposable) => disposable.dispose()); - resizeObserver.disconnect(); - terminal.dispose(); - terminalRef.current = null; - fitAddonRef.current = null; - resetWriteState(); - }; - }, [fitTerminal, fontFamily, fontSize, theme, resetWriteState]); - - React.useEffect(() => { - const terminal = terminalRef.current; - if (!terminal) { + if (!container) { return; } - const options = getTerminalOptions(fontFamily, fontSize, theme); - Object.assign(terminal.options as Record, options); - fitTerminal(); - }, [fitTerminal, fontFamily, fontSize, theme]); + + container.tabIndex = 0; + + const initialize = async () => { + try { + const ghostty = await getGhostty(); + if (disposed) { + return; + } + + const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty); + + const terminal = new GhosttyTerminal(options); + + const fitAddon = new FitAddon(); + + localTerminal = terminal; + terminalRef.current = terminal; + fitAddonRef.current = fitAddon; + + terminal.loadAddon(fitAddon); + terminal.open(container); + bumpTerminalReady(); + + const viewport = findScrollableViewport(container); + if (viewport) { + viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container'); + viewportRef.current = viewport; + forceRender(); + } else { + viewportRef.current = null; + } + + fitTerminal(); + setupTouchScroll(); + terminal.focus(); + + localDisposables = [ + terminal.onData((data: string) => { + inputHandlerRef.current(data); + }), + ]; + + localResizeObserver = new ResizeObserver(() => { + fitTerminal(); + }); + localResizeObserver.observe(container); + + if (typeof window !== 'undefined') { + window.setTimeout(() => { + fitTerminal(); + }, 0); + } + } catch { + // ignored + } + }; + + void initialize(); + + return () => { + disposed = true; + touchScrollCleanupRef.current?.(); + touchScrollCleanupRef.current = null; + + localDisposables.forEach((disposable) => disposable.dispose()); + localResizeObserver?.disconnect(); + + localTerminal?.dispose(); + terminalRef.current = null; + fitAddonRef.current = null; + viewportRef.current = null; + lastReportedSizeRef.current = null; + resetWriteState(); + }; + }, [fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]); + React.useEffect(() => { const terminal = terminalRef.current; @@ -473,9 +699,10 @@ const TerminalViewport = React.forwardRef { setupTouchScroll(); @@ -492,7 +719,7 @@ const TerminalViewport = React.forwardRef chunk.id === lastProcessedId); + pending = lastProcessedIndex >= 0 ? chunks.slice(lastProcessedIndex + 1) : chunks; } - const shouldReset = - firstChunkIdRef.current !== currentFirstId || processedCountRef.current > chunks.length; - - if (shouldReset) { - terminal.reset(); - resetWriteState(); - firstChunkIdRef.current = currentFirstId; - } - - if (processedCountRef.current < chunks.length) { - const pending = chunks.slice(processedCountRef.current); + if (pending.length > 0) { enqueueWrite(pending.map((chunk) => chunk.data).join('')); - processedCountRef.current = chunks.length; } - }, [chunks, enqueueWrite, fitTerminal, resetWriteState]); + + lastProcessedChunkIdRef.current = chunks[chunks.length - 1].id; + }, [chunks, terminalReadyVersion, enqueueWrite, fitTerminal, resetWriteState]); React.useImperativeHandle( ref, (): TerminalController => ({ focus: () => { + if (enableTouchScroll) { + focusHiddenInput(); + return; + } terminalRef.current?.focus(); }, clear: () => { @@ -540,12 +767,69 @@ const TerminalViewport = React.forwardRef - {viewportRef.current ? ( +
{ + if (enableTouchScroll) { + focusHiddenInput(event.clientX, event.clientY); + } else { + terminalRef.current?.focus(); + } + }} + > + {enableTouchScroll ? ( +