From 110b0e5d8f19491609fc36f1f8ace3d5461a8c7f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 2 Jan 2026 21:57:53 +0200 Subject: [PATCH] feat(terminal) refactoring and stability improvements (#98) * feat(terminal): replace xterm with ghostty-web Replace xterm.js terminal with ghostty-web implementation Add terminal serialization support for state restoration Apply custom patches to ghostty-web for enhancements * feat(terminal): add bun-pty backend support Switch terminal to ghostty-web with bun-pty backend for better performance Auto-detect and prefer Bun runtime when available for terminal sessions Update terminal viewport write queue handling for improved reliability * fix(terminal): prevent unnecessary resize events Only report terminal resize when dimensions actually change Simplify chunk processing state tracking Disable terminal transparency for consistent rendering * feat(terminal): increase scrollback and buffer limits Increase terminal scrollback buffer from 10k to 50k lines Increase terminal buffer limit from 256k to 1M bytes Add rate limiting and improve output handling for terminal streams --- AGENTS.md | 4 +- bun.lock | 61 +- package.json | 8 +- .../src-tauri/src/assistant_notifications.rs | 10 +- .../desktop/src-tauri/src/commands/files.rs | 31 +- .../desktop/src-tauri/src/commands/git.rs | 173 ++--- .../desktop/src-tauri/src/commands/mod.rs | 2 +- .../src-tauri/src/commands/permissions.rs | 2 +- .../src-tauri/src/commands/settings.rs | 35 +- .../src-tauri/src/commands/terminal.rs | 223 ++++--- packages/desktop/src-tauri/src/lib.rs | 1 + packages/desktop/src-tauri/src/main.rs | 631 +++++++++++------- .../desktop/src-tauri/src/opencode_auth.rs | 2 - .../desktop/src-tauri/src/opencode_config.rs | 366 ++++++---- .../desktop/src-tauri/src/opencode_manager.rs | 41 +- packages/desktop/src-tauri/src/path_utils.rs | 1 - .../desktop/src-tauri/src/session_activity.rs | 49 +- .../desktop/src-tauri/src/skills_catalog.rs | 262 ++++++-- packages/desktop/src/api/terminal.ts | 11 +- .../desktop/src/lib/tauriCallbackManager.ts | 18 +- packages/desktop/vite.config.ts | 3 + packages/ui/package.json | 4 +- .../components/terminal/TerminalViewport.tsx | 564 ++++++++++++---- .../ui/src/components/views/TerminalView.tsx | 5 + packages/ui/src/lib/api/types.ts | 3 + .../ui/src/lib/terminal/SerializeAddon.ts | 497 ++++++++++++++ packages/ui/src/lib/terminalTheme.ts | 51 +- packages/ui/src/stores/useTerminalStore.ts | 2 +- packages/ui/src/types/ghostty-web.d.ts | 11 + packages/vscode/vite.config.ts | 3 + packages/web/bin/cli.js | 57 +- packages/web/package.json | 6 +- packages/web/server/index.js | 62 +- packages/web/vite.config.ts | 3 + patches/ghostty-web+0.3.0.patch | 33 + vite.config.ts | 3 + 36 files changed, 2417 insertions(+), 821 deletions(-) create mode 100644 packages/ui/src/lib/terminal/SerializeAddon.ts create mode 100644 packages/ui/src/types/ghostty-web.d.ts create mode 100644 patches/ghostty-web+0.3.0.patch 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 ? ( +