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
This commit is contained in:
Bohdan Triapitsyn
2026-01-02 21:57:53 +02:00
committed by GitHub
parent dca8be01ca
commit 110b0e5d8f
36 changed files with 2417 additions and 821 deletions
+2 -2
View File
@@ -152,9 +152,9 @@ Backend: `packages/ui/src/lib/gitApi.ts` + `packages/web/server/index.js` (simpl
### Terminal ### Terminal
`packages/ui/src/components/views/`: TerminalView `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 `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 ### Theme System
`packages/ui/src/lib/theme/`: themes (2 definitions), cssGenerator, syntaxThemeGenerator `packages/ui/src/lib/theme/`: themes (2 definitions), cssGenerator, syntaxThemeGenerator
+44 -17
View File
@@ -23,17 +23,17 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0", "@remixicon/react": "^4.7.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@xterm/addon-fit": "^0.10.0", "bun-pty": "^0.4.5",
"@xterm/xterm": "^5.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"electron-context-menu": "^4.1.1", "electron-context-menu": "^4.1.1",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
"express": "^5.1.0", "express": "^5.1.0",
"ghostty-web": "0.3.0",
"http-proxy-middleware": "^3.0.5", "http-proxy-middleware": "^3.0.5",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-pty": "^1.0.0", "node-pty": "^1.1.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -64,6 +64,7 @@
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"nodemon": "^3.1.7", "nodemon": "^3.1.7",
"patch-package": "^8.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"tw-animate-css": "^1.3.8", "tw-animate-css": "^1.3.8",
@@ -115,17 +116,15 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0", "@remixicon/react": "^4.7.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"express": "^5.1.0", "express": "^5.1.0",
"ghostty-web": "0.3.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"http-proxy-middleware": "^3.0.5", "http-proxy-middleware": "^3.0.5",
"motion": "^12.23.24", "motion": "^12.23.24",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-pty": "^1.0.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.6",
@@ -206,15 +205,15 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0", "@remixicon/react": "^4.7.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@xterm/addon-fit": "^0.10.0", "bun-pty": "^0.4.5",
"@xterm/xterm": "^5.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"express": "^5.1.0", "express": "^5.1.0",
"ghostty-web": "0.3.0",
"http-proxy-middleware": "^3.0.5", "http-proxy-middleware": "^3.0.5",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-pty": "^1.0.0", "node-pty": "^1.1.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -969,9 +968,7 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], "@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=="], "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
"@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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-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": ["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=="], "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=="], "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=="], "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=="], "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-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=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -1669,9 +1674,9 @@
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "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=="], "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-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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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"], "openchamber": ["openchamber@workspace:packages/vscode"],
@@ -2035,6 +2046,8 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "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-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "@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=="], "@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/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-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=="], "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=="], "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=="], "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], "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=="], "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=="], "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=="], "@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=="], "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/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=="], "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=="], "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/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=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+5 -3
View File
@@ -34,6 +34,7 @@
"lint:ui": "bun run --cwd packages/ui lint", "lint:ui": "bun run --cwd packages/ui lint",
"lint:desktop": "bun run --cwd packages/desktop lint", "lint:desktop": "bun run --cwd packages/desktop lint",
"clean": "bun run --filter '*' clean", "clean": "bun run --filter '*' clean",
"postinstall": "patch-package",
"dev:web": "bun run --cwd packages/web build:watch", "dev:web": "bun run --cwd packages/web build:watch",
"dev:web:server": "bun run --cwd packages/web dev:server: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\"", "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", "@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0", "@remixicon/react": "^4.7.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@xterm/addon-fit": "^0.10.0", "ghostty-web": "0.3.0",
"@xterm/xterm": "^5.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -84,7 +84,8 @@
"express": "^5.1.0", "express": "^5.1.0",
"http-proxy-middleware": "^3.0.5", "http-proxy-middleware": "^3.0.5",
"next-themes": "^0.4.6", "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": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -115,6 +116,7 @@
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"nodemon": "^3.1.7", "nodemon": "^3.1.7",
"patch-package": "^8.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"tw-animate-css": "^1.3.8", "tw-animate-css": "^1.3.8",
@@ -73,11 +73,13 @@ async fn run_once(
let prefix = opencode.api_prefix(); let prefix = opencode.api_prefix();
let mut url = format!("http://127.0.0.1:{port}{}/event", 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)?; let mut parsed = reqwest::Url::parse(&url)?;
parsed parsed.query_pairs_mut().append_pair("directory", &dir);
.query_pairs_mut()
.append_pair("directory", &dir);
url = parsed.to_string(); url = parsed.to_string();
} }
@@ -1,5 +1,5 @@
use crate::{DesktopRuntime, SettingsStore};
use crate::path_utils::expand_tilde_path; use crate::path_utils::expand_tilde_path;
use crate::{DesktopRuntime, SettingsStore};
use serde::Serialize; use serde::Serialize;
use std::{ use std::{
collections::{HashSet, VecDeque}, collections::{HashSet, VecDeque},
@@ -233,7 +233,11 @@ pub async fn search_files(
let match_all = normalized_query.is_empty(); let match_all = normalized_query.is_empty();
// Collect more candidates for fuzzy matching, then sort and trim // 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<ScoredFileHit> = Vec::new(); let mut candidates: Vec<ScoredFileHit> = Vec::new();
let mut queue = VecDeque::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 // Sort by score descending, then by path length, then alphabetically
if !match_all { if !match_all {
candidates.sort_by(|a, b| { candidates.sort_by(|a, b| match b.score.cmp(&a.score) {
match b.score.cmp(&a.score) { std::cmp::Ordering::Equal => {
std::cmp::Ordering::Equal => { match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) {
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),
std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path), other => other,
other => other,
}
} }
other => other,
} }
other => other,
}); });
} }
@@ -497,7 +499,11 @@ fn fuzzy_match_score(query: &str, candidate: &str) -> Option<i32> {
continue; 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); let idx = c[search_start..].iter().position(|&c_char| c_char == *ch);
match idx { match idx {
@@ -520,7 +526,8 @@ fn fuzzy_match_score(query: &str, candidate: &str) -> Option<i32> {
if idx == 0 { if idx == 0 {
score += 12; score += 12;
} else if let Some(prev) = c.get(idx - 1) { } else if let Some(prev) = c.get(idx - 1) {
if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' { if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' '
{
score += 10; score += 10;
} }
} }
+92 -81
View File
@@ -1,5 +1,5 @@
use crate::{DesktopRuntime, SettingsStore};
use crate::path_utils::expand_tilde_path; use crate::path_utils::expand_tilde_path;
use crate::{DesktopRuntime, SettingsStore};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use log::{error, info, warn}; use log::{error, info, warn};
use regex::Regex; 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() input_path.to_path_buf()
} else { } else {
let from_root = root.join(input_path); 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 from_root
} else { } else {
repo_root.join(input_path) repo_root.join(input_path)
@@ -832,13 +835,10 @@ pub async fn get_git_status(
let mut selected_base: Option<String> = None; let mut selected_base: Option<String> = None;
for candidate in base_candidates { for candidate in base_candidates {
let verified = run_git_with_allowed_exit( let verified =
&["rev-parse", "--verify", &candidate], run_git_with_allowed_exit(&["rev-parse", "--verify", &candidate], &path, &[1])
&path, .await
&[1], .unwrap_or_default();
)
.await
.unwrap_or_default();
if !verified.trim().is_empty() { if !verified.trim().is_empty() {
selected_base = Some(candidate); selected_base = Some(candidate);
@@ -916,7 +916,9 @@ pub async fn get_git_diff(
Ok(output) 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 { fn is_image_file(path: &str) -> bool {
if let Some(ext) = path.rsplit('.').next() { 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<Vec<u8>> { async fn run_git_binary(args: &[&str], cwd: &Path) -> Result<Vec<u8>> {
@@ -983,8 +989,8 @@ pub async fn get_git_file_diff(
path_str: String, path_str: String,
state: State<'_, DesktopRuntime>, state: State<'_, DesktopRuntime>,
) -> Result<(String, String), String> { ) -> Result<(String, String), String> {
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use tokio::fs; use tokio::fs;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
let root = validate_git_path(&directory, state.settings()) let root = validate_git_path(&directory, state.settings())
.await .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 (repo_root, full_path, relative_path) = resolve_path_for_git_show(&root, &path_str).await;
let is_image = is_image_file(&relative_path); 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 // Original from HEAD
let original = if is_image { let original = if is_image {
@@ -1035,47 +1045,50 @@ pub async fn get_git_file_diff(
}; };
// Modified from working tree (if file exists) // Modified from working tree (if file exists)
let modified = if let Ok(metadata) = metadata_with_timeout(&full_path, GIT_FILE_DIFF_TIMEOUT_MS).await { let modified =
if metadata.is_file() { if let Ok(metadata) = metadata_with_timeout(&full_path, GIT_FILE_DIFF_TIMEOUT_MS).await {
if is_image { if metadata.is_file() {
// For images, read as binary and convert to data URL if is_image {
if metadata.len() > GIT_FILE_IMAGE_MAX_BYTES { // For images, read as binary and convert to data URL
String::new() 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 { } else {
match tokio::time::timeout( match read_file_bytes_limited_with_timeout(
std::time::Duration::from_millis(GIT_FILE_DIFF_TIMEOUT_MS), &full_path,
fs::read(&full_path), GIT_FILE_TEXT_MAX_BYTES,
GIT_FILE_DIFF_TIMEOUT_MS,
) )
.await .await
{ {
Ok(Ok(bytes)) => format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)), Ok((bytes, truncated)) => {
_ => String::new(), let mut text = String::from_utf8_lossy(&bytes).to_string();
if truncated {
text.push_str("\n…(truncated)\n");
}
text
}
Err(_) => String::new(),
} }
} }
} else { } else {
match read_file_bytes_limited_with_timeout( String::new()
&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(),
}
} }
} else { } else {
String::new() String::new()
} };
} else {
String::new()
};
Ok((cap_ipc_payload(original), cap_ipc_payload(modified))) 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())?; .map_err(|e| e.to_string())?;
// Discover actual remote heads so we can drop stale remote-tracking refs // Discover actual remote heads so we can drop stale remote-tracking refs
let allowed_remote_heads: Option<HashSet<String>> = match run_git_bytes_with_allowed_exit_timeout( let allowed_remote_heads: Option<HashSet<String>> =
&["ls-remote", "--heads", "origin"], match run_git_bytes_with_allowed_exit_timeout(
&root, &["ls-remote", "--heads", "origin"],
&[0], &root,
GIT_LS_REMOTE_TIMEOUT_MS, &[0],
) GIT_LS_REMOTE_TIMEOUT_MS,
.await )
{ .await
Ok(bytes) => { {
let ls_remote = String::from_utf8_lossy(&bytes); Ok(bytes) => {
let mut set = HashSet::new(); let ls_remote = String::from_utf8_lossy(&bytes);
for line in ls_remote.lines() { let mut set = HashSet::new();
if let Some((_, ref_name)) = line.split_once('\t') { for line in ls_remote.lines() {
if let Some(stripped) = ref_name.trim().strip_prefix("refs/heads/") { if let Some((_, ref_name)) = line.split_once('\t') {
set.insert(stripped.to_string()); 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);
Err(err) => { None
warn!("Failed to list remote heads: {}", err); }
None };
}
};
// Structured for-each-ref output so we can mark remotes consistently with the web runtime // Structured for-each-ref output so we can mark remotes consistently with the web runtime
let output = run_git( let output = run_git(
@@ -1518,21 +1532,15 @@ pub async fn git_push(
let remote_key = format!("branch.{}.remote", branch_name); let remote_key = format!("branch.{}.remote", branch_name);
let merge_key = format!("branch.{}.merge", branch_name); let merge_key = format!("branch.{}.merge", branch_name);
let upstream_remote = run_git_with_allowed_exit( let upstream_remote =
&["config", "--get", &remote_key], run_git_with_allowed_exit(&["config", "--get", &remote_key], &root, &[1])
&root, .await
&[1], .unwrap_or_default();
)
.await
.unwrap_or_default();
let upstream_merge = run_git_with_allowed_exit( let upstream_merge =
&["config", "--get", &merge_key], run_git_with_allowed_exit(&["config", "--get", &merge_key], &root, &[1])
&root, .await
&[1], .unwrap_or_default();
)
.await
.unwrap_or_default();
if upstream_remote.trim().is_empty() || upstream_merge.trim().is_empty() { if upstream_remote.trim().is_empty() || upstream_merge.trim().is_empty() {
args.push("--set-upstream".to_string()); args.push("--set-upstream".to_string());
@@ -1904,7 +1912,10 @@ pub async fn get_commit_files(
file.path.clone() 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(); file.change_type = status.clone();
} }
} }
@@ -1,7 +1,7 @@
pub mod files; pub mod files;
pub mod git; pub mod git;
pub mod logs; pub mod logs;
pub mod notifications;
pub mod permissions; pub mod permissions;
pub mod settings; pub mod settings;
pub mod terminal; pub mod terminal;
pub mod notifications;
@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
use tauri::AppHandle; use tauri::AppHandle;
use tauri::State; use tauri::State;
use crate::DesktopRuntime;
use crate::path_utils::expand_tilde_path; use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -3,8 +3,8 @@ use serde_json::{json, Value};
use std::collections::HashSet; use std::collections::HashSet;
use tauri::State; use tauri::State;
use crate::DesktopRuntime;
use crate::path_utils::expand_tilde_path; use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -160,7 +160,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") { if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
let parsed = n let parsed = n
.as_u64() .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)); .or_else(|| n.as_f64().map(|value| value.round().max(0.0) as u64));
if let Some(value) = parsed { if let Some(value) = parsed {
let clamped = value.max(1).min(365); let clamped = value.max(1).min(365);
@@ -198,13 +201,31 @@ fn sanitize_settings_update(payload: &Value) -> Value {
let mut catalogs: Vec<Value> = vec![]; let mut catalogs: Vec<Value> = vec![];
for entry in arr { 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 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 label = obj
let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("").trim(); .get("label")
let subpath = obj.get("subpath").and_then(|v| v.as_str()).unwrap_or("").trim(); .and_then(|v| v.as_str())
let git_identity_id = obj.get("gitIdentityId").and_then(|v| v.as_str()).unwrap_or("").trim(); .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() { if id.is_empty() || label.is_empty() || source.is_empty() {
continue; continue;
@@ -1,4 +1,5 @@
use log::error; use log::error;
use parking_lot::Mutex;
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
@@ -6,8 +7,9 @@ use std::{
env, env,
io::{Read, Write}, io::{Read, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::{Arc, Mutex}, sync::Arc,
thread, thread,
time::Duration,
}; };
use tauri::{Emitter, State, Window}; 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_NAME: &str = "OpenChamber";
const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION"); 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 struct TerminalSession {
pub master: Box<dyn MasterPty + Send>, pub master: Box<dyn MasterPty + Send>,
pub writer: Arc<Mutex<Box<dyn Write + Send>>>, pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
@@ -94,7 +100,7 @@ pub async fn create_terminal_session(
let child = Arc::new(Mutex::new(child)); let child = Arc::new(Mutex::new(child));
let session_id = uuid::Uuid::new_v4().to_string(); let session_id = uuid::Uuid::new_v4().to_string();
state.sessions.lock().unwrap().insert( state.sessions.lock().insert(
session_id.clone(), session_id.clone(),
TerminalSession { TerminalSession {
master, master,
@@ -115,21 +121,18 @@ pub async fn send_terminal_input(
data: String, data: String,
state: State<'_, TerminalState>, state: State<'_, TerminalState>,
) -> Result<(), String> { ) -> Result<(), String> {
let sessions = state.sessions.lock().unwrap(); let writer = {
let Some(session) = sessions.get(&session_id) else { let sessions = state.sessions.lock();
return Err("Terminal session not found".to_string()); let Some(session) = sessions.get(&session_id) else {
return Err("Terminal session not found".to_string());
};
session.writer.clone()
}; };
let mut writer = session let mut guard = writer.lock();
.writer guard
.lock()
.map_err(|_| "Terminal busy".to_string())?;
writer
.write_all(data.as_bytes()) .write_all(data.as_bytes())
.map_err(|e| format!("Failed to write to terminal: {e}"))?; .map_err(|e| format!("Failed to write to terminal: {e}"))?;
writer
.flush()
.map_err(|e| format!("Failed to flush terminal input: {e}"))?;
Ok(()) Ok(())
} }
@@ -140,7 +143,7 @@ pub async fn resize_terminal(
rows: u16, rows: u16,
state: State<'_, TerminalState>, state: State<'_, TerminalState>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut sessions = state.sessions.lock().unwrap(); let mut sessions = state.sessions.lock();
let Some(session) = sessions.get_mut(&session_id) else { let Some(session) = sessions.get_mut(&session_id) else {
return Err("Terminal session not found".to_string()); return Err("Terminal session not found".to_string());
}; };
@@ -154,6 +157,7 @@ pub async fn resize_terminal(
pixel_height: 0, pixel_height: 0,
}) })
.map_err(|e| format!("Failed to resize terminal: {e}"))?; .map_err(|e| format!("Failed to resize terminal: {e}"))?;
Ok(()) Ok(())
} }
@@ -162,15 +166,10 @@ pub async fn close_terminal(
session_id: String, session_id: String,
state: State<'_, TerminalState>, state: State<'_, TerminalState>,
) -> Result<(), String> { ) -> Result<(), String> {
let session = { let session = { state.sessions.lock().remove(&session_id) };
let mut sessions = state.sessions.lock().unwrap();
sessions.remove(&session_id)
};
if let Some(session) = session { if let Some(session) = session {
if let Ok(mut child) = session.child.lock() { let _ = session.child.lock().kill();
let _ = child.kill();
}
} }
Ok(()) Ok(())
@@ -191,11 +190,9 @@ pub async fn restart_terminal_session(
window: Window, window: Window,
) -> Result<CreateTerminalResponse, String> { ) -> Result<CreateTerminalResponse, String> {
{ {
let mut sessions = state.sessions.lock().unwrap(); let session = state.sessions.lock().remove(&payload.session_id);
if let Some(session) = sessions.remove(&payload.session_id) { if let Some(session) = session {
if let Ok(mut child) = session.child.lock() { let _ = session.child.lock().kill();
let _ = child.kill();
}
} }
} }
@@ -239,7 +236,7 @@ pub async fn restart_terminal_session(
let child = Arc::new(Mutex::new(child)); let child = Arc::new(Mutex::new(child));
let session_id = uuid::Uuid::new_v4().to_string(); let session_id = uuid::Uuid::new_v4().to_string();
state.sessions.lock().unwrap().insert( state.sessions.lock().insert(
session_id.clone(), session_id.clone(),
TerminalSession { TerminalSession {
master, master,
@@ -265,65 +262,145 @@ pub async fn force_kill_terminal(
payload: ForceKillPayload, payload: ForceKillPayload,
state: State<'_, TerminalState>, state: State<'_, TerminalState>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut sessions = state.sessions.lock().unwrap(); let mut sessions = state.sessions.lock();
if let Some(session_id) = payload.session_id { if let Some(session_id) = payload.session_id {
// Kill by session_id
if let Some(session) = sessions.remove(&session_id) { if let Some(session) = sessions.remove(&session_id) {
if let Ok(mut child) = session.child.lock() { let _ = session.child.lock().kill();
let _ = child.kill();
}
} }
} else if let Some(cwd) = payload.cwd { return Ok(());
let ids: Vec<String> = sessions.keys().cloned().collect(); }
for id in ids {
if let Some(session) = sessions.remove(&id) { // Current API ignores cwd; keep behavior but avoid holding poisoned locks.
if let Ok(mut child) = session.child.lock() { let _ = payload.cwd;
let _ = child.kill();
} let ids: Vec<String> = sessions.keys().cloned().collect();
} for id in ids {
} if let Some(session) = sessions.remove(&id) {
let _ = cwd; let _ = session.child.lock().kill();
} else {
let ids: Vec<String> = 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();
}
}
} }
} }
Ok(()) Ok(())
} }
fn spawn_reader_thread(mut reader: Box<dyn Read + Send>, window: Window, session_id: String) { fn spawn_reader_thread(reader: Box<dyn Read + Send>, window: Window, session_id: String) {
thread::spawn(move || { thread::spawn(move || {
let mut buffer = [0u8; 16384]; use std::sync::mpsc;
let event_name = format!("terminal://{}", session_id);
loop { let event_name = format!("terminal://{}", session_id);
match reader.read(&mut buffer) { let (tx, rx) = mpsc::channel::<Vec<u8>>();
Ok(0) => break,
Ok(n) => { // Dedicated blocking reader thread.
let data = String::from_utf8_lossy(&buffer[..n]).to_string(); let reader_handle = thread::spawn(move || {
if data.is_empty() { let mut reader = reader;
continue; 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<u8> = 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<u8>, 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; 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; break;
} }
} }
} }
let _ = reader_handle.join();
}); });
} }
@@ -334,10 +411,7 @@ fn spawn_exit_watcher(
session_id: String, session_id: String,
) { ) {
thread::spawn(move || { thread::spawn(move || {
let status = { let status = { child.lock().wait() };
let mut guard = child.lock().expect("terminal child poisoned");
guard.wait()
};
let (exit_code, signal) = match status { let (exit_code, signal) = match status {
Ok(status) => ( Ok(status) => (
@@ -358,8 +432,7 @@ fn spawn_exit_watcher(
}); });
let _ = window.emit(&event_name, payload); let _ = window.emit(&event_name, payload);
let mut sessions = sessions.lock().unwrap(); sessions.lock().remove(&session_id);
sessions.remove(&session_id);
}); });
} }
@@ -388,9 +461,7 @@ fn shell_accepts_login_flag(shell_path: &str) -> bool {
} }
fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> { fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> {
let maybe_path = input let maybe_path = input.map(PathBuf::from).or_else(|| dirs::home_dir());
.map(|value| PathBuf::from(value))
.or_else(|| dirs::home_dir());
let Some(path) = maybe_path else { let Some(path) = maybe_path else {
return Err("Unable to determine working directory".to_string()); return Err("Unable to determine working directory".to_string());
+1
View File
@@ -0,0 +1 @@
+374 -237
View File
@@ -1,19 +1,25 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod assistant_notifications;
mod commands; mod commands;
mod logging; mod logging;
mod assistant_notifications;
mod session_activity;
mod opencode_auth; mod opencode_auth;
mod opencode_config; mod opencode_config;
mod opencode_manager; mod opencode_manager;
mod window_state;
mod path_utils; mod path_utils;
mod session_activity;
mod skills_catalog; 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 anyhow::{anyhow, Result};
use assistant_notifications::spawn_assistant_notifications;
use axum::{ use axum::{
body::{to_bytes, Body}, body::{to_bytes, Body},
extract::{OriginalUri, State}, extract::{OriginalUri, State},
@@ -22,23 +28,22 @@ use axum::{
routing::{any, get, post}, routing::{any, get, post},
Json, Router, 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::files::{create_directory, list_directory, search_files};
use commands::git::{ use commands::git::{
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, 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, 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, ensure_openchamber_ignored, generate_commit_message, get_commit_files,
get_git_branches, get_git_diff, get_git_file_diff, get_git_identities, get_git_log, get_git_status, get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff,
git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree, get_git_identities, get_git_log, get_git_status, git_fetch, git_pull, git_push,
revert_git_file, set_git_identity, update_git_identity, 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::logs::fetch_desktop_logs;
use commands::notifications::desktop_notify;
use commands::permissions::{ use commands::permissions::{
pick_directory, process_directory_selection, request_directory_access, pick_directory, process_directory_selection, request_directory_access,
restore_bookmarks_on_startup, start_accessing_directory, stop_accessing_directory, 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::settings::{load_settings, restart_opencode, save_settings};
use commands::terminal::{ use commands::terminal::{
close_terminal, create_terminal_session, force_kill_terminal, resize_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 futures_util::StreamExt as FuturesStreamExt;
use log::{error, info, warn}; use log::{error, info, warn};
use opencode_manager::OpenCodeManager; use opencode_manager::OpenCodeManager;
use path_utils::expand_tilde_path;
use portpicker::pick_unused_port; use portpicker::pick_unused_port;
use reqwest::{header, Body as ReqwestBody, Client}; use reqwest::{header, Body as ReqwestBody, Client};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use tauri::{Emitter, Manager}; use session_activity::spawn_session_activity_tracker;
#[cfg(feature = "devtools")] #[cfg(feature = "devtools")]
use tauri::WebviewWindow; use tauri::WebviewWindow;
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::init as dialog_plugin; use tauri_plugin_dialog::init as dialog_plugin;
use tauri_plugin_fs::init as fs_plugin; use tauri_plugin_fs::init as fs_plugin;
use tauri_plugin_log::{Target, TargetKind}; use tauri_plugin_log::{Target, TargetKind};
@@ -66,7 +73,6 @@ use tokio::{
}; };
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use window_state::{load_window_state, persist_window_state, WindowStateManager}; use window_state::{load_window_state, persist_window_state, WindowStateManager};
use path_utils::expand_tilde_path;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -134,8 +140,10 @@ const MENU_ITEM_HELP_DIALOG_ID: &str = "openchamber_help_dialog";
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
const MENU_ITEM_DOWNLOAD_LOGS_ID: &str = "openchamber_download_logs"; 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_BUG_REPORT_URL: &str =
const GITHUB_FEATURE_REQUEST_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml"; "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"; const DISCORD_INVITE_URL: &str = "https://discord.gg/ZYRSdnwwKA";
#[derive(Clone)] #[derive(Clone)]
@@ -149,7 +157,9 @@ pub(crate) struct DesktopRuntime {
impl DesktopRuntime { impl DesktopRuntime {
fn initialize_sync() -> Result<Self> { fn initialize_sync() -> Result<Self> {
let settings = Arc::new(SettingsStore::new()?); 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 opencode = Arc::new(OpenCodeManager::new_with_directory(initial_dir.clone()));
let client = Client::builder().build()?; let client = Client::builder().build()?;
@@ -265,7 +275,13 @@ struct ServerInfoPayload {
async fn desktop_server_info( async fn desktop_server_info(
state: tauri::State<'_, DesktopRuntime>, state: tauri::State<'_, DesktopRuntime>,
) -> Result<ServerInfoPayload, String> { ) -> Result<ServerInfoPayload, String> {
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 { Ok(ServerInfoPayload {
server_port: state.server_port, server_port: state.server_port,
opencode_port: state.opencode.current_port(), opencode_port: state.opencode.current_port(),
@@ -300,10 +316,14 @@ fn get_macos_major_version() -> isize {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn adjust_traffic_lights_position<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>, x: f64, y: f64) { fn adjust_traffic_lights_position<R: tauri::Runtime>(
use objc2::runtime::AnyObject; window: &tauri::WebviewWindow<R>,
x: f64,
y: f64,
) {
use objc2::msg_send; 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() { if let Ok(ns_window) = window.ns_window() {
unsafe { unsafe {
@@ -338,8 +358,12 @@ fn prevent_app_nap() {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn build_macos_menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<tauri::menu::Menu<R>> { fn build_macos_menu<R: tauri::Runtime>(
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID}; app: &tauri::AppHandle<R>,
) -> tauri::Result<tauri::menu::Menu<R>> {
use tauri::menu::{
Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
};
let pkg_info = app.package_info(); let pkg_info = app.package_info();
@@ -360,13 +384,7 @@ fn build_macos_menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Resu
)?; )?;
// App menu items // App menu items
let settings = MenuItem::with_id( let settings = MenuItem::with_id(app, MENU_ITEM_SETTINGS_ID, "Settings", true, Some("Cmd+,"))?;
app,
MENU_ITEM_SETTINGS_ID,
"Settings",
true,
Some("Cmd+,"),
)?;
let command_palette = MenuItem::with_id( let command_palette = MenuItem::with_id(
app, app,
@@ -402,13 +420,8 @@ fn build_macos_menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Resu
)?; )?;
// View menu items // View menu items
let open_git_tab = MenuItem::with_id( let open_git_tab =
app, MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Ctrl+G"))?;
MENU_ITEM_OPEN_GIT_TAB_ID,
"Git",
true,
Some("Ctrl+G"),
)?;
let open_diff_tab = MenuItem::with_id( let open_diff_tab = MenuItem::with_id(
app, app,
@@ -664,12 +677,21 @@ fn main() {
info!("[macos] Detected macOS version: {}", macos_version); info!("[macos] Detected macOS version: {}", macos_version);
let corner_radius = if macos_version >= 26 { 24.0 } else { 10.0 }; let corner_radius = if macos_version >= 26 { 24.0 } else { 10.0 };
if let Err(error) = if let Err(error) = apply_vibrancy(
apply_vibrancy(&window, NSVisualEffectMaterial::Sidebar, None, Some(corner_radius)) &window,
{ NSVisualEffectMaterial::Sidebar,
warn!("[desktop:vibrancy] Failed to apply macOS vibrancy: {}", error); None,
Some(corner_radius),
) {
warn!(
"[desktop:vibrancy] Failed to apply macOS vibrancy: {}",
error
);
} else { } 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 { if macos_version < 26 {
@@ -691,7 +713,11 @@ fn main() {
let app_handle = app.app_handle().clone(); let app_handle = app.app_handle().clone();
let runtime_clone = runtime.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 { tauri::async_runtime::spawn(async move {
// Only start opencode if we have a saved directory, otherwise frontend will prompt // Only start opencode if we have a saved directory, otherwise frontend will prompt
if has_initial_dir { if has_initial_dir {
@@ -700,7 +726,9 @@ fn main() {
info!("[desktop] No saved directory - waiting for user to select one"); info!("[desktop] No saved directory - waiting for user to select one");
} }
if let Err(e) = restore_bookmarks_on_startup(app_handle.state::<DesktopRuntime>().clone()).await { if let Err(e) =
restore_bookmarks_on_startup(app_handle.state::<DesktopRuntime>().clone()).await
{
warn!("Failed to restore bookmarks on startup: {}", e); warn!("Failed to restore bookmarks on startup: {}", e);
} }
@@ -728,8 +756,12 @@ fn main() {
Ok(false) => { Ok(false) => {
let _ = app_handle.emit("server.instance.disposed", ()); let _ = app_handle.emit("server.instance.disposed", ());
if runtime.opencode_manager().is_cli_available() { if runtime.opencode_manager().is_cli_available() {
if let Err(err) = runtime.opencode_manager().ensure_running().await { if let Err(err) =
warn!("[desktop:watchdog] Failed to restart OpenCode: {err}"); runtime.opencode_manager().ensure_running().await
{
warn!(
"[desktop:watchdog] Failed to restart OpenCode: {err}"
);
} else { } else {
backoff_ms = 1000; backoff_ms = 1000;
} }
@@ -779,10 +811,12 @@ fn main() {
}; };
let changed = match &last_snapshot { let changed = match &last_snapshot {
Some(prev) => prev.ok != snapshot.ok Some(prev) => {
|| prev.port != snapshot.port prev.ok != snapshot.ok
|| prev.api_prefix != snapshot.api_prefix || prev.port != snapshot.port
|| prev.cli_available != snapshot.cli_available, || prev.api_prefix != snapshot.api_prefix
|| prev.cli_available != snapshot.cli_available
}
None => true, None => true,
}; };
@@ -990,7 +1024,9 @@ fn main() {
tauri::WindowEvent::Focused(true) => { tauri::WindowEvent::Focused(true) => {
// Clear dock badge and underlying badge state when the window gains focus // Clear dock badge and underlying badge state when the window gains focus
let _ = window.set_badge_count(None); 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) => { tauri::WindowEvent::Moved(position) => {
let is_maximized = window.is_maximized().unwrap_or(false); let is_maximized = window.is_maximized().unwrap_or(false);
@@ -1037,7 +1073,6 @@ fn main() {
app.run(|_app_handle, _event| {}); app.run(|_app_handle, _event| {});
} }
fn spawn_http_server(port: u16, state: ServerState, shutdown_rx: broadcast::Receiver<()>) { fn spawn_http_server(port: u16, state: ServerState, shutdown_rx: broadcast::Receiver<()>) {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
if let Err(error) = run_http_server(port, state, shutdown_rx).await { if let Err(error) = run_http_server(port, state, shutdown_rx).await {
@@ -1053,7 +1088,10 @@ async fn run_http_server(
) -> Result<()> { ) -> Result<()> {
let router = Router::new() let router = Router::new()
.route("/health", get(health_handler)) .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/opencode/directory", post(change_directory_handler))
.route("/api", any(proxy_to_opencode)) .route("/api", any(proxy_to_opencode))
.route("/api/{*rest}", any(proxy_to_opencode)) .route("/api/{*rest}", any(proxy_to_opencode))
@@ -1084,7 +1122,9 @@ async fn health_handler(State(state): State<ServerState>) -> Json<HealthResponse
}) })
} }
async fn models_metadata_handler(State(state): State<ServerState>) -> Result<Json<Value>, StatusCode> { async fn models_metadata_handler(
State(state): State<ServerState>,
) -> Result<Json<Value>, StatusCode> {
let now = Instant::now(); let now = Instant::now();
let cached_payload: Option<Value> = { let cached_payload: Option<Value> = {
let cache = state.models_metadata_cache.lock().await; let cache = state.models_metadata_cache.lock().await;
@@ -1150,12 +1190,17 @@ fn json_response<T: Serialize>(status: StatusCode, payload: T) -> Response<Body>
} }
fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response<Body> { fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response<Body> {
json_response(status, ConfigErrorResponse { json_response(
error: message.into(), status,
}) ConfigErrorResponse {
error: message.into(),
},
)
} }
async fn parse_request_payload(req: Request<Body>) -> Result<HashMap<String, Value>, Response<Body>> { async fn parse_request_payload(
req: Request<Body>,
) -> Result<HashMap<String, Value>, Response<Body>> {
let (_, body) = req.into_parts(); let (_, body) = req.into_parts();
let body_bytes = to_bytes(body, PROXY_BODY_LIMIT) let body_bytes = to_bytes(body, PROXY_BODY_LIMIT)
.await .await
@@ -1174,14 +1219,12 @@ async fn refresh_opencode_after_config_change(
reason: &str, reason: &str,
) -> Result<(), Response<Body>> { ) -> Result<(), Response<Body>> {
info!("[desktop:config] Restarting OpenCode after {}", reason); info!("[desktop:config] Restarting OpenCode after {}", reason);
state state.opencode.restart().await.map_err(|err| {
.opencode config_error_response(
.restart()
.await
.map_err(|err| config_error_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to restart OpenCode: {}", err), format!("Failed to restart OpenCode: {}", err),
))?; )
})?;
Ok(()) Ok(())
} }
@@ -1228,7 +1271,8 @@ async fn handle_agent_route(
}; };
// Extract scope from payload if present // Extract scope from payload if present
let scope = payload.get("scope") let scope = payload
.get("scope")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.and_then(|s| match s { .and_then(|s| match s {
"project" => Some(opencode_config::AgentScope::Project), "project" => Some(opencode_config::AgentScope::Project),
@@ -1236,7 +1280,9 @@ async fn handle_agent_route(
_ => None, _ => 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(()) => { Ok(()) => {
if let Err(resp) = if let Err(resp) =
refresh_opencode_after_config_change(state, "agent creation").await 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 { Method::DELETE => {
Ok(()) => { match opencode_config::delete_agent(&name, Some(&working_directory)).await {
if let Err(resp) = Ok(()) => {
refresh_opencode_after_config_change(state, "agent deletion").await if let Err(resp) =
{ refresh_opencode_after_config_change(state, "agent deletion").await
return Ok(resp); {
} return Ok(resp);
}
Ok(json_response( Ok(json_response(
StatusCode::OK, StatusCode::OK,
ConfigActionResponse { ConfigActionResponse {
success: true, success: true,
requires_reload: true, requires_reload: true,
message: format!( message: format!(
"Agent {} deleted successfully. Reloading interface...", "Agent {} deleted successfully. Reloading interface...",
name name
), ),
reload_delay_ms: CLIENT_RELOAD_DELAY_MS, 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()), _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
} }
} }
@@ -1366,9 +1414,7 @@ struct SkillFileResponse {
content: String, content: String,
} }
async fn handle_skill_list_route( async fn handle_skill_list_route(state: &ServerState) -> Result<Response<Body>, StatusCode> {
state: &ServerState,
) -> Result<Response<Body>, StatusCode> {
let working_directory = state.opencode.get_working_directory(); let working_directory = state.opencode.get_working_directory();
let discovered = opencode_config::discover_skills(Some(&working_directory)); let discovered = opencode_config::discover_skills(Some(&working_directory));
@@ -1385,12 +1431,18 @@ async fn handle_skill_list_route(
}); });
} }
Err(err) => { 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( async fn handle_skill_route(
@@ -1410,17 +1462,37 @@ async fn handle_skill_route(
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
Ok(sources) => { Ok(sources) => {
if !sources.md.exists { 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)?; 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 { match opencode_config::read_skill_supporting_file(
Ok(content) => Ok(json_response(StatusCode::OK, SkillFileResponse { path: fp.clone(), content })), std::path::Path::new(&skill_dir),
Err(_) => Ok(config_error_response(StatusCode::NOT_FOUND, "File not found")), 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) => { Err(err) => {
error!("[desktop:config] Failed to read skill sources: {}", 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, Ok(data) => data,
Err(resp) => return Ok(resp), 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 { match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
Ok(sources) => { Ok(sources) => {
if !sources.md.exists { 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)?; 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 { match opencode_config::write_skill_supporting_file(
Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse { std::path::Path::new(&skill_dir),
success: true, fp,
requires_reload: false, content,
message: format!("File {} saved successfully", fp), )
reload_delay_ms: 0, .await
})), {
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), 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 => { Method::DELETE => {
@@ -1456,20 +1549,37 @@ async fn handle_skill_route(
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
Ok(sources) => { Ok(sources) => {
if !sources.md.exists { 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)?; 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 { match opencode_config::delete_skill_supporting_file(
Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse { std::path::Path::new(&skill_dir),
success: true, fp,
requires_reload: false, )
message: format!("File {} deleted successfully", fp), .await
reload_delay_ms: 0, {
})), Ok(()) => Ok(json_response(
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())), 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()), _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
@@ -1508,7 +1618,8 @@ async fn handle_skill_route(
Err(resp) => return Ok(resp), Err(resp) => return Ok(resp),
}; };
let scope = payload.get("scope") let scope = payload
.get("scope")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.and_then(|s| match s { .and_then(|s| match s {
"project" => Some(opencode_config::SkillScope::Project), "project" => Some(opencode_config::SkillScope::Project),
@@ -1516,7 +1627,14 @@ async fn handle_skill_route(
_ => None, _ => 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(()) => { Ok(()) => {
if let Err(resp) = if let Err(resp) =
refresh_opencode_after_config_change(state, "skill creation").await refresh_opencode_after_config_change(state, "skill creation").await
@@ -1552,7 +1670,8 @@ async fn handle_skill_route(
Err(resp) => return Ok(resp), 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(()) => { Ok(()) => {
if let Err(resp) = if let Err(resp) =
refresh_opencode_after_config_change(state, "skill update").await 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 { Method::DELETE => {
Ok(()) => { match opencode_config::delete_skill(&name, Some(&working_directory)).await {
if let Err(resp) = Ok(()) => {
refresh_opencode_after_config_change(state, "skill deletion").await if let Err(resp) =
{ refresh_opencode_after_config_change(state, "skill deletion").await
return Ok(resp); {
} return Ok(resp);
}
Ok(json_response( Ok(json_response(
StatusCode::OK, StatusCode::OK,
ConfigActionResponse { ConfigActionResponse {
success: true, success: true,
requires_reload: true, requires_reload: true,
message: format!( message: format!(
"Skill {} deleted successfully. Reloading interface...", "Skill {} deleted successfully. Reloading interface...",
name name
), ),
reload_delay_ms: CLIENT_RELOAD_DELAY_MS, 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()), _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
} }
} }
@@ -1661,7 +1782,8 @@ async fn handle_command_route(
}; };
// Extract scope from payload if present // Extract scope from payload if present
let scope = payload.get("scope") let scope = payload
.get("scope")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.and_then(|s| match s { .and_then(|s| match s {
"project" => Some(opencode_config::CommandScope::Project), "project" => Some(opencode_config::CommandScope::Project),
@@ -1669,7 +1791,9 @@ async fn handle_command_route(
_ => None, _ => 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(()) => { Ok(()) => {
if let Err(resp) = if let Err(resp) =
refresh_opencode_after_config_change(state, "command creation").await refresh_opencode_after_config_change(state, "command creation").await
@@ -1691,7 +1815,10 @@ async fn handle_command_route(
)) ))
} }
Err(err) => { Err(err) => {
error!("[desktop:config] Failed to create command {}: {}", name, err); error!(
"[desktop:config] Failed to create command {}: {}",
name, err
);
Ok(config_error_response( Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(), err.to_string(),
@@ -1727,7 +1854,10 @@ async fn handle_command_route(
)) ))
} }
Err(err) => { Err(err) => {
error!("[desktop:config] Failed to update command {}: {}", name, err); error!(
"[desktop:config] Failed to update command {}: {}",
name, err
);
Ok(config_error_response( Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(), err.to_string(),
@@ -1735,37 +1865,42 @@ async fn handle_command_route(
} }
} }
} }
Method::DELETE => match opencode_config::delete_command(&name, Some(&working_directory)).await { Method::DELETE => {
Ok(()) => { match opencode_config::delete_command(&name, Some(&working_directory)).await {
if let Err(resp) = Ok(()) => {
refresh_opencode_after_config_change(state, "command deletion").await if let Err(resp) =
{ refresh_opencode_after_config_change(state, "command deletion").await
return Ok(resp); {
} return Ok(resp);
}
Ok(json_response( Ok(json_response(
StatusCode::OK, StatusCode::OK,
ConfigActionResponse { ConfigActionResponse {
success: true, success: true,
requires_reload: true, requires_reload: true,
message: format!( message: format!(
"Command {} deleted successfully. Reloading interface...", "Command {} deleted successfully. Reloading interface...",
name name
), ),
reload_delay_ms: CLIENT_RELOAD_DELAY_MS, 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()), _ => 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 payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
let scan_request = match serde_json::from_value::<skills_catalog::SkillsScanRequest>(payload_value) { let scan_request =
Ok(v) => v, match serde_json::from_value::<skills_catalog::SkillsScanRequest>(payload_value) {
Err(_) => { Ok(v) => v,
return Ok(json_response( Err(_) => {
StatusCode::BAD_REQUEST, return Ok(json_response(
skills_catalog::SkillsRepoScanResponse { StatusCode::BAD_REQUEST,
ok: false, skills_catalog::SkillsRepoScanResponse {
items: None, ok: false,
error: Some(skills_catalog::SkillsRepoError { items: None,
kind: "invalidSource".to_string(), error: Some(skills_catalog::SkillsRepoError {
message: "Malformed scan request".to_string(), kind: "invalidSource".to_string(),
ssh_only: None, message: "Malformed scan request".to_string(),
identities: None, ssh_only: None,
conflicts: None, identities: None,
}), conflicts: None,
}, }),
)) },
} ))
}; }
};
let response = skills_catalog::scan_repository(scan_request).await; let response = skills_catalog::scan_repository(scan_request).await;
let status = if response.ok { 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 payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
let install_request = match serde_json::from_value::<skills_catalog::SkillsInstallRequest>(payload_value) { let install_request =
Ok(v) => v, match serde_json::from_value::<skills_catalog::SkillsInstallRequest>(payload_value) {
Err(_) => { Ok(v) => v,
return Ok(json_response( Err(_) => {
StatusCode::BAD_REQUEST, return Ok(json_response(
skills_catalog::SkillsInstallResponse { StatusCode::BAD_REQUEST,
ok: false, skills_catalog::SkillsInstallResponse {
installed: None, ok: false,
skipped: None, installed: None,
error: Some(skills_catalog::SkillsRepoError { skipped: None,
kind: "invalidSource".to_string(), error: Some(skills_catalog::SkillsRepoError {
message: "Malformed install request".to_string(), kind: "invalidSource".to_string(),
ssh_only: None, message: "Malformed install request".to_string(),
identities: None, ssh_only: None,
conflicts: None, identities: None,
}), conflicts: None,
}, }),
)) },
} ))
}; }
};
let working_directory = state.opencode.get_working_directory(); let working_directory = state.opencode.get_working_directory();
let response = skills_catalog::install_skills(&working_directory, install_request).await; 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/") { if let Some(files_start) = rest.find("/files/") {
let name = &rest[..files_start]; let name = &rest[..files_start];
let file_path_encoded = &rest[files_start + 7..]; // Skip "/files/" 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) let file_path = urlencoding::decode(file_path_encoded)
.map(|s| s.into_owned()) .map(|s| s.into_owned())
.unwrap_or_else(|_| file_path_encoded.to_string()); .unwrap_or_else(|_| file_path_encoded.to_string());
@@ -1914,7 +2051,8 @@ async fn handle_config_routes(
"Skill name is required", "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(); let trimmed = rest.trim();
@@ -1939,8 +2077,7 @@ async fn handle_config_routes(
ConfigActionResponse { ConfigActionResponse {
success: true, success: true,
requires_reload: true, requires_reload: true,
message: "Configuration reloaded successfully. Refreshing interface..." message: "Configuration reloaded successfully. Refreshing interface...".to_string(),
.to_string(),
reload_delay_ms: CLIENT_RELOAD_DELAY_MS, reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
}, },
)); ));
@@ -2063,12 +2200,12 @@ async fn change_directory_handler(
.set_working_directory(resolved_path.clone()) .set_working_directory(resolved_path.clone())
.await .await
.map_err(|e| { .map_err(|e| {
error!( error!(
"[desktop:http] ERROR: Failed to set working directory: {}", "[desktop:http] ERROR: Failed to set working directory: {}",
e e
); );
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
state.opencode.restart().await.map_err(|e| { state.opencode.restart().await.map_err(|e| {
error!("[desktop:http] ERROR: Failed to restart OpenCode: {}", e); error!("[desktop:http] ERROR: Failed to restart OpenCode: {}", e);
@@ -94,5 +94,3 @@ pub async fn remove_provider_auth(provider_id: &str) -> Result<bool> {
Ok(true) Ok(true)
} }
+190 -50
View File
@@ -199,7 +199,11 @@ struct JsonEntrySource {
section: Option<Value>, section: Option<Value>,
} }
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(ref custom_path) = layers.paths.custom {
if let Some(section) = layers.custom.get(section_key).and_then(|v| v.as_object()) { if let Some(section) = layers.custom.get(section_key).and_then(|v| v.as_object()) {
if let Some(value) = section.get(entry_name) { if let Some(value) = section.get(entry_name) {
@@ -308,7 +312,10 @@ async fn ensure_project_agent_dir(working_directory: &Path) -> Result<PathBuf> {
} }
/// Determine agent scope based on where the .md file exists /// Determine agent scope based on where the .md file exists
pub fn get_agent_scope(agent_name: &str, working_directory: Option<&Path>) -> (Option<AgentScope>, Option<PathBuf>) { pub fn get_agent_scope(
agent_name: &str,
working_directory: Option<&Path>,
) -> (Option<AgentScope>, Option<PathBuf>) {
if let Some(wd) = working_directory { if let Some(wd) = working_directory {
let project_path = get_project_agent_path(wd, agent_name); let project_path = get_project_agent_path(wd, agent_name);
if project_path.exists() { if project_path.exists() {
@@ -325,7 +332,11 @@ pub fn get_agent_scope(agent_name: &str, working_directory: Option<&Path>) -> (O
} }
/// Get the path where an agent should be written based on scope /// 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>) -> (AgentScope, PathBuf) { fn get_agent_write_path(
agent_name: &str,
working_directory: Option<&Path>,
requested_scope: Option<AgentScope>,
) -> (AgentScope, PathBuf) {
// For updates: check existing location first (project takes precedence) // For updates: check existing location first (project takes precedence)
let (existing_scope, existing_path) = get_agent_scope(agent_name, working_directory); let (existing_scope, existing_path) = get_agent_scope(agent_name, working_directory);
if let Some(path) = existing_path { if let Some(path) = existing_path {
@@ -368,7 +379,10 @@ async fn ensure_project_command_dir(working_directory: &Path) -> Result<PathBuf>
} }
/// Determine command scope based on where the .md file exists /// Determine command scope based on where the .md file exists
pub fn get_command_scope(command_name: &str, working_directory: Option<&Path>) -> (Option<CommandScope>, Option<PathBuf>) { pub fn get_command_scope(
command_name: &str,
working_directory: Option<&Path>,
) -> (Option<CommandScope>, Option<PathBuf>) {
if let Some(wd) = working_directory { if let Some(wd) = working_directory {
let project_path = get_project_command_path(wd, command_name); let project_path = get_project_command_path(wd, command_name);
if project_path.exists() { if project_path.exists() {
@@ -385,7 +399,11 @@ pub fn get_command_scope(command_name: &str, working_directory: Option<&Path>) -
} }
/// Get the path where a command should be written based on scope /// 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>) -> (CommandScope, PathBuf) { fn get_command_write_path(
command_name: &str,
working_directory: Option<&Path>,
requested_scope: Option<CommandScope>,
) -> (CommandScope, PathBuf) {
// For updates: check existing location first (project takes precedence) // For updates: check existing location first (project takes precedence)
let (existing_scope, existing_path) = get_command_scope(command_name, working_directory); let (existing_scope, existing_path) = get_command_scope(command_name, working_directory);
if let Some(path) = existing_path { if let Some(path) = existing_path {
@@ -396,7 +414,10 @@ fn get_command_write_path(command_name: &str, working_directory: Option<&Path>,
let scope = requested_scope.unwrap_or(CommandScope::User); let scope = requested_scope.unwrap_or(CommandScope::User);
if scope == CommandScope::Project { if scope == CommandScope::Project {
if let Some(wd) = working_directory { 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),
);
} }
} }
@@ -606,7 +627,10 @@ async fn write_md_file(
} }
/// Get information about where agent configuration is stored /// Get information about where agent configuration is stored
pub async fn get_agent_sources(agent_name: &str, working_directory: Option<&Path>) -> Result<ConfigSources> { pub async fn get_agent_sources(
agent_name: &str,
working_directory: Option<&Path>,
) -> Result<ConfigSources> {
ensure_dirs().await?; ensure_dirs().await?;
// Check project level first (takes precedence) // Check project level first (takes precedence)
@@ -687,7 +711,7 @@ pub async fn create_agent(
agent_name: &str, agent_name: &str,
config: &HashMap<String, Value>, config: &HashMap<String, Value>,
working_directory: Option<&Path>, working_directory: Option<&Path>,
scope: Option<AgentScope> scope: Option<AgentScope>,
) -> Result<()> { ) -> Result<()> {
ensure_dirs().await?; ensure_dirs().await?;
@@ -741,7 +765,12 @@ pub async fn create_agent(
// Write .md file // Write .md file
write_md_file(&target_path, &frontmatter, &prompt).await?; 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(()) Ok(())
} }
@@ -799,7 +828,10 @@ pub async fn update_agent(
Some(parse_md_file(&md_path).await?) Some(parse_md_file(&md_path).await?)
} else if is_builtin_override { } else if is_builtin_override {
// Only create new md data for built-in overrides // 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 { } else {
None None
}; };
@@ -836,8 +868,7 @@ pub async fn update_agent(
md_modified = true; md_modified = true;
} }
continue; 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 is_prompt_file_reference(prompt_ref) {
if let Some(prompt_file_path) = resolve_prompt_file_path(prompt_ref) { if let Some(prompt_file_path) = resolve_prompt_file_path(prompt_ref) {
write_prompt_file(&prompt_file_path, &normalized_value).await?; 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); let project_path = get_project_agent_path(wd, agent_name);
if project_path.exists() { if project_path.exists() {
fs::remove_file(&project_path).await?; 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; deleted = true;
} }
} }
@@ -1004,7 +1038,10 @@ pub async fn delete_agent(agent_name: &str, working_directory: Option<&Path>) ->
} }
/// Get information about where command configuration is stored /// Get information about where command configuration is stored
pub async fn get_command_sources(command_name: &str, working_directory: Option<&Path>) -> Result<ConfigSources> { pub async fn get_command_sources(
command_name: &str,
working_directory: Option<&Path>,
) -> Result<ConfigSources> {
ensure_dirs().await?; ensure_dirs().await?;
// Check project level first (takes precedence) // Check project level first (takes precedence)
@@ -1085,7 +1122,7 @@ pub async fn create_command(
command_name: &str, command_name: &str,
config: &HashMap<String, Value>, config: &HashMap<String, Value>,
working_directory: Option<&Path>, working_directory: Option<&Path>,
scope: Option<CommandScope> scope: Option<CommandScope>,
) -> Result<()> { ) -> Result<()> {
ensure_dirs().await?; ensure_dirs().await?;
@@ -1121,7 +1158,10 @@ pub async fn create_command(
let (target_scope, target_path) = if scope == Some(CommandScope::Project) { let (target_scope, target_path) = if scope == Some(CommandScope::Project) {
if let Some(wd) = working_directory { if let Some(wd) = working_directory {
ensure_project_command_dir(wd).await?; 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 { } else {
(CommandScope::User, user_path) (CommandScope::User, user_path)
} }
@@ -1139,7 +1179,12 @@ pub async fn create_command(
// Write .md file // Write .md file
write_md_file(&target_path, &frontmatter, &template).await?; 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(()) Ok(())
} }
@@ -1194,7 +1239,10 @@ pub async fn update_command(
let mut md_data = if md_exists { let mut md_data = if md_exists {
Some(parse_md_file(&md_path).await?) Some(parse_md_file(&md_path).await?)
} else if is_builtin_override { } else if is_builtin_override {
Some(MdData { frontmatter: HashMap::new(), body: String::new() }) Some(MdData {
frontmatter: HashMap::new(),
body: String::new(),
})
} else { } else {
None None
}; };
@@ -1230,7 +1278,9 @@ pub async fn update_command(
md_modified = true; md_modified = true;
} }
continue; 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 is_prompt_file_reference(template_ref) {
if let Some(template_file_path) = resolve_prompt_file_path(template_ref) { if let Some(template_file_path) = resolve_prompt_file_path(template_ref) {
write_prompt_file(&template_file_path, &normalized_value).await?; 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); let project_path = get_project_command_path(wd, command_name);
if project_path.exists() { if project_path.exists() {
fs::remove_file(&project_path).await?; 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; 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); let user_path = get_user_command_path(command_name);
if user_path.exists() { if user_path.exists() {
fs::remove_file(&user_path).await?; 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; deleted = true;
} }
@@ -1471,7 +1527,10 @@ fn get_user_skill_path(skill_name: &str) -> PathBuf {
/// Get project-level skill directory (.opencode/skill/) /// Get project-level skill directory (.opencode/skill/)
fn get_project_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf { 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 /// 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/) /// Get Claude-compatible skill directory (.claude/skills/)
fn get_claude_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf { 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 /// Get Claude-compatible skill SKILL.md path
@@ -1504,24 +1566,39 @@ async fn ensure_project_skill_dir(working_directory: &Path, skill_name: &str) ->
} }
/// Determine skill scope based on where the SKILL.md file exists /// Determine skill scope based on where the SKILL.md file exists
pub fn get_skill_scope(skill_name: &str, working_directory: Option<&Path>) -> (Option<SkillScope>, Option<PathBuf>, Option<SkillSource>) { pub fn get_skill_scope(
skill_name: &str,
working_directory: Option<&Path>,
) -> (Option<SkillScope>, Option<PathBuf>, Option<SkillSource>) {
if let Some(wd) = working_directory { if let Some(wd) = working_directory {
// Check .opencode/skill first // Check .opencode/skill first
let project_path = get_project_skill_path(wd, skill_name); let project_path = get_project_skill_path(wd, skill_name);
if project_path.exists() { 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) // Check .claude/skills (claude-compat)
let claude_path = get_claude_skill_path(wd, skill_name); let claude_path = get_claude_skill_path(wd, skill_name);
if claude_path.exists() { 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); let user_path = get_user_skill_path(skill_name);
if user_path.exists() { 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) (None, None, None)
@@ -1540,7 +1617,8 @@ fn list_supporting_files(skill_dir: &Path) -> Vec<SupportingFile> {
if path.is_dir() { if path.is_dir() {
walk_dir(&path, relative_base, files); walk_dir(&path, relative_base, files);
} else if file_name != "SKILL.md" { } 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()) .map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| file_name.clone()); .unwrap_or_else(|_| file_name.clone());
@@ -1560,17 +1638,21 @@ fn list_supporting_files(skill_dir: &Path) -> Vec<SupportingFile> {
/// Discover all skills from all sources /// Discover all skills from all sources
pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill> { pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill> {
let mut skills: std::collections::HashMap<String, DiscoveredSkill> = std::collections::HashMap::new(); let mut skills: std::collections::HashMap<String, DiscoveredSkill> =
std::collections::HashMap::new();
// Helper to add skill if not already found // Helper to add skill if not already found
let mut add_skill = |name: String, path: PathBuf, scope: Scope, source: SkillSource| { let mut add_skill = |name: String, path: PathBuf, scope: Scope, source: SkillSource| {
if !skills.contains_key(&name) { if !skills.contains_key(&name) {
skills.insert(name.clone(), DiscoveredSkill { skills.insert(
name, name.clone(),
path: path.display().to_string(), DiscoveredSkill {
scope, name,
source, path: path.display().to_string(),
}); scope,
source,
},
);
} }
}; };
@@ -1628,29 +1710,58 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill>
} }
/// Get information about where skill configuration is stored /// Get information about where skill configuration is stored
pub async fn get_skill_sources(skill_name: &str, working_directory: Option<&Path>) -> Result<SkillConfigSources> { pub async fn get_skill_sources(
skill_name: &str,
working_directory: Option<&Path>,
) -> Result<SkillConfigSources> {
ensure_skill_dirs().await?; ensure_skill_dirs().await?;
// Check all possible locations // Check all possible locations
let project_path = working_directory.map(|wd| get_project_skill_path(wd, skill_name)); 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_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_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_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_path = get_user_skill_path(skill_name);
let user_exists = user_path.exists(); 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) // Determine which md file to use (priority: project > claude > user)
let (md_path, md_exists, md_scope, md_source, md_dir) = if project_exists { 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 { } 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 { } 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 { } else {
(None, false, None, None, None) (None, false, None, None, None)
}; };
@@ -1667,8 +1778,16 @@ pub async fn get_skill_sources(skill_name: &str, working_directory: Option<&Path
md_fields.extend(md_data.frontmatter.keys().cloned()); md_fields.extend(md_data.frontmatter.keys().cloned());
// Extract actual content values // Extract actual content values
md_name = md_data.frontmatter.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); md_name = md_data
md_description = md_data.frontmatter.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); .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() { if !md_data.body.trim().is_empty() {
md_fields.push("instructions".to_string()); md_fields.push("instructions".to_string());
@@ -1719,7 +1838,11 @@ pub async fn read_skill_supporting_file(skill_dir: &Path, relative_path: &str) -
} }
/// Write a supporting file /// 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); let full_path = skill_dir.join(relative_path);
if let Some(parent) = full_path.parent() { if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).await?; fs::create_dir_all(parent).await?;
@@ -1834,7 +1957,12 @@ 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(()) Ok(())
} }
@@ -1846,7 +1974,9 @@ pub async fn update_skill(
) -> Result<()> { ) -> Result<()> {
let (_, existing_path, _) = get_skill_scope(skill_name, working_directory); 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_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_data = parse_md_file(&md_path).await?;
let mut md_modified = false; let mut md_modified = false;
@@ -1889,7 +2019,11 @@ pub async fn update_skill(
write_md_file(&md_path, &md_data.frontmatter, &md_data.body).await?; 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(()) Ok(())
} }
@@ -1903,7 +2037,10 @@ pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) ->
let project_dir = get_project_skill_dir(wd, skill_name); let project_dir = get_project_skill_dir(wd, skill_name);
if project_dir.exists() { if project_dir.exists() {
fs::remove_dir_all(&project_dir).await?; 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; deleted = true;
} }
@@ -1911,7 +2048,10 @@ pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) ->
let claude_dir = get_claude_skill_dir(wd, skill_name); let claude_dir = get_claude_skill_dir(wd, skill_name);
if claude_dir.exists() { if claude_dir.exists() {
fs::remove_dir_all(&claude_dir).await?; 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; deleted = true;
} }
} }
@@ -268,14 +268,12 @@ impl OpenCodeManager {
} }
async fn spawn_process(&self) -> Result<Child> { async fn spawn_process(&self) -> Result<Child> {
let binary = self.binary.as_ref().ok_or_else(|| { let binary = self
anyhow!("Cannot spawn process: OpenCode CLI is not available") .binary
})?; .as_ref()
.ok_or_else(|| anyhow!("Cannot spawn process: OpenCode CLI is not available"))?;
info!( info!("[desktop:opencode] launching {} {:?}", binary, self.args);
"[desktop:opencode] launching {} {:?}",
binary, self.args
);
let working_dir = self.working_dir.read().clone(); let working_dir = self.working_dir.read().clone();
let mut cmd = Command::new(binary); let mut cmd = Command::new(binary);
@@ -520,7 +518,10 @@ fn resolve_opencode_binary() -> Option<String> {
if let Ok(value) = std::env::var("OPENCODE_BINARY") { if let Ok(value) = std::env::var("OPENCODE_BINARY") {
if !value.is_empty() && Path::new(&value).exists() { 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); return Some(value);
} }
} }
@@ -529,7 +530,10 @@ fn resolve_opencode_binary() -> Option<String> {
if let Some(ref binary) = shell_env.opencode_binary { if let Some(ref binary) = shell_env.opencode_binary {
if Path::new(binary).exists() { 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()); return Some(binary.clone());
} }
} }
@@ -547,7 +551,10 @@ fn resolve_opencode_binary() -> Option<String> {
if let Some(home) = dirs::home_dir() { if let Some(home) = dirs::home_dir() {
let fallback = home.join(".opencode/bin/opencode"); let fallback = home.join(".opencode/bin/opencode");
if fallback.exists() { 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()); return Some(fallback.to_string_lossy().to_string());
} }
} }
@@ -590,8 +597,8 @@ struct ShellEnv {
fn get_user_shell() -> Option<String> { fn get_user_shell() -> Option<String> {
use std::process::Command; use std::process::Command;
let username = dirs::home_dir() let username =
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?; dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?;
let output = Command::new("dscl") let output = Command::new("dscl")
.args([".", "-read", &format!("/Users/{}", username), "UserShell"]) .args([".", "-read", &format!("/Users/{}", username), "UserShell"])
@@ -663,7 +670,10 @@ fn detect_shell_env() -> ShellEnv {
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); 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(); 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 env
} }
} }
@@ -18,4 +18,3 @@ pub fn expand_tilde_path(value: &str) -> PathBuf {
PathBuf::from(trimmed) PathBuf::from(trimmed)
} }
@@ -1,8 +1,4 @@
use std::{ use std::{collections::HashMap, sync::Arc, time::Duration};
collections::HashMap,
sync::Arc,
time::Duration,
};
use anyhow::Result; use anyhow::Result;
use futures_util::TryStreamExt; use futures_util::TryStreamExt;
@@ -57,7 +53,10 @@ pub fn spawn_session_activity_tracker(
let mut shutdown_rx = runtime.subscribe_shutdown(); let mut shutdown_rx = runtime.subscribe_shutdown();
let phases = Arc::new(Mutex::new(HashMap::<String, ActivityPhase>::new())); let phases = Arc::new(Mutex::new(HashMap::<String, ActivityPhase>::new()));
let cooldowns = Arc::new(Mutex::new(HashMap::<String, tauri::async_runtime::JoinHandle<()>>::new())); let cooldowns = Arc::new(Mutex::new(HashMap::<
String,
tauri::async_runtime::JoinHandle<()>,
>::new()));
loop { loop {
tokio::select! { tokio::select! {
@@ -112,7 +111,11 @@ async fn run_once(
loop { loop {
buf.clear(); 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(Ok(n)) => n,
Ok(Err(err)) => { Ok(Err(err)) => {
@@ -155,7 +158,9 @@ async fn run_once(
data_lines.clear(); data_lines.clear();
match parse_event_envelope(&raw) { 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}"), Err(err) => warn!("[desktop:activity] Failed to parse SSE data: {err}; raw={raw}"),
}; };
continue; continue;
@@ -214,7 +219,9 @@ async fn connect_activity_sse(
let working_dir = opencode.get_working_directory(); let working_dir = opencode.get_working_directory();
let directory = working_dir.to_string_lossy().to_string(); let directory = working_dir.to_string_lossy().to_string();
let mut parsed = reqwest::Url::parse(&event_url)?; 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 directory_url = parsed.to_string();
let response = try_connect_sse(client, &directory_url, "[desktop:activity]").await?; 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))) Ok((response, SseScope::Directory(working_dir)))
} }
async fn try_connect_sse(client: &Client, url: &str, log_prefix: &str) -> Result<reqwest::Response> { async fn try_connect_sse(
client: &Client,
url: &str,
log_prefix: &str,
) -> Result<reqwest::Response> {
debug!("{log_prefix} Connecting SSE: {url}"); debug!("{log_prefix} Connecting SSE: {url}");
let response = client let response = client
@@ -280,7 +291,14 @@ async fn handle_event(
.and_then(Value::as_str) .and_then(Value::as_str)
.map(|s| s.to_string()); .map(|s| s.to_string());
if let Some(id) = session_id { 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" => { "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). // Mark session busy when we see assistant parts streaming (covers cases where session.status is missing).
if is_streaming_assistant_part(&event.properties) { 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. // Derive cooldown from info.finish === 'stop' when present.
+210 -52
View File
@@ -11,9 +11,8 @@ use uuid::Uuid;
use crate::opencode_config; use crate::opencode_config;
static SKILL_NAME_RE: Lazy<Regex> = Lazy::new(|| { static SKILL_NAME_RE: Lazy<Regex> =
Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").expect("valid skill name regex") Lazy::new(|| Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").expect("valid skill name regex"));
});
static AUTH_ERROR_RE: Lazy<Regex> = Lazy::new(|| { static AUTH_ERROR_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(permission denied|publickey|could not read from remote repository|authentication failed)") Regex::new(r"(?i)(permission denied|publickey|could not read from remote repository|authentication failed)")
@@ -188,7 +187,10 @@ fn list_identities() -> Vec<IdentitySummary> {
wrapper wrapper
.profiles .profiles
.into_iter() .into_iter()
.map(|p| IdentitySummary { id: p.id, name: p.name }) .map(|p| IdentitySummary {
id: p.id,
name: p.name,
})
.collect() .collect()
} }
@@ -314,7 +316,12 @@ fn parse_skill_md_frontmatter(contents: &str) -> (Option<String>, Option<String>
(name, description, warnings) (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"); let mut cmd = Command::new("git");
if let Some(key) = ssh_key { 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", "ssh -i {} -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
key 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(); 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(()); return Ok(());
} }
@@ -421,7 +432,18 @@ async fn scan_repo_items(
subpath: Option<&str>, subpath: Option<&str>,
default_subpath: Option<&str>, default_subpath: Option<&str>,
ssh_key: Option<&str>, ssh_key: Option<&str>,
) -> Result<(String, Option<String>, Vec<(String, String, Option<String>, Option<String>, Vec<String>, bool)>)> { ) -> Result<(
String,
Option<String>,
Vec<(
String,
String,
Option<String>,
Option<String>,
Vec<String>,
bool,
)>,
)> {
let parsed = parse_repo_source(source, subpath)?; let parsed = parse_repo_source(source, subpath)?;
let effective_subpath = parsed let effective_subpath = parsed
.effective_subpath .effective_subpath
@@ -435,7 +457,10 @@ async fn scan_repo_items(
parsed.clone_https.clone() 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) // Clone into temp_base (directory must not exist for git clone target)
let _ = tokio::fs::remove_dir_all(&temp_base).await; let _ = tokio::fs::remove_dir_all(&temp_base).await;
@@ -482,7 +507,13 @@ async fn scan_repo_items(
]; ];
set_args.extend(patterns.clone()); 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() { if sparse_set.is_ok() {
let checkout = run_git( let checkout = run_git(
&vec![ &vec![
@@ -539,7 +570,13 @@ async fn scan_repo_items(
list_args.push(sp.clone()); 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 { let stdout = match list_out {
Ok((out, _)) => out, Ok((out, _)) => out,
Err(_) => { Err(_) => {
@@ -605,7 +642,14 @@ async fn scan_repo_items(
format!("HEAD:{}", skill_md_repo_path), 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, Ok((out, _)) => out,
Err(_) => { Err(_) => {
warnings.push("Failed to read SKILL.md".to_string()); 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); warnings.append(&mut fm_warnings);
let installable = validate_skill_name(&skill_name); let installable = validate_skill_name(&skill_name);
@@ -644,7 +689,8 @@ struct CacheEntry {
items: Vec<SkillsCatalogItem>, items: Vec<SkillsCatalogItem>,
} }
static CATALOG_CACHE: Lazy<Mutex<HashMap<String, CacheEntry>>> = Lazy::new(|| Mutex::new(HashMap::new())); static CATALOG_CACHE: Lazy<Mutex<HashMap<String, CacheEntry>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<&str>) -> String { fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<&str>) -> String {
format!( format!(
@@ -656,13 +702,12 @@ fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<&
} }
fn load_custom_catalog_sources() -> Vec<SkillsCatalogSource> { fn load_custom_catalog_sources() -> Vec<SkillsCatalogSource> {
let settings_path = dirs::home_dir() let settings_path = dirs::home_dir().map(|mut home| {
.map(|mut home| { home.push(".config");
home.push(".config"); home.push("openchamber");
home.push("openchamber"); home.push("settings.json");
home.push("settings.json"); home
home });
});
let Some(path) = settings_path else { let Some(path) = settings_path else {
return vec![]; return vec![];
@@ -684,13 +729,31 @@ fn load_custom_catalog_sources() -> Vec<SkillsCatalogSource> {
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
for entry in arr { 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 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 label = obj
let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("").trim(); .get("label")
let subpath = obj.get("subpath").and_then(|v| v.as_str()).unwrap_or("").trim(); .and_then(|v| v.as_str())
let git_identity_id = obj.get("gitIdentityId").and_then(|v| v.as_str()).unwrap_or("").trim(); .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() { if id.is_empty() || label.is_empty() || source.is_empty() {
continue; continue;
@@ -706,8 +769,16 @@ fn load_custom_catalog_sources() -> Vec<SkillsCatalogSource> {
label: label.to_string(), label: label.to_string(),
description: Some(source.to_string()), description: Some(source.to_string()),
source: source.to_string(), source: source.to_string(),
default_subpath: if subpath.is_empty() { None } else { Some(subpath.to_string()) }, default_subpath: if subpath.is_empty() {
git_identity_id: if git_identity_id.is_empty() { None } else { Some(git_identity_id.to_string()) }, 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 sources = get_curated_sources().await;
let discovered = opencode_config::discover_skills(Some(working_directory)); let discovered = opencode_config::discover_skills(Some(working_directory));
let installed_by_name: HashMap<String, opencode_config::DiscoveredSkill> = let installed_by_name: HashMap<String, opencode_config::DiscoveredSkill> = discovered
discovered.into_iter().map(|s| (s.name.clone(), s)).collect(); .into_iter()
.map(|s| (s.name.clone(), s))
.collect();
let mut items_by_source: HashMap<String, Vec<SkillsCatalogItem>> = HashMap::new(); let mut items_by_source: HashMap<String, Vec<SkillsCatalogItem>> = HashMap::new();
@@ -752,7 +825,11 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal
.or(parsed.effective_subpath.as_deref()) .or(parsed.effective_subpath.as_deref())
.unwrap_or(""); .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 { let maybe_cached = if refresh {
None None
@@ -773,7 +850,13 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal
items items
} else { } else {
let ssh_key = resolve_identity_ssh_key(src.git_identity_id.as_deref()); 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 { let (_, _, raw_items) = match scan {
Ok(v) => v, Ok(v) => v,
@@ -804,7 +887,11 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal
frontmatter_name: fm_name, frontmatter_name: fm_name,
description: desc, description: desc,
installable, installable,
warnings: if warnings.is_empty() { None } else { Some(warnings) }, warnings: if warnings.is_empty() {
None
} else {
Some(warnings)
},
installed: SkillsCatalogInstalledBadge { installed: SkillsCatalogInstalledBadge {
is_installed: installed.is_some(), is_installed: installed.is_some(),
scope: installed.map(|s| match s.scope { scope: installed.map(|s| match s.scope {
@@ -865,7 +952,14 @@ pub struct SkillsScanRequest {
pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse { pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse {
let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref()); 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)) => { Ok((_normalized, effective_subpath, raw_items)) => {
let mut items = vec![]; let mut items = vec![];
for (repo_source, skill_dir, fm_name, desc, warnings, installable) in raw_items { 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, frontmatter_name: fm_name,
description: desc, description: desc,
installable, installable,
warnings: if warnings.is_empty() { None } else { Some(warnings) }, warnings: if warnings.is_empty() {
installed: SkillsCatalogInstalledBadge { is_installed: false, scope: None }, None
} else {
Some(warnings)
},
installed: SkillsCatalogInstalledBadge {
is_installed: false,
scope: None,
},
}); });
} }
items.sort_by(|a, b| a.skill_name.cmp(&b.skill_name)); 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 { return SkillsRepoScanResponse {
ok: false, ok: false,
items: None, 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" { 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")) Err(anyhow!("Invalid scope"))
@@ -1017,7 +1123,10 @@ async fn copy_dir_no_symlinks(src: &Path, dst: &Path) -> Result<()> {
Ok(()) 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 ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref());
let selections: Vec<String> = req let selections: Vec<String> = req
@@ -1032,7 +1141,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
ok: false, ok: false,
installed: None, installed: None,
skipped: 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"); let auto = req.conflict_policy.as_deref().unwrap_or("prompt");
if decision.is_none() && auto != "skipAll" && auto != "overwriteAll" { 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() 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 _ = tokio::fs::remove_dir_all(&temp_base).await;
let clone_res = clone_repo(&clone_url, &temp_base, ssh_key.as_deref()).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, ok: false,
installed: None, installed: None,
skipped: 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(), "init".to_string(),
"--cone".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![ let mut set_args = vec![
"-C".to_string(), "-C".to_string(),
@@ -1148,7 +1274,14 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
set_args.push(dir.clone()); 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; safe_rm(&temp_base).await;
return SkillsInstallResponse { return SkillsInstallResponse {
ok: false, ok: false,
@@ -1166,7 +1299,14 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
"HEAD".to_string(), "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; safe_rm(&temp_base).await;
return SkillsInstallResponse { return SkillsInstallResponse {
ok: false, ok: false,
@@ -1188,21 +1328,30 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
.to_string(); .to_string();
if !validate_skill_name(&skill_name) { 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; continue;
} }
let src_dir = repo_path_to_fs(&temp_base, &skill_dir); let src_dir = repo_path_to_fs(&temp_base, &skill_dir);
let skill_md = src_dir.join("SKILL.md"); let skill_md = src_dir.join("SKILL.md");
if !skill_md.exists() { 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; continue;
} }
let target_dir = match target_skill_dir(&req.scope, working_directory, &skill_name) { let target_dir = match target_skill_dir(&req.scope, working_directory, &skill_name) {
Ok(p) => p, Ok(p) => p,
Err(err) => { Err(err) => {
skipped.push(SkippedSkill { skill_name, reason: err.to_string() }); skipped.push(SkippedSkill {
skill_name,
reason: err.to_string(),
});
continue; continue;
} }
}; };
@@ -1234,7 +1383,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
} }
if exists && decision.as_deref() == Some("skip") { 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; 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 { if let Err(err) = copy_dir_no_symlinks(&src_dir, &target_dir).await {
let _ = tokio::fs::remove_dir_all(&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; continue;
} }
installed.push(InstalledSkill { skill_name, scope: req.scope.clone() }); installed.push(InstalledSkill {
skill_name,
scope: req.scope.clone(),
});
} }
safe_rm(&temp_base).await; safe_rm(&temp_base).await;
+9 -2
View File
@@ -57,7 +57,9 @@ export const createDesktopTerminalAPI = (): TerminalAPI => ({
const startListening = async () => { const startListening = async () => {
try { try {
const unlisten = await safeListen<TerminalStreamEvent>(`terminal://${sessionId}`, (event) => { const unlisten = await safeListen<TerminalStreamEvent>(
`terminal://${sessionId}`,
(event) => {
if (cancelled) { if (cancelled) {
return; return;
} }
@@ -67,7 +69,12 @@ export const createDesktopTerminalAPI = (): TerminalAPI => ({
if (event.payload?.type === 'exit') { if (event.payload?.type === 'exit') {
stopListening(); stopListening();
} }
}); },
{
// Terminal streams are long-lived; never auto-expire this listener.
timeout: 0,
}
);
if (cancelled) { if (cancelled) {
unlisten(); unlisten();
@@ -9,6 +9,7 @@ interface PendingCallback {
type: 'invoke' | 'listen'; type: 'invoke' | 'listen';
cleanup?: () => void; cleanup?: () => void;
timeout?: NodeJS.Timeout; timeout?: NodeJS.Timeout;
timeoutMs?: number;
} }
interface CallbackManagerConfig { interface CallbackManagerConfig {
@@ -51,10 +52,17 @@ class TauriCallbackManager {
this.callbacks.set(callback.id, fullCallback); 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(() => { const timeout = setTimeout(() => {
this.cleanupCallback(callback.id, 'timeout'); this.cleanupCallback(callback.id, 'timeout');
}, this.config.listenTimeout); }, timeoutMs);
fullCallback.timeout = timeout; fullCallback.timeout = timeout;
} }
@@ -119,6 +127,9 @@ class TauriCallbackManager {
const expiredCallbacks: string[] = []; const expiredCallbacks: string[] = [];
this.callbacks.forEach((callback, id) => { this.callbacks.forEach((callback, id) => {
if (callback.type !== 'invoke') {
return;
}
const age = now - callback.timestamp; const age = now - callback.timestamp;
if (age > this.config.maxCallbackAge) { if (age > this.config.maxCallbackAge) {
expiredCallbacks.push(id); expiredCallbacks.push(id);
@@ -266,6 +277,7 @@ export async function safeListen<T>(
id: callbackId, id: callbackId,
type: 'listen', type: 'listen',
cleanup: options?.onCancel, cleanup: options?.onCancel,
timeoutMs: options?.timeout,
}); });
const unlisten = await listen<T>(event, (event) => { const unlisten = await listen<T>(event, (event) => {
+3
View File
@@ -19,6 +19,9 @@ export default defineConfig({
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'), '@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
}, },
}, },
worker: {
format: 'es',
},
define: { define: {
'process.env': {}, 'process.env': {},
'process.platform': JSON.stringify('darwin'), 'process.platform': JSON.stringify('darwin'),
+1 -3
View File
@@ -27,8 +27,7 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0", "@remixicon/react": "^4.7.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@xterm/addon-fit": "^0.10.0", "ghostty-web": "0.3.0",
"@xterm/xterm": "^5.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -37,7 +36,6 @@
"http-proxy-middleware": "^3.0.5", "http-proxy-middleware": "^3.0.5",
"motion": "^12.23.24", "motion": "^12.23.24",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-pty": "^1.0.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.6",
@@ -1,14 +1,51 @@
import React from 'react'; import React from 'react';
import { Terminal } from '@xterm/xterm'; import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import type { TerminalTheme } from '@/lib/terminalTheme'; import type { TerminalTheme } from '@/lib/terminalTheme';
import { getTerminalOptions } from '@/lib/terminalTheme'; import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
import type { TerminalChunk } from '@/stores/useTerminalStore'; import type { TerminalChunk } from '@/stores/useTerminalStore';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
let ghosttyPromise: Promise<Ghostty> | null = null;
function getGhostty(): Promise<Ghostty> {
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<HTMLElement>('*'))];
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 = { type TerminalController = {
focus: () => void; focus: () => void;
clear: () => void; clear: () => void;
@@ -34,25 +71,65 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
) => { ) => {
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const viewportRef = React.useRef<HTMLElement | null>(null); const viewportRef = React.useRef<HTMLElement | null>(null);
const terminalRef = React.useRef<Terminal | null>(null); const terminalRef = React.useRef<GhosttyTerminal | null>(null);
const fitAddonRef = React.useRef<FitAddon | null>(null); const fitAddonRef = React.useRef<FitAddon | null>(null);
const inputHandlerRef = React.useRef<(data: string) => void>(onInput); const inputHandlerRef = React.useRef<(data: string) => void>(onInput);
const resizeHandlerRef = React.useRef<(cols: number, rows: number) => void>(onResize); const resizeHandlerRef = React.useRef<(cols: number, rows: number) => void>(onResize);
const writeQueueRef = React.useRef<string[]>([]); const lastReportedSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const pendingWriteRef = React.useRef('');
const writeScheduledRef = React.useRef<number | null>(null);
const isWritingRef = React.useRef(false); const isWritingRef = React.useRef(false);
const processedCountRef = React.useRef(0); const lastProcessedChunkIdRef = React.useRef<number | null>(null);
const firstChunkIdRef = React.useRef<number | null>(null);
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null); const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
const viewportDiscoveryAttemptsRef = React.useRef(0);
const hiddenInputRef = React.useRef<HTMLTextAreaElement | null>(null);
const [, forceRender] = React.useReducer((x) => x + 1, 0); const [, forceRender] = React.useReducer((x) => x + 1, 0);
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
inputHandlerRef.current = onInput; inputHandlerRef.current = onInput;
resizeHandlerRef.current = onResize; 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(() => { 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; isWritingRef.current = false;
processedCountRef.current = 0; lastProcessedChunkIdRef.current = null;
firstChunkIdRef.current = null;
}, []); }, []);
const fitTerminal = React.useCallback(() => { const fitTerminal = React.useCallback(() => {
@@ -68,61 +145,85 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
} }
try { try {
fitAddon.fit(); fitAddon.fit();
resizeHandlerRef.current(terminal.cols, terminal.rows); const next = { cols: terminal.cols, rows: terminal.rows };
const previous = lastReportedSizeRef.current;
if (!previous || previous.cols !== next.cols || previous.rows !== next.rows) {
lastReportedSizeRef.current = next;
resizeHandlerRef.current(next.cols, next.rows);
}
} catch { /* ignored */ } } catch { /* ignored */ }
}, []); }, []);
const flushWriteQueue = React.useCallback(() => { const flushWrites = React.useCallback(() => {
if (isWritingRef.current) { if (isWritingRef.current) {
return; return;
} }
const consumeNext = () => { const term = terminalRef.current;
const term = terminalRef.current; if (!term) {
if (!term) { resetWriteState();
resetWriteState(); return;
return; }
}
const chunk = writeQueueRef.current.shift(); if (!pendingWriteRef.current) {
if (chunk === undefined) { return;
isWritingRef.current = false; }
return;
}
isWritingRef.current = true; const chunk = pendingWriteRef.current;
term.write(chunk, () => { pendingWriteRef.current = '';
isWritingRef.current = false;
if (writeQueueRef.current.length > 0) { isWritingRef.current = true;
if (typeof window !== 'undefined') { term.write(chunk, () => {
window.setTimeout(consumeNext, 0); isWritingRef.current = false;
} else { if (pendingWriteRef.current) {
consumeNext(); if (typeof window !== 'undefined') {
} writeScheduledRef.current = window.requestAnimationFrame(() => {
writeScheduledRef.current = null;
flushWrites();
});
} else {
flushWrites();
} }
}); }
}; });
consumeNext();
}, [resetWriteState]); }, [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( const enqueueWrite = React.useCallback(
(data: string) => { (data: string) => {
if (!data) { if (!data) {
return; return;
} }
writeQueueRef.current = [data]; pendingWriteRef.current += data;
isWritingRef.current = false; scheduleFlushWrites();
flushWriteQueue();
}, },
[flushWriteQueue] [scheduleFlushWrites]
); );
const setupTouchScroll = React.useCallback(() => { const setupTouchScroll = React.useCallback(() => {
touchScrollCleanupRef.current?.(); touchScrollCleanupRef.current?.();
touchScrollCleanupRef.current = null; touchScrollCleanupRef.current = null;
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
viewportDiscoveryTimeoutRef.current = null;
}
if (!enableTouchScroll) { if (!enableTouchScroll) {
viewportDiscoveryAttemptsRef.current = 0;
return; return;
} }
@@ -131,11 +232,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return; return;
} }
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null; // Ghostty scrollback is internal (canvas-based). On touch devices we need
if (!viewport) { // to translate touch deltas into terminal scroll calls.
const terminal = terminalRef.current;
if (!terminal) {
return; return;
} }
viewportDiscoveryAttemptsRef.current = 0;
const baseScrollMultiplier = 2.2; const baseScrollMultiplier = 2.2;
const maxScrollBoost = 2.8; const maxScrollBoost = 2.8;
const boostDenominator = 25; const boostDenominator = 25;
@@ -149,24 +254,35 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
lastTime: null as number | null, lastTime: null as number | null,
velocity: 0, velocity: 0,
rafId: null as number | null, rafId: null as number | null,
startX: null as number | null,
startY: null as number | null,
didMove: false,
}; };
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()); const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
const getMaxScrollTop = () => Math.max(0, viewport.scrollHeight - viewport.clientHeight); const lineHeightPx = Math.max(12, Math.round(fontSize * 1.35));
let remainderPx = 0;
const setScrollTop = (nextScrollTop: number) => {
const maxScrollTop = getMaxScrollTop();
viewport.scrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop));
};
const scrollByPixels = (deltaPixels: number) => { const scrollByPixels = (deltaPixels: number) => {
if (!deltaPixels) { if (!deltaPixels) {
return; return false;
} }
const previous = viewport.scrollTop;
setScrollTop(previous + deltaPixels); const before = terminal.getViewportY();
return viewport.scrollTop !== previous;
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 = () => { const stopKinetic = () => {
@@ -176,11 +292,18 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.rafId = null; state.rafId = null;
}; };
const listenerOptions: AddEventListenerOptions = { passive: false, capture: true }; const listenerOptions: AddEventListenerOptions = { passive: false, capture: false };
const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window; const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window;
if (supportsPointerEvents) { if (supportsPointerEvents) {
const stateWithPointerId = Object.assign(state, { pointerId: null as number | null }); const stateWithPointerId = Object.assign(state, {
pointerId: null as number | null,
startX: null as number | null,
startY: null as number | null,
moved: false,
});
const TAP_MOVE_THRESHOLD_PX = 6;
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
if (event.pointerType !== 'touch') { if (event.pointerType !== 'touch') {
@@ -188,6 +311,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
} }
stopKinetic(); stopKinetic();
stateWithPointerId.pointerId = event.pointerId; stateWithPointerId.pointerId = event.pointerId;
stateWithPointerId.startX = event.clientX;
stateWithPointerId.startY = event.clientY;
stateWithPointerId.moved = false;
stateWithPointerId.lastY = event.clientY; stateWithPointerId.lastY = event.clientY;
stateWithPointerId.lastTime = nowMs(); stateWithPointerId.lastTime = nowMs();
stateWithPointerId.velocity = 0; stateWithPointerId.velocity = 0;
@@ -201,6 +327,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return; return;
} }
if (stateWithPointerId.startX !== null && stateWithPointerId.startY !== null && !stateWithPointerId.moved) {
const dx = event.clientX - stateWithPointerId.startX;
const dy = event.clientY - stateWithPointerId.startY;
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
stateWithPointerId.moved = true;
}
}
if (stateWithPointerId.lastY === null) { if (stateWithPointerId.lastY === null) {
stateWithPointerId.lastY = event.clientY; stateWithPointerId.lastY = event.clientY;
stateWithPointerId.lastTime = nowMs(); stateWithPointerId.lastTime = nowMs();
@@ -230,10 +364,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
stateWithPointerId.velocity = -maxVelocity; stateWithPointerId.velocity = -maxVelocity;
} }
if (event.cancelable) { // Only prevent default once we're actually scrolling.
event.preventDefault(); if (stateWithPointerId.moved) {
if (event.cancelable) {
event.preventDefault();
}
event.stopPropagation();
} }
event.stopPropagation();
scrollByPixels(deltaPixels); scrollByPixels(deltaPixels);
}; };
@@ -241,13 +379,24 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) { if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) {
return; return;
} }
const wasTap = !stateWithPointerId.moved;
stateWithPointerId.pointerId = null; stateWithPointerId.pointerId = null;
stateWithPointerId.startX = null;
stateWithPointerId.startY = null;
stateWithPointerId.moved = false;
stateWithPointerId.lastY = null; stateWithPointerId.lastY = null;
stateWithPointerId.lastTime = null; stateWithPointerId.lastTime = null;
try { try {
container.releasePointerCapture(event.pointerId); container.releasePointerCapture(event.pointerId);
} catch { /* ignored */ } } catch { /* ignored */ }
if (wasTap) {
focusHiddenInput(event.clientX, event.clientY);
return;
}
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
} }
@@ -287,10 +436,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
container.addEventListener('pointercancel', handlePointerUp, listenerOptions); container.addEventListener('pointercancel', handlePointerUp, listenerOptions);
const previousTouchAction = container.style.touchAction; const previousTouchAction = container.style.touchAction;
container.style.touchAction = 'none'; container.style.touchAction = 'manipulation';
touchScrollCleanupRef.current = () => { touchScrollCleanupRef.current = () => {
stopKinetic(); 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('pointerdown', handlePointerDown, listenerOptions);
container.removeEventListener('pointermove', handlePointerMove, listenerOptions); container.removeEventListener('pointermove', handlePointerMove, listenerOptions);
container.removeEventListener('pointerup', handlePointerUp, listenerOptions); container.removeEventListener('pointerup', handlePointerUp, listenerOptions);
@@ -301,6 +455,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return; return;
} }
const TAP_MOVE_THRESHOLD_PX = 6;
const handleTouchStart = (event: TouchEvent) => { const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length !== 1) { if (event.touches.length !== 1) {
return; return;
@@ -309,6 +465,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.lastY = event.touches[0].clientY; state.lastY = event.touches[0].clientY;
state.lastTime = nowMs(); state.lastTime = nowMs();
state.velocity = 0; state.velocity = 0;
state.startX = event.touches[0].clientX;
state.startY = event.touches[0].clientY;
state.didMove = false;
}; };
const handleTouchMove = (event: TouchEvent) => { const handleTouchMove = (event: TouchEvent) => {
@@ -316,11 +475,24 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.lastY = null; state.lastY = null;
state.lastTime = null; state.lastTime = null;
state.velocity = 0; state.velocity = 0;
state.startX = null;
state.startY = null;
state.didMove = false;
stopKinetic(); stopKinetic();
return; return;
} }
const currentX = event.touches[0].clientX;
const currentY = event.touches[0].clientY; const currentY = event.touches[0].clientY;
if (state.startX !== null && state.startY !== null && !state.didMove) {
const dx = currentX - state.startX;
const dy = currentY - state.startY;
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
state.didMove = true;
}
}
if (state.lastY === null) { if (state.lastY === null) {
state.lastY = currentY; state.lastY = currentY;
state.lastTime = nowMs(); state.lastTime = nowMs();
@@ -350,20 +522,37 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.velocity = -maxVelocity; state.velocity = -maxVelocity;
} }
event.preventDefault(); if (state.didMove) {
event.stopPropagation(); event.preventDefault();
event.stopPropagation();
}
scrollByPixels(deltaPixels); scrollByPixels(deltaPixels);
}; };
const handleTouchEnd = () => { const handleTouchEnd = (event: TouchEvent) => {
const wasTap = !state.didMove;
state.lastY = null; state.lastY = null;
state.lastTime = 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') { if (typeof window === 'undefined') {
return; return;
} }
if (Math.abs(state.velocity) < minVelocity) { if (Math.abs(velocity) < minVelocity) {
state.velocity = 0; state.velocity = 0;
return; return;
} }
@@ -394,77 +583,114 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
container.addEventListener('touchstart', handleTouchStart, listenerOptions); container.addEventListener('touchstart', handleTouchStart, listenerOptions);
container.addEventListener('touchmove', handleTouchMove, listenerOptions); container.addEventListener('touchmove', handleTouchMove, listenerOptions);
container.addEventListener('touchend', handleTouchEnd, listenerOptions); container.addEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
container.addEventListener('touchcancel', handleTouchEnd, listenerOptions); container.addEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
const previousTouchAction = container.style.touchAction; const previousTouchAction = container.style.touchAction;
container.style.touchAction = 'none'; container.style.touchAction = 'manipulation';
touchScrollCleanupRef.current = () => { touchScrollCleanupRef.current = () => {
stopKinetic(); 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('touchstart', handleTouchStart, listenerOptions);
container.removeEventListener('touchmove', handleTouchMove, listenerOptions); container.removeEventListener('touchmove', handleTouchMove, listenerOptions);
container.removeEventListener('touchend', handleTouchEnd, listenerOptions); container.removeEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
container.removeEventListener('touchcancel', handleTouchEnd, listenerOptions); container.removeEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
container.style.touchAction = previousTouchAction; container.style.touchAction = previousTouchAction;
}; };
}, [enableTouchScroll]); }, [enableTouchScroll, focusHiddenInput, fontSize]);
React.useEffect(() => { React.useEffect(() => {
const terminal = new Terminal(getTerminalOptions(fontFamily, fontSize, theme)); let disposed = false;
const fitAddon = new FitAddon(); let localTerminal: GhosttyTerminal | null = null;
let localResizeObserver: ResizeObserver | null = null;
terminalRef.current = terminal; let localDisposables: Array<{ dispose: () => void }> = [];
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
const container = containerRef.current; const container = containerRef.current;
if (container) { 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) {
return; return;
} }
const options = getTerminalOptions(fontFamily, fontSize, theme);
Object.assign(terminal.options as Record<string, unknown>, options); container.tabIndex = 0;
fitTerminal();
}, [fitTerminal, fontFamily, fontSize, theme]); 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(() => { React.useEffect(() => {
const terminal = terminalRef.current; const terminal = terminalRef.current;
@@ -473,9 +699,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
} }
terminal.reset(); terminal.reset();
resetWriteState(); resetWriteState();
lastReportedSizeRef.current = null;
fitTerminal(); fitTerminal();
terminal.focus(); terminal.focus();
}, [sessionKey, fitTerminal, resetWriteState]); }, [sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
React.useEffect(() => { React.useEffect(() => {
setupTouchScroll(); setupTouchScroll();
@@ -492,7 +719,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
} }
if (chunks.length === 0) { if (chunks.length === 0) {
if (processedCountRef.current !== 0) { if (lastProcessedChunkIdRef.current !== null) {
terminal.reset(); terminal.reset();
resetWriteState(); resetWriteState();
fitTerminal(); fitTerminal();
@@ -500,31 +727,31 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return; return;
} }
const currentFirstId = chunks[0].id; const lastProcessedId = lastProcessedChunkIdRef.current;
if (firstChunkIdRef.current === null) { let pending: TerminalChunk[];
firstChunkIdRef.current = currentFirstId;
if (lastProcessedId === null) {
pending = chunks;
} else {
const lastProcessedIndex = chunks.findIndex((chunk) => chunk.id === lastProcessedId);
pending = lastProcessedIndex >= 0 ? chunks.slice(lastProcessedIndex + 1) : chunks;
} }
const shouldReset = if (pending.length > 0) {
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);
enqueueWrite(pending.map((chunk) => chunk.data).join('')); 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( React.useImperativeHandle(
ref, ref,
(): TerminalController => ({ (): TerminalController => ({
focus: () => { focus: () => {
if (enableTouchScroll) {
focusHiddenInput();
return;
}
terminalRef.current?.focus(); terminalRef.current?.focus();
}, },
clear: () => { clear: () => {
@@ -540,12 +767,69 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
fitTerminal(); fitTerminal();
}, },
}), }),
[fitTerminal, resetWriteState] [enableTouchScroll, focusHiddenInput, fitTerminal, resetWriteState]
); );
return ( return (
<div ref={containerRef} className={cn('relative h-full w-full', className)}> <div
{viewportRef.current ? ( ref={containerRef}
className={cn('relative h-full w-full', className)}
style={{ backgroundColor: theme.background }}
onClick={(event) => {
if (enableTouchScroll) {
focusHiddenInput(event.clientX, event.clientY);
} else {
terminalRef.current?.focus();
}
}}
>
{enableTouchScroll ? (
<textarea
ref={hiddenInputRef}
inputMode="text"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
tabIndex={-1}
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: 0,
width: 1,
height: 1,
opacity: 0.001,
zIndex: 1,
background: 'transparent',
color: 'transparent',
border: 'none',
padding: 0,
margin: 0,
outline: 'none',
}}
onInput={(event) => {
const raw = String(event.currentTarget.value || '');
if (!raw) {
return;
}
// iOS often inserts `\n` for Enter; the PTY expects CR.
const value = raw.replace(/\r\n|\r|\n/g, '\r');
inputHandlerRef.current(value);
event.currentTarget.value = '';
}}
onKeyDown={(event) => {
if (event.key === 'Backspace') {
// If there's nothing in the input buffer, emulate DEL.
if (!event.currentTarget.value) {
inputHandlerRef.current('\x7f');
}
}
}}
/>
) : null}
{viewportRef.current && !enableTouchScroll ? (
<OverlayScrollbar <OverlayScrollbar
containerRef={viewportRef} containerRef={viewportRef}
disableHorizontal disableHorizontal
@@ -190,6 +190,11 @@ export const TerminalView: React.FC = () => {
switch (event.type) { switch (event.type) {
case 'connected': { case 'connected': {
if (event.runtime || event.ptyBackend) {
console.log(
`[Terminal] connected runtime=${event.runtime ?? 'unknown'} pty=${event.ptyBackend ?? 'unknown'}`
);
}
setConnecting(directory, false); setConnecting(directory, false);
setConnectionError(null); setConnectionError(null);
setIsFatalError(false); setIsFatalError(false);
+3
View File
@@ -42,6 +42,9 @@ export interface TerminalStreamEvent {
signal?: number | null; signal?: number | null;
attempt?: number; attempt?: number;
maxAttempts?: number; maxAttempts?: number;
runtime?: 'node' | 'bun';
ptyBackend?: string;
} }
export interface CreateTerminalOptions { export interface CreateTerminalOptions {
@@ -0,0 +1,497 @@
/**
* SerializeAddon for ghostty-web
*
* Port of xterm.js addon-serialize for ghostty-web terminal.
* Enables serialization of terminal contents to restore state after reconnection.
*
* Features:
* - ANSI color preservation (16-color, 256-color, RGB)
* - Text attributes (bold, italic, underline, faint, strikethrough, blink, inverse, invisible, dim)
* - Scrollback support with configurable limits
* - Round-trip compatibility
* - Cursor positioning
*/
import type { Terminal as GhosttyTerminal } from 'ghostty-web';
// Constants for ANSI escape codes
const C0 = {
ESC: '\u001b',
};
const SGR = {
RESET: 0,
BOLD: 1,
DIM: 2,
ITALIC: 3,
UNDERLINE: 4,
SLOW_BLINK: 5,
RAPID_BLINK: 6,
INVERSE: 7,
INVISIBLE: 8,
STRIKETHROUGH: 9,
NORMAL_INTENSITY: 22,
NO_ITALIC: 23,
NO_UNDERLINE: 24,
NO_BLINK: 25,
NO_INVERSE: 27,
VISIBLE: 28,
NO_STRIKETHROUGH: 29,
FG_DEFAULT: 39,
BG_DEFAULT: 49,
};
export interface SerializeOptions {
/**
* The row range to serialize. When an explicit range is specified, the cursor
* will get its final repositioning.
*/
range?: {
start: number;
end: number;
};
/**
* The number of rows in the scrollback buffer to serialize, starting from
* the bottom of the scrollback buffer. When not specified, all available
* rows in the scrollback buffer will be serialized.
*/
scrollback?: number;
/**
* Whether to exclude the terminal modes from the serialization.
* Default: false
*/
excludeModes?: boolean;
/**
* Whether to exclude the alt buffer from the serialization.
* Default: false
*/
excludeAltBuffer?: boolean;
}
export interface TextSerializeOptions {
/**
* The number of rows in the scrollback buffer to serialize, starting from
* the bottom of the scrollback buffer.
*/
scrollback?: number;
/**
* Whether to trim trailing whitespace from lines.
* Default: true
*/
trimWhitespace?: boolean;
}
interface CellState {
fg: number | null;
bg: number | null;
bold: boolean;
dim: boolean;
italic: boolean;
underline: boolean;
blink: boolean;
inverse: boolean;
invisible: boolean;
strikethrough: boolean;
}
const NULL_CELL_STATE: CellState = {
fg: null,
bg: null,
bold: false,
dim: false,
italic: false,
underline: false,
blink: false,
inverse: false,
invisible: false,
strikethrough: false,
};
/**
* SerializeAddon for ghostty-web terminal
*/
export class SerializeAddon {
private _terminal: GhosttyTerminal | undefined;
/**
* Activate the addon
*/
activate(terminal: GhosttyTerminal): void {
this._terminal = terminal;
}
/**
* Dispose the addon
*/
dispose(): void {
this._terminal = undefined;
}
/**
* Serialize the terminal buffer to ANSI escape sequences
*/
serialize(options: SerializeOptions = {}): string {
if (!this._terminal) {
throw new Error('SerializeAddon not activated');
}
const buffer = this._terminal.buffer.active;
if (!buffer) {
return '';
}
const result: string[] = [];
let currentState: CellState = { ...NULL_CELL_STATE };
// Determine range to serialize
const scrollbackLimit = options.scrollback ?? buffer.length;
let startRow: number;
let endRow: number;
if (options.range) {
startRow = options.range.start;
endRow = options.range.end;
} else {
// Serialize scrollback + viewport
const totalRows = buffer.length;
const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY);
startRow = Math.max(0, buffer.baseY - scrollbackRows);
endRow = buffer.baseY + buffer.cursorY;
}
// Clamp to valid range
startRow = Math.max(0, startRow);
endRow = Math.min(buffer.length - 1, endRow);
for (let y = startRow; y <= endRow; y++) {
const line = buffer.getLine(y);
if (!line) {
result.push('\r\n');
continue;
}
let lineContent = '';
let lastNonSpaceCol = -1;
// Find the last non-space column
for (let x = line.length - 1; x >= 0; x--) {
const cell = line.getCell(x);
if (cell) {
const char = this._getCellChar(cell);
if (char !== ' ' && char !== '') {
lastNonSpaceCol = x;
break;
}
}
}
// Serialize each cell up to the last non-space
for (let x = 0; x <= lastNonSpaceCol; x++) {
const cell = line.getCell(x);
if (!cell) {
lineContent += ' ';
continue;
}
// Get cell attributes and generate SGR sequences if needed
const newState = this._getCellState(cell);
const sgrSequences = this._generateSgrDiff(currentState, newState);
if (sgrSequences) {
lineContent += sgrSequences;
currentState = newState;
}
// Get character
const char = this._getCellChar(cell);
lineContent += char || ' ';
}
// Reset attributes at end of line if any were set
if (this._hasAttributes(currentState)) {
lineContent += `${C0.ESC}[${SGR.RESET}m`;
currentState = { ...NULL_CELL_STATE };
}
result.push(lineContent);
// Add newline unless it's the last row with cursor
if (y < endRow) {
result.push('\r\n');
}
}
// Position cursor
const cursorY = buffer.cursorY;
const cursorX = buffer.cursorX;
if (cursorY >= 0 && cursorX >= 0) {
// Use CUP (Cursor Position) to move cursor to correct position
// CUP is 1-based, so add 1 to both coordinates
const relativeY = cursorY - (endRow - buffer.baseY);
if (relativeY !== 0 || cursorX !== 0) {
result.push(`${C0.ESC}[${cursorY + 1};${cursorX + 1}H`);
}
}
return result.join('');
}
/**
* Serialize the terminal buffer to plain text (no escape sequences)
*/
serializeAsText(options: TextSerializeOptions = {}): string {
if (!this._terminal) {
throw new Error('SerializeAddon not activated');
}
const buffer = this._terminal.buffer.active;
if (!buffer) {
return '';
}
const trimWhitespace = options.trimWhitespace ?? true;
const scrollbackLimit = options.scrollback ?? buffer.length;
const result: string[] = [];
// Determine range
const totalRows = buffer.length;
const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY);
const startRow = Math.max(0, buffer.baseY - scrollbackRows);
const endRow = buffer.baseY + buffer.cursorY;
for (let y = startRow; y <= endRow; y++) {
const line = buffer.getLine(y);
if (!line) {
result.push('');
continue;
}
let lineContent = '';
for (let x = 0; x < line.length; x++) {
const cell = line.getCell(x);
if (cell) {
const char = this._getCellChar(cell);
lineContent += char || ' ';
} else {
lineContent += ' ';
}
}
if (trimWhitespace) {
lineContent = lineContent.trimEnd();
}
result.push(lineContent);
}
return result.join('\n');
}
/**
* Get the character from a cell, handling wide characters and special codepoints
*/
private _getCellChar(cell: { getChars?: () => string; getCodepoint?: () => number }): string {
// Try getChars() first (ghostty-web standard)
if (typeof cell.getChars === 'function') {
const chars = cell.getChars();
if (chars) return chars;
}
// Try getCodepoint()
if (typeof cell.getCodepoint === 'function') {
const codepoint = cell.getCodepoint();
if (codepoint && codepoint > 0 && codepoint <= 0x10FFFF &&
!(codepoint >= 0xD800 && codepoint <= 0xDFFF)) {
return String.fromCodePoint(codepoint);
}
}
// Fallback
return ' ';
}
/**
* Get the state of a cell (colors and attributes)
*/
private _getCellState(cell: {
getFgColor?: () => number;
getBgColor?: () => number;
isBold?: () => boolean | number;
isDim?: () => boolean | number;
isFaint?: () => boolean | number;
isItalic?: () => boolean | number;
isUnderline?: () => boolean | number;
isBlink?: () => boolean | number;
isInverse?: () => boolean | number;
isInvisible?: () => boolean | number;
isStrikethrough?: () => boolean | number;
}): CellState {
const state: CellState = { ...NULL_CELL_STATE };
// Get foreground color
if (typeof cell.getFgColor === 'function') {
const fg = cell.getFgColor();
if (fg !== undefined && fg !== null && fg !== -1) {
state.fg = fg;
}
}
// Get background color
if (typeof cell.getBgColor === 'function') {
const bg = cell.getBgColor();
if (bg !== undefined && bg !== null && bg !== -1) {
state.bg = bg;
}
}
// Get attributes
if (typeof cell.isBold === 'function') {
state.bold = !!cell.isBold();
}
if (typeof cell.isDim === 'function') {
state.dim = !!cell.isDim();
} else if (typeof cell.isFaint === 'function') {
state.dim = !!cell.isFaint();
}
if (typeof cell.isItalic === 'function') {
state.italic = !!cell.isItalic();
}
if (typeof cell.isUnderline === 'function') {
state.underline = !!cell.isUnderline();
}
if (typeof cell.isBlink === 'function') {
state.blink = !!cell.isBlink();
}
if (typeof cell.isInverse === 'function') {
state.inverse = !!cell.isInverse();
}
if (typeof cell.isInvisible === 'function') {
state.invisible = !!cell.isInvisible();
}
if (typeof cell.isStrikethrough === 'function') {
state.strikethrough = !!cell.isStrikethrough();
}
return state;
}
/**
* Generate SGR escape sequences for the difference between two cell states
*/
private _generateSgrDiff(from: CellState, to: CellState): string | null {
const codes: number[] = [];
// Check if we need a full reset
const needsReset =
(from.bold && !to.bold) ||
(from.dim && !to.dim) ||
(from.italic && !to.italic) ||
(from.underline && !to.underline) ||
(from.blink && !to.blink) ||
(from.inverse && !to.inverse) ||
(from.invisible && !to.invisible) ||
(from.strikethrough && !to.strikethrough);
if (needsReset) {
codes.push(SGR.RESET);
// After reset, we need to re-apply all 'to' attributes
if (to.bold) codes.push(SGR.BOLD);
if (to.dim) codes.push(SGR.DIM);
if (to.italic) codes.push(SGR.ITALIC);
if (to.underline) codes.push(SGR.UNDERLINE);
if (to.blink) codes.push(SGR.SLOW_BLINK);
if (to.inverse) codes.push(SGR.INVERSE);
if (to.invisible) codes.push(SGR.INVISIBLE);
if (to.strikethrough) codes.push(SGR.STRIKETHROUGH);
// Re-apply colors
if (to.fg !== null) {
this._appendColorCode(codes, to.fg, true);
}
if (to.bg !== null) {
this._appendColorCode(codes, to.bg, false);
}
} else {
// Apply only changed attributes
if (!from.bold && to.bold) codes.push(SGR.BOLD);
if (!from.dim && to.dim) codes.push(SGR.DIM);
if (!from.italic && to.italic) codes.push(SGR.ITALIC);
if (!from.underline && to.underline) codes.push(SGR.UNDERLINE);
if (!from.blink && to.blink) codes.push(SGR.SLOW_BLINK);
if (!from.inverse && to.inverse) codes.push(SGR.INVERSE);
if (!from.invisible && to.invisible) codes.push(SGR.INVISIBLE);
if (!from.strikethrough && to.strikethrough) codes.push(SGR.STRIKETHROUGH);
// Handle color changes
if (from.fg !== to.fg) {
if (to.fg === null) {
codes.push(SGR.FG_DEFAULT);
} else {
this._appendColorCode(codes, to.fg, true);
}
}
if (from.bg !== to.bg) {
if (to.bg === null) {
codes.push(SGR.BG_DEFAULT);
} else {
this._appendColorCode(codes, to.bg, false);
}
}
}
if (codes.length === 0) {
return null;
}
return `${C0.ESC}[${codes.join(';')}m`;
}
/**
* Append color code to the codes array
*/
private _appendColorCode(codes: number[], color: number, isForeground: boolean): void {
const base = isForeground ? 30 : 40;
const extBase = isForeground ? 38 : 48;
if (color < 8) {
// Basic 8 colors
codes.push(base + color);
} else if (color < 16) {
// Bright 8 colors
codes.push(base + 60 + (color - 8));
} else if (color < 256) {
// 256-color palette
codes.push(extBase, 5, color);
} else {
// RGB (24-bit) color encoded as 0xRRGGBB + 0x1000000
const rgb = color - 0x1000000;
const r = (rgb >> 16) & 0xFF;
const g = (rgb >> 8) & 0xFF;
const b = rgb & 0xFF;
codes.push(extBase, 2, r, g, b);
}
}
/**
* Check if the state has any attributes set
*/
private _hasAttributes(state: CellState): boolean {
return (
state.fg !== null ||
state.bg !== null ||
state.bold ||
state.dim ||
state.italic ||
state.underline ||
state.blink ||
state.inverse ||
state.invisible ||
state.strikethrough
);
}
}
+50 -1
View File
@@ -1,3 +1,4 @@
import type { Ghostty } from 'ghostty-web';
import type { Theme } from '@/types/theme'; import type { Theme } from '@/types/theme';
export interface TerminalTheme { export interface TerminalTheme {
@@ -79,7 +80,7 @@ export function getTerminalOptions(
cursorStyle: 'block' as const, cursorStyle: 'block' as const,
theme, theme,
allowTransparency: false, allowTransparency: false,
scrollback: 10000, scrollback: 50_000,
minimumContrastRatio: 1, minimumContrastRatio: 1,
fastScrollModifier: 'shift' as const, fastScrollModifier: 'shift' as const,
fastScrollSensitivity: 5, fastScrollSensitivity: 5,
@@ -89,3 +90,51 @@ export function getTerminalOptions(
rightClickSelectsWord: true, rightClickSelectsWord: true,
}; };
} }
/**
* Get terminal options for Ghostty Web terminal
*/
export function getGhosttyTerminalOptions(
fontFamily: string,
fontSize: number,
theme: TerminalTheme,
ghostty: Ghostty
) {
const powerlineFallbacks =
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
return {
cursorBlink: true,
fontSize,
lineHeight: 1.15,
fontFamily: augmentedFontFamily,
allowTransparency: false,
theme: {
background: theme.background,
foreground: theme.foreground,
cursor: theme.cursor,
cursorAccent: theme.cursorAccent,
selectionBackground: theme.selectionBackground,
selectionForeground: theme.selectionForeground,
black: theme.black,
red: theme.red,
green: theme.green,
yellow: theme.yellow,
blue: theme.blue,
magenta: theme.magenta,
cyan: theme.cyan,
white: theme.white,
brightBlack: theme.brightBlack,
brightRed: theme.brightRed,
brightGreen: theme.brightGreen,
brightYellow: theme.brightYellow,
brightBlue: theme.brightBlue,
brightMagenta: theme.brightMagenta,
brightCyan: theme.brightCyan,
brightWhite: theme.brightWhite,
},
scrollback: 50_000,
ghostty,
};
}
+1 -1
View File
@@ -30,7 +30,7 @@ interface TerminalStore {
clearAllTerminalSessions: () => void; clearAllTerminalSessions: () => void;
} }
const TERMINAL_BUFFER_LIMIT = 256_000; const TERMINAL_BUFFER_LIMIT = 1_000_000;
function normalizeDirectory(dir: string): string { function normalizeDirectory(dir: string): string {
let normalized = dir.trim(); let normalized = dir.trim();
+11
View File
@@ -0,0 +1,11 @@
export {};
declare module 'ghostty-web' {
export interface ITerminalOptions {
lineHeight?: number;
}
export interface RendererOptions {
lineHeight?: number;
}
}
+3
View File
@@ -17,6 +17,9 @@ export default defineConfig({
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'), '@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
}, },
}, },
worker: {
format: 'es',
},
define: { define: {
'process.env.NODE_ENV': JSON.stringify('production'), 'process.env.NODE_ENV': JSON.stringify('production'),
'global': 'globalThis', 'global': 'globalThis',
+55 -2
View File
@@ -11,6 +11,35 @@ const __dirname = path.dirname(__filename);
const DEFAULT_PORT = 3000; const DEFAULT_PORT = 3000;
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
function getBunBinary() {
if (typeof process.env.BUN_BINARY === 'string' && process.env.BUN_BINARY.trim().length > 0) {
return process.env.BUN_BINARY.trim();
}
if (typeof process.env.BUN_INSTALL === 'string' && process.env.BUN_INSTALL.trim().length > 0) {
return path.join(process.env.BUN_INSTALL.trim(), 'bin', 'bun');
}
return 'bun';
}
const BUN_BIN = getBunBinary();
function isBunRuntime() {
return typeof globalThis.Bun !== 'undefined';
}
function isBunInstalled() {
try {
const result = spawnSync(BUN_BIN, ['--version'], { stdio: 'ignore', env: process.env });
return result.status === 0;
} catch {
return false;
}
}
function getPreferredServerRuntime() {
return isBunInstalled() ? 'bun' : 'node';
}
function generateRandomPassword(length = 16) { function generateRandomPassword(length = 16) {
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
let password = ''; let password = '';
@@ -384,9 +413,12 @@ const commands = {
serverArgs.push('--try-cf-tunnel'); serverArgs.push('--try-cf-tunnel');
} }
const preferredRuntime = getPreferredServerRuntime();
const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : process.execPath;
if (options.daemon) { if (options.daemon) {
const child = spawn(process.execPath, serverArgs, { const child = spawn(runtimeBin, serverArgs, {
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
env: { env: {
@@ -430,8 +462,29 @@ const commands = {
writeInstanceOptions(instanceFilePath, { ...options, uiPassword: effectiveUiPassword }); writeInstanceOptions(instanceFilePath, { ...options, uiPassword: effectiveUiPassword });
// Prefer bun when installed (much faster PTY). If CLI is running under Node,
// run the server in a child process so Node doesn't have to load bun-pty.
if (preferredRuntime === 'bun' && !isBunRuntime()) {
const child = spawn(runtimeBin, serverArgs, {
stdio: 'inherit',
env: {
...process.env,
OPENCHAMBER_PORT: options.port.toString(),
OPENCODE_BINARY: opencodeBinary,
...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}),
OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false',
},
});
child.on('exit', (code) => {
process.exit(typeof code === 'number' ? code : 1);
});
return;
}
const { startWebUiServer } = await import(serverPath); const { startWebUiServer } = await import(serverPath);
const server = await startWebUiServer({ await startWebUiServer({
port: options.port, port: options.port,
attachSignals: true, attachSignals: true,
exitOnShutdown: true, exitOnShutdown: true,
+3 -3
View File
@@ -37,15 +37,15 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0", "@remixicon/react": "^4.7.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@xterm/addon-fit": "^0.10.0", "ghostty-web": "0.3.0",
"@xterm/xterm": "^5.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"express": "^5.1.0", "express": "^5.1.0",
"http-proxy-middleware": "^3.0.5", "http-proxy-middleware": "^3.0.5",
"next-themes": "^0.4.6", "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": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
+41 -21
View File
@@ -3692,23 +3692,39 @@ async function main(options = {}) {
} }
}); });
let ptyLib = null; let ptyProviderPromise = null;
let ptyLoadError = null; const getPtyProvider = async () => {
const getPtyLib = async () => { if (ptyProviderPromise) {
if (ptyLib) return ptyLib; return ptyProviderPromise;
if (ptyLoadError) throw ptyLoadError;
try {
ptyLib = await import('node-pty');
console.log('node-pty loaded successfully');
return ptyLib;
} catch (error) {
ptyLoadError = error;
console.error('Failed to load node-pty:', error.message);
console.error('Terminal functionality will not be available.');
console.error('To fix: run "npm rebuild node-pty" or "npm install"');
throw new Error('node-pty is not available. Run: npm rebuild node-pty');
} }
ptyProviderPromise = (async () => {
const isBunRuntime = typeof globalThis.Bun !== 'undefined';
if (isBunRuntime) {
try {
const bunPty = await import('bun-pty');
console.log('Using bun-pty for terminal sessions');
return { spawn: bunPty.spawn, backend: 'bun-pty' };
} catch (error) {
console.warn('bun-pty unavailable, falling back to node-pty');
}
}
try {
const nodePty = await import('node-pty');
console.log('Using node-pty for terminal sessions');
return { spawn: nodePty.spawn, backend: 'node-pty' };
} catch (error) {
console.error('Failed to load node-pty:', error && error.message ? error.message : error);
if (isBunRuntime) {
throw new Error('No PTY backend available. Install bun-pty or node-pty.');
}
throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)');
}
})();
return ptyProviderPromise;
}; };
const terminalSessions = new Map(); const terminalSessions = new Map();
@@ -3745,7 +3761,6 @@ async function main(options = {}) {
return res.status(400).json({ error: 'Invalid working directory' }); return res.status(400).json({ error: 'Invalid working directory' });
} }
const pty = await getPtyLib();
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh'); const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
const sessionId = Math.random().toString(36).substring(2, 15) + const sessionId = Math.random().toString(36).substring(2, 15) +
@@ -3754,6 +3769,7 @@ async function main(options = {}) {
const envPath = buildAugmentedPath(); const envPath = buildAugmentedPath();
const resolvedEnv = { ...process.env, PATH: envPath }; const resolvedEnv = { ...process.env, PATH: envPath };
const pty = await getPtyProvider();
const ptyProcess = pty.spawn(shell, [], { const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color', name: 'xterm-256color',
cols: cols || 80, cols: cols || 80,
@@ -3768,6 +3784,7 @@ async function main(options = {}) {
const session = { const session = {
ptyProcess, ptyProcess,
ptyBackend: pty.backend,
cwd, cwd,
lastActivity: Date.now(), lastActivity: Date.now(),
clients: new Set(), clients: new Set(),
@@ -3801,12 +3818,14 @@ async function main(options = {}) {
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('X-Accel-Buffering', 'no');
res.write('data: {"type":"connected"}\n\n');
const clientId = Math.random().toString(36).substring(7); const clientId = Math.random().toString(36).substring(7);
session.clients.add(clientId); session.clients.add(clientId);
session.lastActivity = Date.now(); session.lastActivity = Date.now();
const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
const ptyBackend = session.ptyBackend || 'unknown';
res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`);
const heartbeatInterval = setInterval(() => { const heartbeatInterval = setInterval(() => {
try { try {
@@ -3871,7 +3890,7 @@ async function main(options = {}) {
req.on('close', cleanup); req.on('close', cleanup);
req.on('error', cleanup); req.on('error', cleanup);
console.log(`Client ${clientId} connected to terminal session ${sessionId}`); console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`);
}); });
app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => { app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
@@ -3958,7 +3977,6 @@ async function main(options = {}) {
return res.status(400).json({ error: 'Invalid working directory' }); return res.status(400).json({ error: 'Invalid working directory' });
} }
const pty = await getPtyLib();
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh'); const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
const newSessionId = Math.random().toString(36).substring(2, 15) + const newSessionId = Math.random().toString(36).substring(2, 15) +
@@ -3967,6 +3985,7 @@ async function main(options = {}) {
const envPath = buildAugmentedPath(); const envPath = buildAugmentedPath();
const resolvedEnv = { ...process.env, PATH: envPath }; const resolvedEnv = { ...process.env, PATH: envPath };
const pty = await getPtyProvider();
const ptyProcess = pty.spawn(shell, [], { const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color', name: 'xterm-256color',
cols: cols || 80, cols: cols || 80,
@@ -3981,6 +4000,7 @@ async function main(options = {}) {
const session = { const session = {
ptyProcess, ptyProcess,
ptyBackend: pty.backend,
cwd, cwd,
lastActivity: Date.now(), lastActivity: Date.now(),
clients: new Set(), clients: new Set(),
+3
View File
@@ -22,6 +22,9 @@ export default defineConfig({
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'), '@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
}, },
}, },
worker: {
format: 'es',
},
define: { define: {
'process.env': {}, 'process.env': {},
global: 'globalThis', global: 'globalThis',
+33
View File
@@ -0,0 +1,33 @@
diff --git a/node_modules/ghostty-web/dist/ghostty-web.js b/node_modules/ghostty-web/dist/ghostty-web.js
index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 100644
--- a/node_modules/ghostty-web/dist/ghostty-web.js
+++ b/node_modules/ghostty-web/dist/ghostty-web.js
@@ -1285,1 +1285,1 @@
- C === 0 || C < 32 ? B.push(" ") : B.push(String.fromCodePoint(C));
+ C === 0 || C < 32 || C > 1114111 || (C >= 55296 && C <= 57343) ? B.push(" ") : B.push(String.fromCodePoint(C));
@@ -1328,1 +1328,1 @@
- this.ctx = Q, this.fontSize = B.fontSize ?? 15, this.fontFamily = B.fontFamily ?? "monospace", this.cursorStyle = B.cursorStyle ?? "block", this.cursorBlink = B.cursorBlink ?? !1, this.theme = { ...r, ...B.theme }, this.devicePixelRatio = B.devicePixelRatio ?? window.devicePixelRatio ?? 1, this.palette = [
+ this.ctx = Q, this.fontSize = B.fontSize ?? 15, this.fontFamily = B.fontFamily ?? "monospace", this.lineHeight = B.lineHeight ?? 1, this.cursorStyle = B.cursorStyle ?? "block", this.cursorBlink = B.cursorBlink ?? !1, this.theme = { ...r, ...B.theme }, this.devicePixelRatio = B.devicePixelRatio ?? window.devicePixelRatio ?? 1, this.palette = [
@@ -1353,1 +1353,1 @@
- const Q = B.measureText("M"), g = Math.ceil(Q.width), C = Q.actualBoundingBoxAscent || this.fontSize * 0.8, I = Q.actualBoundingBoxDescent || this.fontSize * 0.2, w = Math.ceil(C + I) + 2, o = Math.ceil(C) + 1;
+ const Q = B.measureText("M"), g = Math.ceil(Q.width), C = Q.actualBoundingBoxAscent || this.fontSize * 0.8, I = Q.actualBoundingBoxDescent || this.fontSize * 0.2, w0 = Math.ceil(C + I), extra = Math.max(0, Math.round((this.lineHeight - 1) * w0)), w = w0 + 2 + extra, o = Math.ceil(C) + 1;
@@ -1484,1 +1484,1 @@
- const s = g, F = C + this.metrics.baseline, a = String.fromCodePoint(A.codepoint || 32);
+ const s = g, F = C + this.metrics.baseline, a = (A.codepoint === 0 || A.codepoint == null || A.codepoint < 0 || A.codepoint > 1114111 || (A.codepoint >= 55296 && A.codepoint <= 57343)) ? " " : String.fromCodePoint(A.codepoint);
@@ -1730,1 +1730,1 @@
- if (s && s.codepoint !== 0) {
+ if (s && s.codepoint !== 0 && s.codepoint <= 1114111 && !(s.codepoint >= 55296 && s.codepoint <= 57343)) {
@@ -1995,1 +1995,1 @@
- if (!w || w.codepoint === 0)
+ if (!w || w.codepoint === 0 || w.codepoint > 1114111 || (w.codepoint >= 55296 && w.codepoint <= 57343))
@@ -2147,4 +2147,5 @@
scrollback: A.scrollback ?? 1e3,
fontSize: A.fontSize ?? 15,
fontFamily: A.fontFamily ?? "monospace",
+ lineHeight: A.lineHeight ?? 1,
allowTransparency: A.allowTransparency ?? !1,
@@ -2267,3 +2267,4 @@
fontSize: this.options.fontSize,
fontFamily: this.options.fontFamily,
+ lineHeight: this.options.lineHeight,
cursorStyle: this.options.cursorStyle,
+3
View File
@@ -17,6 +17,9 @@ export default defineConfig({
'@opencode-ai/sdk': path.resolve(__dirname, './node_modules/@opencode-ai/sdk/dist/client.js'), '@opencode-ai/sdk': path.resolve(__dirname, './node_modules/@opencode-ai/sdk/dist/client.js'),
}, },
}, },
worker: {
format: 'es',
},
define: { define: {
'process.env': {}, 'process.env': {},
global: 'globalThis', global: 'globalThis',