diff --git a/.gitignore b/.gitignore index 168ad8325..8f1d76ace 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ private/ .replit replit.nix .quartz/ +quartz/cli/tui/dist/ diff --git a/package-lock.json b/package-lock.json index 9140a54a6..8ce3bb378 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@floating-ui/dom": "^1.7.4", "@myriaddreamin/rehype-typst": "^0.6.0", "@napi-rs/simple-git": "0.1.22", + "@opentui/core": "^0.1.80", + "@opentui/react": "^0.1.80", "ansi-truncate": "^1.4.0", "async-mutex": "^0.5.0", "chokidar": "^5.0.0", @@ -37,6 +39,7 @@ "preact-render-to-string": "^6.6.5", "pretty-bytes": "^7.1.0", "pretty-time": "^1.1.0", + "react": "^19.2.4", "reading-time": "^1.5.0", "rehype-autolink-headings": "^7.1.0", "rehype-citation": "^2.3.1", @@ -76,6 +79,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^25.0.10", "@types/pretty-time": "^1.1.5", + "@types/react": "^19.2.14", "@types/source-map-support": "^0.5.10", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", @@ -195,6 +199,13 @@ "sisteransi": "^1.0.5" } }, + "node_modules/@dimforge/rapier2d-simd-compat": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@dimforge/rapier2d-simd-compat/-/rapier2d-simd-compat-0.17.3.tgz", + "integrity": "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -1120,6 +1131,418 @@ "node": "20 || >=22" } }, + "node_modules/@jimp/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.0.tgz", + "integrity": "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w==", + "license": "MIT", + "dependencies": { + "@jimp/file-ops": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "await-to-js": "^3.0.0", + "exif-parser": "^0.1.12", + "file-type": "^16.0.0", + "mime": "3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/diff": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.0.tgz", + "integrity": "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw==", + "license": "MIT", + "dependencies": { + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "pixelmatch": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/file-ops": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.0.tgz", + "integrity": "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-bmp": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.0.tgz", + "integrity": "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "bmp-ts": "^1.0.9" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-gif": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.0.tgz", + "integrity": "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "gifwrap": "^0.10.1", + "omggif": "^1.0.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-jpeg": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.0.tgz", + "integrity": "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "jpeg-js": "^0.4.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-png": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.0.tgz", + "integrity": "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "pngjs": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-tiff": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.0.tgz", + "integrity": "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "utif2": "^4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.0.tgz", + "integrity": "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.0.tgz", + "integrity": "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/utils": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.0.tgz", + "integrity": "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.0.tgz", + "integrity": "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "tinycolor2": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.0.tgz", + "integrity": "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.0.tgz", + "integrity": "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.0.tgz", + "integrity": "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.0.tgz", + "integrity": "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.0.tgz", + "integrity": "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.0.tgz", + "integrity": "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.0.tgz", + "integrity": "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-hash": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.0.tgz", + "integrity": "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/js-bmp": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/js-tiff": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "any-base": "^1.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.0.tgz", + "integrity": "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.0.tgz", + "integrity": "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/types": "1.6.0", + "parse-bmfont-ascii": "^1.0.6", + "parse-bmfont-binary": "^1.0.6", + "parse-bmfont-xml": "^1.1.6", + "simple-xml-to-json": "^1.2.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-quantize": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.0.tgz", + "integrity": "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz", + "integrity": "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.0.tgz", + "integrity": "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.0.tgz", + "integrity": "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-hash": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/types": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.0.tgz", + "integrity": "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@myriaddreamin/rehype-typst": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@myriaddreamin/rehype-typst/-/rehype-typst-0.6.0.tgz", @@ -1629,6 +2052,127 @@ "node": ">= 8" } }, + "node_modules/@opentui/core": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@opentui/core/-/core-0.1.80.tgz", + "integrity": "sha512-fkgk8XiO1pauTrWzh3uRL4JNYGPINegv/JoTa1POygV9Rdwu0i4KseicwtDtvwEp6q7p+OdspmNqi6/DqPYqEQ==", + "license": "MIT", + "dependencies": { + "bun-ffi-structs": "0.1.2", + "diff": "8.0.2", + "jimp": "1.6.0", + "marked": "17.0.1", + "yoga-layout": "3.2.1" + }, + "optionalDependencies": { + "@dimforge/rapier2d-simd-compat": "^0.17.3", + "@opentui/core-darwin-arm64": "0.1.80", + "@opentui/core-darwin-x64": "0.1.80", + "@opentui/core-linux-arm64": "0.1.80", + "@opentui/core-linux-x64": "0.1.80", + "@opentui/core-win32-arm64": "0.1.80", + "@opentui/core-win32-x64": "0.1.80", + "bun-webgpu": "0.1.5", + "planck": "^1.4.2", + "three": "0.177.0" + }, + "peerDependencies": { + "web-tree-sitter": "0.25.10" + } + }, + "node_modules/@opentui/core-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-arm64/-/core-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-B1KXimgNzwXY9KS/fZqAnUOj1TSmMmfmm6BJKZwXM65S2hYyuHeZ/E7qN6e4Wqr3dVhzMxH06cR92qf30Qs2QA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opentui/core-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-x64/-/core-darwin-x64-0.1.80.tgz", + "integrity": "sha512-gthbIutmy4VmeOiMacUVTVkB0N/Uc4bLU1Yy2hM23RoJBmfiMgDuf+tpCTYKaJpg4UmWHGP4Q11CNSGCNeNW4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opentui/core-linux-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64/-/core-linux-arm64-0.1.80.tgz", + "integrity": "sha512-tfGq8BDF144CKKAZP97kXmZYBOKCz2Q1XDTpzz6doxHUy5/4pLc0TnJEOZj/Vhzq37wBoG6TGRrCXgHvDAps1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64/-/core-linux-x64-0.1.80.tgz", + "integrity": "sha512-IrlHbzSUy/p6GWelToOkk/PG5qh7haLL+dr/vL83pdSGfh+eJIBHta+EjycvBGr5SxVzCVJFmXUi4QZ47S+o2w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-win32-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-arm64/-/core-win32-arm64-0.1.80.tgz", + "integrity": "sha512-ZgXAClJSc7GgaTftWcmyO5psMYsO+S8XHnxwGm0rXhs/bAz52kH46wKO8wfPAfqUsIYpe1UGgaAejv104w7yEQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opentui/core-win32-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-x64/-/core-win32-x64-0.1.80.tgz", + "integrity": "sha512-8qGyKHfiU/VOYhfER7YWuIQDb2XBfpiyMkUjdwXYPYIPJ0A0vcAulg9CPNrRb+hzzAthH5nmX8gnu8yPsE/n4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opentui/react": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@opentui/react/-/react-0.1.80.tgz", + "integrity": "sha512-l5VrdnP/l9P7TqWMxpPZsnkiHEW6f2Nblt6a7+h2m09P3+dFJq4TZ3KdMwvsecQ5dc8lN8WZqV/T4mvvzhc8yw==", + "license": "MIT", + "dependencies": { + "@opentui/core": "0.1.80", + "react-reconciler": "^0.32.0" + }, + "peerDependencies": { + "react": ">=19.0.0", + "react-devtools-core": "^7.0.1", + "ws": "^8.18.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.4", "hasInstallScript": true, @@ -2055,6 +2599,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "license": "MIT", @@ -2132,6 +2682,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/source-map-support": { "version": "0.5.10", "dev": true, @@ -2169,6 +2729,13 @@ "version": "1.2.0", "license": "ISC" }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/@xmldom/xmldom": { "version": "0.9.8", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.8.tgz", @@ -2226,6 +2793,12 @@ "fast-string-truncated-width": "^3.0.1" } }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "license": "Python-2.0" @@ -2268,6 +2841,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-to-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz", + "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bail": { "version": "2.0.2", "license": "MIT", @@ -2298,6 +2880,12 @@ ], "license": "MIT" }, + "node_modules/bmp-ts": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz", + "integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==", + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "license": "MIT", @@ -2336,6 +2924,83 @@ "version": "1.1.2", "license": "MIT" }, + "node_modules/bun-ffi-structs": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/bun-ffi-structs/-/bun-ffi-structs-0.1.2.tgz", + "integrity": "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w==", + "license": "MIT", + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/bun-webgpu": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/bun-webgpu/-/bun-webgpu-0.1.5.tgz", + "integrity": "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@webgpu/types": "^0.1.60" + }, + "optionalDependencies": { + "bun-webgpu-darwin-arm64": "^0.1.5", + "bun-webgpu-darwin-x64": "^0.1.5", + "bun-webgpu-linux-x64": "^0.1.5", + "bun-webgpu-win32-x64": "^0.1.5" + } + }, + "node_modules/bun-webgpu-darwin-arm64": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/bun-webgpu-darwin-arm64/-/bun-webgpu-darwin-arm64-0.1.5.tgz", + "integrity": "sha512-qM7W5IaFpWYGPDcNiQ8DOng3noQ97gxpH2MFH1mGsdKwI0T4oy++egSh5Z7s6AQx8WKgc9GzAsTUM4KZkFdacw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/bun-webgpu-darwin-x64": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/bun-webgpu-darwin-x64/-/bun-webgpu-darwin-x64-0.1.5.tgz", + "integrity": "sha512-oVoIsme27pcXB68YxnQSAgdNGCa4A3PGWYIBUewOh9VnJaoik4JenGb5Yy+svGE+ETFhQXV9nhHqgMPsDRrO6A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/bun-webgpu-linux-x64": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/bun-webgpu-linux-x64/-/bun-webgpu-linux-x64-0.1.5.tgz", + "integrity": "sha512-+SYt09k+xDEl/GfcU7L1zdNgm7IlvAFKV5Xl/auBwuprKG5UwXNhjRlRAWfhTMCUZWN+NDf8E+ZQx0cQi9K2/g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/bun-webgpu-win32-x64": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/bun-webgpu-win32-x64/-/bun-webgpu-win32-x64-0.1.5.tgz", + "integrity": "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/bytes": { "version": "3.0.0", "license": "MIT", @@ -2578,6 +3243,13 @@ "postcss-value-parser": "^4.0.2" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.7", "license": "MIT", @@ -2661,6 +3333,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diff": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff3": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", @@ -2857,6 +3538,11 @@ "node": ">=0.8.x" } }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" + }, "node_modules/extend": { "version": "3.0.2", "license": "MIT" @@ -2942,6 +3628,23 @@ "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", "license": "MIT" }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "license": "MIT", @@ -3031,7 +3734,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.3.0", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", "license": "MIT", "engines": { "node": ">=18" @@ -3088,6 +3793,16 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gifwrap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", + "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, "node_modules/github-slugger": { "version": "2.0.0", "license": "ISC" @@ -3566,6 +4281,21 @@ "node": ">= 4" } }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "license": "MIT", + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "license": "MIT" + }, "node_modules/immutable": { "version": "5.1.4", "license": "MIT" @@ -3765,6 +4495,50 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/jimp": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz", + "integrity": "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/diff": "1.6.0", + "@jimp/js-bmp": "1.6.0", + "@jimp/js-gif": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/js-tiff": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/plugin-blur": "1.6.0", + "@jimp/plugin-circle": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-contain": "1.6.0", + "@jimp/plugin-cover": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-displace": "1.6.0", + "@jimp/plugin-dither": "1.6.0", + "@jimp/plugin-fisheye": "1.6.0", + "@jimp/plugin-flip": "1.6.0", + "@jimp/plugin-hash": "1.6.0", + "@jimp/plugin-mask": "1.6.0", + "@jimp/plugin-print": "1.6.0", + "@jimp/plugin-quantize": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/plugin-rotate": "1.6.0", + "@jimp/plugin-threshold": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, "node_modules/js-yaml": { "version": "4.1.1", "license": "MIT", @@ -4080,6 +4854,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4998,6 +5784,18 @@ "version": "0.4.5", "license": "MIT" }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.33.0", "license": "MIT", @@ -5112,6 +5910,12 @@ } } }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5144,6 +5948,28 @@ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", "license": "MIT" }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "license": "MIT", + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, "node_modules/parse-css-color": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", @@ -5226,6 +6052,19 @@ "version": "3.3.0", "license": "MIT" }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -5249,6 +6088,49 @@ "node": ">=6" } }, + "node_modules/pixelmatch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz", + "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==", + "license": "ISC", + "dependencies": { + "pngjs": "^6.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/planck": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/planck/-/planck-1.4.3.tgz", + "integrity": "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=24.0" + }, + "peerDependencies": { + "stage-js": "^1.0.0-alpha.12" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5355,6 +6237,62 @@ "node": ">= 0.6" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-7.0.1.tgz", + "integrity": "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-reconciler": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.32.0.tgz", + "integrity": "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -5395,6 +6333,22 @@ "ieee754": "^1.2.1" } }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/readdirp": { "version": "5.0.0", "license": "MIT", @@ -6259,6 +7213,21 @@ "node": ">=16" } }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, "node_modules/section-matter": { "version": "1.0.0", "license": "MIT", @@ -6394,6 +7363,18 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/shiki": { "version": "3.22.0", "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.22.0.tgz", @@ -6456,6 +7437,15 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-xml-to-json": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.3.tgz", + "integrity": "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA==", + "license": "MIT", + "engines": { + "node": ">=20.12.2" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "license": "MIT" @@ -6589,6 +7579,23 @@ "node": ">=0.10.0" } }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/style-to-js": { "version": "1.1.16", "license": "MIT", @@ -6662,12 +7669,25 @@ "node": ">=16.0.0" } }, + "node_modules/three": { + "version": "0.177.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz", + "integrity": "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg==", + "license": "MIT", + "optional": true + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -6703,6 +7723,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/toml": { "version": "3.0.0", "license": "MIT" @@ -6767,8 +7804,8 @@ }, "node_modules/typescript": { "version": "5.9.3", - "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6980,6 +8017,21 @@ "version": "3.0.2", "license": "MIT" }, + "node_modules/utif2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", + "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.11" + } + }, + "node_modules/utif2/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/varint": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", @@ -7052,6 +8104,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-tree-sitter": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", + "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/emscripten": "^1.40.0" + }, + "peerDependenciesMeta": { + "@types/emscripten": { + "optional": true + } + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -7123,6 +8190,7 @@ "node_modules/ws": { "version": "8.19.0", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -7139,6 +8207,34 @@ } } }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==", + "license": "MIT" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "license": "ISC", @@ -7174,6 +8270,15 @@ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "license": "MIT", diff --git a/package.json b/package.json index 2953e7182..d23f7647c 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "check": "tsc --noEmit && npx prettier . --check", "format": "npx prettier . --write", "test": "tsx --test", + "build:tui": "node quartz/cli/build-tui.mjs", "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1", "install-plugins": "npx tsx ./quartz/plugins/loader/install-plugins.ts", "prebuild": "npm run install-plugins" @@ -41,6 +42,8 @@ "@floating-ui/dom": "^1.7.4", "@myriaddreamin/rehype-typst": "^0.6.0", "@napi-rs/simple-git": "0.1.22", + "@opentui/core": "^0.1.80", + "@opentui/react": "^0.1.80", "ansi-truncate": "^1.4.0", "async-mutex": "^0.5.0", "chokidar": "^5.0.0", @@ -65,6 +68,7 @@ "preact-render-to-string": "^6.6.5", "pretty-bytes": "^7.1.0", "pretty-time": "^1.1.0", + "react": "^19.2.4", "reading-time": "^1.5.0", "rehype-autolink-headings": "^7.1.0", "rehype-citation": "^2.3.1", @@ -101,6 +105,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^25.0.10", "@types/pretty-time": "^1.1.5", + "@types/react": "^19.2.14", "@types/source-map-support": "^0.5.10", "@types/ws": "^8.18.1", "@types/yargs": "^17.0.35", diff --git a/quartz.lock.json b/quartz.lock.json index 299d72b16..a5542da25 100644 --- a/quartz.lock.json +++ b/quartz.lock.json @@ -1,59 +1,11 @@ { "version": "1.0.0", "plugins": { - "explorer": { - "source": "github:quartz-community/explorer", - "resolved": "https://github.com/quartz-community/explorer.git", - "commit": "09452e44a4c0e9b2c1d94e6e7a5dc98137dac230", - "installedAt": "2026-02-17T17:28:27.727Z" - }, - "graph": { - "source": "github:quartz-community/graph", - "resolved": "https://github.com/quartz-community/graph.git", - "commit": "f61ef4aaad0a560428373508e49efa8bf5d0dbf6", - "installedAt": "2026-02-17T17:28:28.934Z" - }, - "search": { - "source": "github:quartz-community/search", - "resolved": "https://github.com/quartz-community/search.git", - "commit": "5d206baacbad17e99925f61c6263e98a494258d1", - "installedAt": "2026-02-17T17:28:29.442Z" - }, - "backlinks": { - "source": "github:quartz-community/backlinks", - "resolved": "https://github.com/quartz-community/backlinks.git", - "commit": "8adb778566a3d8425d321d6bbc0b673cba9ec63d", - "installedAt": "2026-02-17T17:28:30.109Z" - }, - "table-of-contents": { - "source": "github:quartz-community/table-of-contents", - "resolved": "https://github.com/quartz-community/table-of-contents.git", - "commit": "38e7f0198114e4466cb39ea0c2ebb01e46847d39", - "installedAt": "2026-02-17T17:28:30.541Z" - }, - "comments": { - "source": "github:quartz-community/comments", - "resolved": "https://github.com/quartz-community/comments.git", - "commit": "a2bee0c8f2f899f4f181a8a6f52ef300eb6bb8e4", - "installedAt": "2026-02-17T17:28:30.983Z" - }, - "breadcrumbs": { - "source": "github:quartz-community/breadcrumbs", - "resolved": "https://github.com/quartz-community/breadcrumbs.git", - "commit": "441c3c474b2a7aeecb483aa4421c7a633458e97d", - "installedAt": "2026-02-17T17:28:31.401Z" - }, - "recent-notes": { - "source": "github:quartz-community/recent-notes", - "resolved": "https://github.com/quartz-community/recent-notes.git", - "commit": "96d5df9fc6d7e0e8bd3a90856faaa08d2ac3441b", - "installedAt": "2026-02-17T17:28:31.827Z" - }, - "latex": { - "source": "github:quartz-community/latex", - "resolved": "https://github.com/quartz-community/latex.git", - "commit": "d7d4a8de001ec18289d12b58c8144f27ab72cb06", - "installedAt": "2026-02-17T17:28:32.350Z" + "alias-redirects": { + "source": "github:quartz-community/alias-redirects", + "resolved": "https://github.com/quartz-community/alias-redirects.git", + "commit": "65897957ab8108d015cd4255d42d84df7377c083", + "installedAt": "2026-02-17T17:28:43.267Z" }, "article-title": { "source": "github:quartz-community/article-title", @@ -61,101 +13,23 @@ "commit": "031872e39ba30d8567af63c6a3891b3054432194", "installedAt": "2026-02-17T17:28:32.818Z" }, - "tag-list": { - "source": "github:quartz-community/tag-list", - "resolved": "https://github.com/quartz-community/tag-list.git", - "commit": "a0a30b822447261a7a9bcf96d649ca7525eb3ded", - "installedAt": "2026-02-17T17:28:33.366Z" + "backlinks": { + "source": "github:quartz-community/backlinks", + "resolved": "https://github.com/quartz-community/backlinks.git", + "commit": "8adb778566a3d8425d321d6bbc0b673cba9ec63d", + "installedAt": "2026-02-17T17:28:30.109Z" }, - "page-title": { - "source": "github:quartz-community/page-title", - "resolved": "https://github.com/quartz-community/page-title.git", - "commit": "d14bffe11830eff6e4489945324a0dd33d994bf6", - "installedAt": "2026-02-17T17:28:33.828Z" + "breadcrumbs": { + "source": "github:quartz-community/breadcrumbs", + "resolved": "https://github.com/quartz-community/breadcrumbs.git", + "commit": "441c3c474b2a7aeecb483aa4421c7a633458e97d", + "installedAt": "2026-02-17T17:28:31.401Z" }, - "darkmode": { - "source": "github:quartz-community/darkmode", - "resolved": "https://github.com/quartz-community/darkmode.git", - "commit": "453e59cf913b2276a5bb6d6bfdc0685304618877", - "installedAt": "2026-02-17T17:28:34.395Z" - }, - "reader-mode": { - "source": "github:quartz-community/reader-mode", - "resolved": "https://github.com/quartz-community/reader-mode.git", - "commit": "d362013e0de040dcd8711e7c6bf68b93a0668bae", - "installedAt": "2026-02-17T17:28:34.989Z" - }, - "content-meta": { - "source": "github:quartz-community/content-meta", - "resolved": "https://github.com/quartz-community/content-meta.git", - "commit": "be178b7ab4f521f0ca29051676f207a23b706051", - "installedAt": "2026-02-17T17:28:35.416Z" - }, - "footer": { - "source": "github:quartz-community/footer", - "resolved": "https://github.com/quartz-community/footer.git", - "commit": "eef4ba0af26ea6097543784fc5cfa6820d6b8250", - "installedAt": "2026-02-17T17:28:35.903Z" - }, - "content-page": { - "source": "github:quartz-community/content-page", - "resolved": "https://github.com/quartz-community/content-page.git", - "commit": "9975389d442707d8772aebeaba02676169569c60", - "installedAt": "2026-02-17T17:28:36.353Z" - }, - "folder-page": { - "source": "github:quartz-community/folder-page", - "resolved": "https://github.com/quartz-community/folder-page.git", - "commit": "630c26711de6a5cb91ba2b81882f4fad91bbbaeb", - "installedAt": "2026-02-17T17:28:36.806Z" - }, - "tag-page": { - "source": "github:quartz-community/tag-page", - "resolved": "https://github.com/quartz-community/tag-page.git", - "commit": "27ad16e609832805b2e11c7836937706a38caec1", - "installedAt": "2026-02-17T17:28:37.377Z" - }, - "created-modified-date": { - "source": "github:quartz-community/created-modified-date", - "resolved": "https://github.com/quartz-community/created-modified-date.git", - "commit": "4d976e88da01d9fa0dbdcd11e756b1b3037b3a49", - "installedAt": "2026-02-17T17:28:37.833Z" - }, - "syntax-highlighting": { - "source": "github:quartz-community/syntax-highlighting", - "resolved": "https://github.com/quartz-community/syntax-highlighting.git", - "commit": "a3ec90046f68e8d8bbbd275f07f606268cb6e001", - "installedAt": "2026-02-17T17:28:38.257Z" - }, - "obsidian-flavored-markdown": { - "source": "github:quartz-community/obsidian-flavored-markdown", - "resolved": "https://github.com/quartz-community/obsidian-flavored-markdown.git", - "commit": "a5f45c21cec287e8aa2896b5032fe48962ed9b77", - "installedAt": "2026-02-17T17:28:38.693Z" - }, - "github-flavored-markdown": { - "source": "github:quartz-community/github-flavored-markdown", - "resolved": "https://github.com/quartz-community/github-flavored-markdown.git", - "commit": "7e19794a519debc994e2e99c67b223661b0aac0c", - "installedAt": "2026-02-17T17:28:39.147Z" - }, - "crawl-links": { - "source": "github:quartz-community/crawl-links", - "resolved": "https://github.com/quartz-community/crawl-links.git", - "commit": "3ca88b4ba1a049cc79b3cf1154b771e4eb8f7549", - "installedAt": "2026-02-17T17:28:39.629Z" - }, - "description": { - "source": "github:quartz-community/description", - "resolved": "https://github.com/quartz-community/description.git", - "commit": "82f1c95e53acc7521fa6bf46e9ea20018ee28264", - "installedAt": "2026-02-17T17:28:40.060Z" - }, - "hard-line-breaks": { - "source": "github:quartz-community/hard-line-breaks", - "resolved": "https://github.com/quartz-community/hard-line-breaks.git", - "commit": "b14a477db87e6657440a044e6d2fbf9d943472e6", - "installedAt": "2026-02-17T17:28:40.498Z" + "canvas-page": { + "source": "github:quartz-community/canvas-page", + "resolved": "https://github.com/quartz-community/canvas-page.git", + "commit": "18107bc8d759a1860caeda10de336ac66bf7f923", + "installedAt": "2026-02-18T15:02:34.059Z" }, "citations": { "source": "github:quartz-community/citations", @@ -163,47 +37,17 @@ "commit": "b5db4ea6fd6f3fc9c1ce37cd9cdba0ca9e74ecc8", "installedAt": "2026-02-17T17:28:40.960Z" }, - "ox-hugo": { - "source": "github:quartz-community/ox-hugo", - "resolved": "https://github.com/quartz-community/ox-hugo.git", - "commit": "d8fdf7c6a54464e960c6b817147968cf07719086", - "installedAt": "2026-02-17T17:28:41.395Z" - }, - "roam": { - "source": "github:quartz-community/roam", - "resolved": "https://github.com/quartz-community/roam.git", - "commit": "f94276c284edb01e6b4136ec6f7683f29123c54c", - "installedAt": "2026-02-17T17:28:41.832Z" - }, - "remove-draft": { - "source": "github:quartz-community/remove-draft", - "resolved": "https://github.com/quartz-community/remove-draft.git", - "commit": "61744308cf739f63fe351dea8dffd6b7c434440d", - "installedAt": "2026-02-17T17:28:42.324Z" - }, - "explicit-publish": { - "source": "github:quartz-community/explicit-publish", - "resolved": "https://github.com/quartz-community/explicit-publish.git", - "commit": "ba19e6f32393fa5fe788dbf39a9487bc8e73247a", - "installedAt": "2026-02-17T17:28:42.799Z" - }, - "alias-redirects": { - "source": "github:quartz-community/alias-redirects", - "resolved": "https://github.com/quartz-community/alias-redirects.git", - "commit": "65897957ab8108d015cd4255d42d84df7377c083", - "installedAt": "2026-02-17T17:28:43.267Z" - }, "cname": { "source": "github:quartz-community/cname", "resolved": "https://github.com/quartz-community/cname.git", "commit": "752470f04410576a767b48094e926d41eef3eb18", "installedAt": "2026-02-17T17:28:43.713Z" }, - "favicon": { - "source": "github:quartz-community/favicon", - "resolved": "https://github.com/quartz-community/favicon.git", - "commit": "71cfff28b7a41f28618f574e672b2b399c6b781d", - "installedAt": "2026-02-17T17:28:44.365Z" + "comments": { + "source": "github:quartz-community/comments", + "resolved": "https://github.com/quartz-community/comments.git", + "commit": "bf03ced30c7828c3ae4dae5d313e596983f5d453", + "installedAt": "2026-02-18T19:52:41.699Z" }, "content-index": { "source": "github:quartz-community/content-index", @@ -211,17 +55,179 @@ "commit": "2b9c62d659b7f8e9e7cc028a201cae7061224653", "installedAt": "2026-02-17T17:28:44.806Z" }, + "content-meta": { + "source": "github:quartz-community/content-meta", + "resolved": "https://github.com/quartz-community/content-meta.git", + "commit": "be178b7ab4f521f0ca29051676f207a23b706051", + "installedAt": "2026-02-17T17:28:35.416Z" + }, + "content-page": { + "source": "github:quartz-community/content-page", + "resolved": "https://github.com/quartz-community/content-page.git", + "commit": "738cdc7ab6bc5718e68db4d318b1001a25a1d7d9", + "installedAt": "2026-02-18T15:02:36.681Z" + }, + "crawl-links": { + "source": "github:quartz-community/crawl-links", + "resolved": "https://github.com/quartz-community/crawl-links.git", + "commit": "58bdc4878d0e0eae126f8389465ef9c7c2a1fbc9", + "installedAt": "2026-02-18T19:52:43.755Z" + }, + "created-modified-date": { + "source": "github:quartz-community/created-modified-date", + "resolved": "https://github.com/quartz-community/created-modified-date.git", + "commit": "0a129541ba64a28ea747fba6f2391611c2b6185c", + "installedAt": "2026-02-18T19:52:44.210Z" + }, + "darkmode": { + "source": "github:quartz-community/darkmode", + "resolved": "https://github.com/quartz-community/darkmode.git", + "commit": "453e59cf913b2276a5bb6d6bfdc0685304618877", + "installedAt": "2026-02-17T17:28:34.395Z" + }, + "description": { + "source": "github:quartz-community/description", + "resolved": "https://github.com/quartz-community/description.git", + "commit": "82f1c95e53acc7521fa6bf46e9ea20018ee28264", + "installedAt": "2026-02-17T17:28:40.060Z" + }, + "explicit-publish": { + "source": "github:quartz-community/explicit-publish", + "resolved": "https://github.com/quartz-community/explicit-publish.git", + "commit": "ba19e6f32393fa5fe788dbf39a9487bc8e73247a", + "installedAt": "2026-02-17T17:28:42.799Z" + }, + "explorer": { + "source": "github:quartz-community/explorer", + "resolved": "https://github.com/quartz-community/explorer.git", + "commit": "99f0b2a62f85d60352542ecaf3c516816a401068", + "installedAt": "2026-02-18T15:02:39.752Z" + }, + "favicon": { + "source": "github:quartz-community/favicon", + "resolved": "https://github.com/quartz-community/favicon.git", + "commit": "71cfff28b7a41f28618f574e672b2b399c6b781d", + "installedAt": "2026-02-17T17:28:44.365Z" + }, + "folder-page": { + "source": "github:quartz-community/folder-page", + "resolved": "https://github.com/quartz-community/folder-page.git", + "commit": "2684accc90cf56ebc21ab21f301fdb8b847f328a", + "installedAt": "2026-02-18T15:02:40.643Z" + }, + "footer": { + "source": "github:quartz-community/footer", + "resolved": "https://github.com/quartz-community/footer.git", + "commit": "eef4ba0af26ea6097543784fc5cfa6820d6b8250", + "installedAt": "2026-02-17T17:28:35.903Z" + }, + "github-flavored-markdown": { + "source": "github:quartz-community/github-flavored-markdown", + "resolved": "https://github.com/quartz-community/github-flavored-markdown.git", + "commit": "7e19794a519debc994e2e99c67b223661b0aac0c", + "installedAt": "2026-02-17T17:28:39.147Z" + }, + "graph": { + "source": "github:quartz-community/graph", + "resolved": "https://github.com/quartz-community/graph.git", + "commit": "b908529c04044e907680cc9b9f37854ae1a14000", + "installedAt": "2026-02-18T15:02:42.108Z" + }, + "hard-line-breaks": { + "source": "github:quartz-community/hard-line-breaks", + "resolved": "https://github.com/quartz-community/hard-line-breaks.git", + "commit": "b14a477db87e6657440a044e6d2fbf9d943472e6", + "installedAt": "2026-02-17T17:28:40.498Z" + }, + "latex": { + "source": "github:quartz-community/latex", + "resolved": "https://github.com/quartz-community/latex.git", + "commit": "6660addddc5af95437d225ebf3ec2cfecf4527e7", + "installedAt": "2026-02-18T19:52:49.775Z" + }, + "obsidian-flavored-markdown": { + "source": "github:quartz-community/obsidian-flavored-markdown", + "resolved": "https://github.com/quartz-community/obsidian-flavored-markdown.git", + "commit": "a5f45c21cec287e8aa2896b5032fe48962ed9b77", + "installedAt": "2026-02-17T17:28:38.693Z" + }, "og-image": { "source": "github:quartz-community/og-image", "resolved": "https://github.com/quartz-community/og-image.git", "commit": "9a00809fccb0e1e4b7db4ee6e6eed002952396f4", "installedAt": "2026-02-17T17:28:45.533Z" }, - "canvas-page": { - "source": "github:quartz-community/canvas-page", - "resolved": "https://github.com/quartz-community/canvas-page.git", - "commit": "683c2da6bb068d3e596cdb3bfcca68007829cd17", - "installedAt": "2026-02-17T17:28:46.012Z" + "ox-hugo": { + "source": "github:quartz-community/ox-hugo", + "resolved": "https://github.com/quartz-community/ox-hugo.git", + "commit": "d8fdf7c6a54464e960c6b817147968cf07719086", + "installedAt": "2026-02-17T17:28:41.395Z" + }, + "page-title": { + "source": "github:quartz-community/page-title", + "resolved": "https://github.com/quartz-community/page-title.git", + "commit": "d14bffe11830eff6e4489945324a0dd33d994bf6", + "installedAt": "2026-02-17T17:28:33.828Z" + }, + "reader-mode": { + "source": "github:quartz-community/reader-mode", + "resolved": "https://github.com/quartz-community/reader-mode.git", + "commit": "d362013e0de040dcd8711e7c6bf68b93a0668bae", + "installedAt": "2026-02-17T17:28:34.989Z" + }, + "recent-notes": { + "source": "github:quartz-community/recent-notes", + "resolved": "https://github.com/quartz-community/recent-notes.git", + "commit": "96d5df9fc6d7e0e8bd3a90856faaa08d2ac3441b", + "installedAt": "2026-02-17T17:28:31.827Z" + }, + "remove-draft": { + "source": "github:quartz-community/remove-draft", + "resolved": "https://github.com/quartz-community/remove-draft.git", + "commit": "61744308cf739f63fe351dea8dffd6b7c434440d", + "installedAt": "2026-02-17T17:28:42.324Z" + }, + "roam": { + "source": "github:quartz-community/roam", + "resolved": "https://github.com/quartz-community/roam.git", + "commit": "f94276c284edb01e6b4136ec6f7683f29123c54c", + "installedAt": "2026-02-17T17:28:41.832Z" + }, + "search": { + "source": "github:quartz-community/search", + "resolved": "https://github.com/quartz-community/search.git", + "commit": "5d206baacbad17e99925f61c6263e98a494258d1", + "installedAt": "2026-02-17T17:28:29.442Z" + }, + "spacer": { + "source": "github:quartz-community/spacer", + "resolved": "https://github.com/quartz-community/spacer.git", + "commit": "b972767017536c879d92ac00f4732fc8c993d560", + "installedAt": "2026-02-17T20:39:12.603Z" + }, + "syntax-highlighting": { + "source": "github:quartz-community/syntax-highlighting", + "resolved": "https://github.com/quartz-community/syntax-highlighting.git", + "commit": "a3ec90046f68e8d8bbbd275f07f606268cb6e001", + "installedAt": "2026-02-17T17:28:38.257Z" + }, + "table-of-contents": { + "source": "github:quartz-community/table-of-contents", + "resolved": "https://github.com/quartz-community/table-of-contents.git", + "commit": "38e7f0198114e4466cb39ea0c2ebb01e46847d39", + "installedAt": "2026-02-17T17:28:30.541Z" + }, + "tag-list": { + "source": "github:quartz-community/tag-list", + "resolved": "https://github.com/quartz-community/tag-list.git", + "commit": "a0a30b822447261a7a9bcf96d649ca7525eb3ded", + "installedAt": "2026-02-17T17:28:33.366Z" + }, + "tag-page": { + "source": "github:quartz-community/tag-page", + "resolved": "https://github.com/quartz-community/tag-page.git", + "commit": "45e16343dd6bb85174237fc66f81e835244e7935", + "installedAt": "2026-02-18T15:02:50.019Z" } } } diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index 78d3dc606..4a60e067e 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -32,6 +32,47 @@ import { } from "./cli/args.js" import { version } from "./cli/constants.js" +async function launchTui() { + const { pathToFileURL } = await import("url") + const { join } = await import("path") + const { existsSync } = await import("fs") + const { spawn } = await import("child_process") + const tuiPath = join(process.cwd(), "quartz", "cli", "tui", "dist", "App.mjs") + + if (!existsSync(tuiPath)) { + console.log("TUI not built yet. Building...") + const buildScript = pathToFileURL(join(process.cwd(), "quartz", "cli", "build-tui.mjs")).href + await import(buildScript) + console.log("TUI built successfully.") + } + + // OpenTUI requires Bun runtime (uses bun:ffi for Zig renderer) + return new Promise((resolve, reject) => { + const child = spawn("bun", ["run", tuiPath], { + stdio: "inherit", + cwd: process.cwd(), + }) + + child.on("error", (err) => { + if (err.code === "ENOENT") { + console.error( + "Error: Bun runtime not found. The TUI requires Bun to run.\n" + + "Install Bun: https://bun.sh/docs/installation", + ) + } + reject(err) + }) + + child.on("close", (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`TUI exited with code ${code}`)) + } + }) + }) +} + yargs(hideBin(process.argv)) .scriptName("quartz") .version(version) @@ -59,8 +100,11 @@ yargs(hideBin(process.argv)) .command("migrate", "Migrate old config to quartz.plugins.json", CommonArgv, async () => { await handleMigrate() }) + .command("tui", "Launch interactive plugin manager", CommonArgv, async () => { + await launchTui() + }) .command( - "plugin ", + "plugin [subcommand]", "Manage Quartz plugins", (yargs) => { return yargs @@ -125,10 +169,11 @@ yargs(hideBin(process.argv)) .command("check", "Check for plugin updates", CommonArgv, async () => { await handlePluginCheck() }) - .demandCommand(1, "Please specify a plugin subcommand") + .demandCommand(0, "") }, - async () => { - // This handler is called when no subcommand is provided + async (argv) => { + if (!argv._.includes("plugin") || argv._.length > 1) return + await launchTui() }, ) .showHelpOnFail(false) diff --git a/quartz/cli/build-tui.mjs b/quartz/cli/build-tui.mjs new file mode 100644 index 000000000..3811f5dcd --- /dev/null +++ b/quartz/cli/build-tui.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import esbuild from "esbuild" +import path from "path" +import { fileURLToPath } from "url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const tuiDir = path.join(__dirname, "tui") + +await esbuild.build({ + entryPoints: [path.join(tuiDir, "App.tsx")], + outdir: path.join(tuiDir, "dist"), + bundle: true, + platform: "node", + format: "esm", + jsx: "automatic", + jsxImportSource: "@opentui/react", + packages: "external", + sourcemap: true, + target: "esnext", + outExtension: { ".js": ".mjs" }, +}) diff --git a/quartz/cli/plugin-data.js b/quartz/cli/plugin-data.js new file mode 100644 index 000000000..7a7f282e4 --- /dev/null +++ b/quartz/cli/plugin-data.js @@ -0,0 +1,280 @@ +import fs from "fs" +import path from "path" +import { execSync } from "child_process" + +const LOCKFILE_PATH = path.join(process.cwd(), "quartz.lock.json") +const PLUGINS_DIR = path.join(process.cwd(), ".quartz", "plugins") +const PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.json") +const DEFAULT_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.default.json") + +export function readPluginsJson() { + if (!fs.existsSync(PLUGINS_JSON_PATH)) return null + try { + return JSON.parse(fs.readFileSync(PLUGINS_JSON_PATH, "utf-8")) + } catch { + return null + } +} + +export function writePluginsJson(data) { + fs.writeFileSync(PLUGINS_JSON_PATH, JSON.stringify(data, null, 2) + "\n") +} + +export function readDefaultPluginsJson() { + if (!fs.existsSync(DEFAULT_PLUGINS_JSON_PATH)) return null + try { + return JSON.parse(fs.readFileSync(DEFAULT_PLUGINS_JSON_PATH, "utf-8")) + } catch { + return null + } +} + +export function readLockfile() { + if (!fs.existsSync(LOCKFILE_PATH)) return null + try { + return JSON.parse(fs.readFileSync(LOCKFILE_PATH, "utf-8")) + } catch { + return null + } +} + +export function writeLockfile(lockfile) { + if (lockfile.plugins) { + const sorted = {} + for (const key of Object.keys(lockfile.plugins).sort()) { + sorted[key] = lockfile.plugins[key] + } + lockfile = { ...lockfile, plugins: sorted } + } + fs.writeFileSync(LOCKFILE_PATH, JSON.stringify(lockfile, null, 2) + "\n") +} + +export function extractPluginName(source) { + if (source.startsWith("github:")) { + const withoutPrefix = source.replace("github:", "") + const [repoPath] = withoutPrefix.split("#") + const parts = repoPath.split("/") + return parts[parts.length - 1] + } + if (source.startsWith("git+") || source.startsWith("https://")) { + const url = source.replace("git+", "") + const match = url.match(/\/([^/]+?)(?:\.git)?(?:#|$)/) + return match?.[1] ?? source + } + return source +} + +export function readManifestFromPackageJson(pluginDir) { + const pkgPath = path.join(pluginDir, "package.json") + if (!fs.existsSync(pkgPath)) return null + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) + return pkg.quartz ?? null + } catch { + return null + } +} + +export function parseGitSource(source) { + if (source.startsWith("github:")) { + const [repoPath, ref] = source.replace("github:", "").split("#") + const [owner, repo] = repoPath.split("/") + return { name: repo, url: `https://github.com/${owner}/${repo}.git`, ref } + } + if (source.startsWith("git+")) { + const url = source.replace("git+", "") + const name = path.basename(url, ".git") + return { name, url } + } + if (source.startsWith("https://")) { + const name = path.basename(source, ".git") + return { name, url: source } + } + throw new Error(`Cannot parse plugin source: ${source}`) +} + +export function getGitCommit(pluginDir) { + try { + return execSync("git rev-parse HEAD", { cwd: pluginDir, encoding: "utf-8" }).trim() + } catch { + return "unknown" + } +} + +export function getPluginDir(name) { + return path.join(PLUGINS_DIR, name) +} + +export function pluginDirExists(name) { + return fs.existsSync(path.join(PLUGINS_DIR, name)) +} + +export function ensurePluginsDir() { + if (!fs.existsSync(PLUGINS_DIR)) { + fs.mkdirSync(PLUGINS_DIR, { recursive: true }) + } +} + +/** + * Merges quartz.plugins.json, quartz.lock.json, and on-disk manifest data + * into enriched plugin entries with: name, displayName, source, enabled, + * options, order, layout, category, installed, locked, manifest, + * currentCommit, modified. + */ +export function getEnrichedPlugins() { + const pluginsJson = readPluginsJson() + const lockfile = readLockfile() + + if (!pluginsJson?.plugins) return [] + + return pluginsJson.plugins.map((entry, index) => { + const name = extractPluginName(entry.source) + const pluginDir = path.join(PLUGINS_DIR, name) + const installed = fs.existsSync(pluginDir) + const locked = lockfile?.plugins?.[name] ?? null + const manifest = installed ? readManifestFromPackageJson(pluginDir) : null + const currentCommit = installed ? getGitCommit(pluginDir) : null + const modified = locked && currentCommit ? currentCommit !== locked.commit : false + + return { + index, + name, + displayName: manifest?.displayName ?? name, + source: entry.source, + enabled: entry.enabled ?? true, + options: entry.options ?? {}, + order: entry.order ?? 50, + layout: entry.layout ?? null, + category: manifest?.category ?? "unknown", + installed, + locked, + manifest, + currentCommit, + modified, + } + }) +} + +export function getLayoutConfig() { + const pluginsJson = readPluginsJson() + return pluginsJson?.layout ?? null +} + +export function getGlobalConfig() { + const pluginsJson = readPluginsJson() + return pluginsJson?.configuration ?? null +} + +export function updatePluginEntry(index, updates) { + const json = readPluginsJson() + if (!json?.plugins?.[index]) return false + Object.assign(json.plugins[index], updates) + writePluginsJson(json) + return true +} + +export function updateGlobalConfig(updates) { + const json = readPluginsJson() + if (!json) return false + json.configuration = { ...json.configuration, ...updates } + writePluginsJson(json) + return true +} + +export function updateLayoutConfig(layout) { + const json = readPluginsJson() + if (!json) return false + json.layout = layout + writePluginsJson(json) + return true +} + +export function reorderPlugin(fromIndex, toIndex) { + const json = readPluginsJson() + if (!json?.plugins) return false + const [moved] = json.plugins.splice(fromIndex, 1) + json.plugins.splice(toIndex, 0, moved) + writePluginsJson(json) + return true +} + +export function removePluginEntry(index) { + const json = readPluginsJson() + if (!json?.plugins?.[index]) return false + json.plugins.splice(index, 1) + writePluginsJson(json) + return true +} + +export function addPluginEntry(entry) { + const json = readPluginsJson() + if (!json) return false + if (!json.plugins) json.plugins = [] + json.plugins.push(entry) + writePluginsJson(json) + return true +} + +export function configExists() { + return fs.existsSync(PLUGINS_JSON_PATH) +} + +export function createConfigFromDefault() { + const defaultJson = readDefaultPluginsJson() + if (!defaultJson) { + // No default available — create minimal config + const minimal = { + $schema: "./quartz/plugins/quartz-plugins.schema.json", + configuration: { + pageTitle: "Quartz", + enableSPA: true, + enablePopovers: true, + analytics: { provider: "plausible" }, + locale: "en-US", + baseUrl: "quartz.jzhao.xyz", + ignorePatterns: ["private", "templates", ".obsidian"], + defaultDateType: "created", + theme: { + cdnCaching: true, + typography: { + header: "Schibsted Grotesk", + body: "Source Sans Pro", + code: "IBM Plex Mono", + }, + colors: { + lightMode: { + light: "#faf8f8", + lightgray: "#e5e5e5", + gray: "#b8b8b8", + darkgray: "#4e4e4e", + dark: "#2b2b2b", + secondary: "#284b63", + tertiary: "#84a59d", + highlight: "rgba(143, 159, 169, 0.15)", + textHighlight: "#fff23688", + }, + darkMode: { + light: "#161618", + lightgray: "#393639", + gray: "#646464", + darkgray: "#d4d4d4", + dark: "#ebebec", + secondary: "#7b97aa", + tertiary: "#84a59d", + highlight: "rgba(143, 159, 169, 0.15)", + textHighlight: "#fff23688", + }, + }, + }, + }, + plugins: [], + layout: { groups: {}, byPageType: {} }, + } + writePluginsJson(minimal) + return minimal + } + writePluginsJson(defaultJson) + return defaultJson +} + +export { LOCKFILE_PATH, PLUGINS_DIR, PLUGINS_JSON_PATH, DEFAULT_PLUGINS_JSON_PATH } diff --git a/quartz/cli/plugin-git-handlers.js b/quartz/cli/plugin-git-handlers.js index c300f1fd4..abe977429 100644 --- a/quartz/cli/plugin-git-handlers.js +++ b/quartz/cli/plugin-git-handlers.js @@ -2,51 +2,21 @@ import fs from "fs" import path from "path" import { execSync } from "child_process" import { styleText } from "util" +import { + readPluginsJson, + writePluginsJson, + readLockfile, + writeLockfile, + extractPluginName, + readManifestFromPackageJson, + parseGitSource, + getGitCommit, + PLUGINS_DIR, + LOCKFILE_PATH, +} from "./plugin-data.js" -const LOCKFILE_PATH = path.join(process.cwd(), "quartz.lock.json") -const PLUGINS_DIR = path.join(process.cwd(), ".quartz", "plugins") -const PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.json") const INTERNAL_EXPORTS = new Set(["manifest", "default"]) -function readPluginsJson() { - if (!fs.existsSync(PLUGINS_JSON_PATH)) return null - try { - return JSON.parse(fs.readFileSync(PLUGINS_JSON_PATH, "utf-8")) - } catch { - return null - } -} - -function writePluginsJson(data) { - fs.writeFileSync(PLUGINS_JSON_PATH, JSON.stringify(data, null, 2) + "\n") -} - -function extractPluginName(source) { - if (source.startsWith("github:")) { - const withoutPrefix = source.replace("github:", "") - const [repoPath] = withoutPrefix.split("#") - const parts = repoPath.split("/") - return parts[parts.length - 1] - } - if (source.startsWith("git+") || source.startsWith("https://")) { - const url = source.replace("git+", "") - const match = url.match(/\/([^/]+?)(?:\.git)?(?:#|$)/) - return match?.[1] ?? source - } - return source -} - -function readManifestFromPackageJson(pluginDir) { - const pkgPath = path.join(pluginDir, "package.json") - if (!fs.existsSync(pkgPath)) return null - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) - return pkg.quartz ?? null - } catch { - return null - } -} - function buildPlugin(pluginDir, name) { try { console.log(styleText("cyan", ` → ${name}: installing dependencies...`)) @@ -128,48 +98,6 @@ async function regeneratePluginIndex() { fs.writeFileSync(indexPath, indexContent) } -function readLockfile() { - if (!fs.existsSync(LOCKFILE_PATH)) { - return null - } - try { - const content = fs.readFileSync(LOCKFILE_PATH, "utf-8") - return JSON.parse(content) - } catch { - return null - } -} - -function writeLockfile(lockfile) { - fs.writeFileSync(LOCKFILE_PATH, JSON.stringify(lockfile, null, 2)) -} - -function parseGitSource(source) { - if (source.startsWith("github:")) { - const [repoPath, ref] = source.replace("github:", "").split("#") - const [owner, repo] = repoPath.split("/") - return { name: repo, url: `https://github.com/${owner}/${repo}.git`, ref } - } - if (source.startsWith("git+")) { - const url = source.replace("git+", "") - const name = path.basename(url, ".git") - return { name, url } - } - if (source.startsWith("https://")) { - const name = path.basename(source, ".git") - return { name, url: source } - } - throw new Error(`Cannot parse plugin source: ${source}`) -} - -function getGitCommit(pluginDir) { - try { - return execSync("git rev-parse HEAD", { cwd: pluginDir, encoding: "utf-8" }).trim() - } catch { - return "unknown" - } -} - export async function handlePluginInstall() { const lockfile = readLockfile() diff --git a/quartz/cli/tui/App.tsx b/quartz/cli/tui/App.tsx new file mode 100644 index 000000000..c76422258 --- /dev/null +++ b/quartz/cli/tui/App.tsx @@ -0,0 +1,114 @@ +import { useCallback, useState } from "react" +import { createCliRenderer } from "@opentui/core" +import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react" +import { Notification, type NotificationMessage } from "./components/Notification.js" +import { SetupWizard } from "./components/SetupWizard.js" +import { StatusBar } from "./components/StatusBar.js" +import { LayoutPanel } from "./panels/LayoutPanel.js" +import { PluginsPanel } from "./panels/PluginsPanel.js" +import { SettingsPanel } from "./panels/SettingsPanel.js" +import { version } from "../constants.js" +import { configExists } from "../plugin-data.js" + +const TABS = ["Plugins", "Layout", "Settings"] as const +type Tab = (typeof TABS)[number] + +export function App() { + const renderer = useRenderer() + const { height: rows } = useTerminalDimensions() + const [hasConfig, setHasConfig] = useState(() => configExists()) + const [activeTab, setActiveTab] = useState("Plugins") + const [notification, setNotification] = useState(null) + const [panelFocused, setPanelFocused] = useState(false) + + const notify = useCallback((message: string, type: "success" | "error" | "info" = "info") => { + setNotification({ message, type }) + setTimeout(() => setNotification(null), 3000) + }, []) + + useKeyboard((event) => { + if (panelFocused || !hasConfig) return + if (event.name !== "q") return + renderer.destroy() + process.exit(0) + }) + + useKeyboard((event) => { + if (!hasConfig || panelFocused) return + if (event.name !== "tab") return + + const currentIndex = TABS.indexOf(activeTab) + const next = event.shift + ? (currentIndex - 1 + TABS.length) % TABS.length + : (currentIndex + 1) % TABS.length + setActiveTab(TABS[next]) + }) + + if (!hasConfig) { + return ( + + + + + {` Quartz v${version} Plugin Manager `} + + + + + + { + setHasConfig(true) + notify("Configuration created", "success") + }} + /> + + + {notification && } + + ) + } + + return ( + + + + + {` Quartz v${version} Plugin Manager `} + + + + + + {TABS.map((tab) => ( + + {tab === activeTab ? ( + + [ {tab} ] + + ) : ( + {` ${tab} `} + )} + + ))} + + + + {activeTab === "Plugins" && ( + + )} + {activeTab === "Layout" && } + {activeTab === "Settings" && ( + + )} + + + {notification && } + + + + ) +} + +const renderer = await createCliRenderer({ exitOnCtrlC: true }) +createRoot(renderer).render() diff --git a/quartz/cli/tui/async-plugin-ops.ts b/quartz/cli/tui/async-plugin-ops.ts new file mode 100644 index 000000000..6a4a5c911 --- /dev/null +++ b/quartz/cli/tui/async-plugin-ops.ts @@ -0,0 +1,572 @@ +import fs from "fs" +import path from "path" +import { execFile } from "child_process" +import { promisify } from "util" +import { + readPluginsJson, + writePluginsJson, + readLockfile, + writeLockfile, + extractPluginName, + readManifestFromPackageJson, + parseGitSource, + PLUGINS_DIR, +} from "../plugin-data.js" + +export type ProgressCallback = ( + message: string, + type: "info" | "success" | "error" | "warning", +) => void + +export interface OperationResult { + success: boolean + installed?: number + failed?: number + updated?: string[] + errors?: string[] +} + +interface LockfileEntry { + source: string + resolved: string + commit: string + installedAt: string +} + +interface Lockfile { + version: string + plugins: Record +} + +interface PluginsJsonEntry { + source: string + enabled?: boolean + options?: Record + order?: number + layout?: { + position: string + priority?: number + display?: string + condition?: string + group?: string + groupOptions?: Record + } +} + +interface PluginsJson { + plugins?: PluginsJsonEntry[] +} + +interface PluginManifestComponent { + defaultPosition?: string + defaultPriority?: number +} + +interface PluginManifest { + defaultEnabled?: boolean + defaultOptions?: Record + defaultOrder?: number + components?: Record +} + +const INTERNAL_EXPORTS = new Set(["manifest", "default"]) +const execFileAsync = promisify(execFile) + +function report( + onProgress: ProgressCallback | undefined, + message: string, + type: OperationResultType, +) { + onProgress?.(message, type) +} + +type OperationResultType = "info" | "success" | "error" | "warning" + +function shortCommit(commit: string) { + return commit.slice(0, 7) +} + +async function runGit(args: string[], cwd?: string, timeout = 60000) { + const { stdout } = await execFileAsync("git", args, { cwd, timeout, encoding: "utf-8" }) + return stdout.trim() +} + +async function runNpm(args: string[], cwd: string, timeout = 300000) { + await execFileAsync("npm", args, { cwd, timeout, encoding: "utf-8" }) +} + +async function getGitCommitAsync(pluginDir: string) { + try { + const commit = await runGit(["rev-parse", "HEAD"], pluginDir) + return commit || "unknown" + } catch { + return "unknown" + } +} + +async function buildPlugin( + pluginDir: string, + name: string, + onProgress?: ProgressCallback, +): Promise { + try { + report(onProgress, ` → ${name}: installing dependencies...`, "info") + await runNpm(["install"], pluginDir) + report(onProgress, ` → ${name}: building...`, "info") + await runNpm(["run", "build"], pluginDir) + return true + } catch (error) { + report(onProgress, ` ✗ ${name}: build failed`, "error") + return false + } +} + +function needsBuild(pluginDir: string) { + const distDir = path.join(pluginDir, "dist") + return !fs.existsSync(distDir) +} + +function parseExportsFromDts(content: string) { + const exports: string[] = [] + const exportMatches = content.matchAll(/export\s*{\s*([^}]+)\s*}(?:\s*from\s*['"]([^'"]+)['"])?/g) + for (const match of exportMatches) { + const fromModule = match[2] + if (fromModule?.startsWith("@")) continue + + const names = match[1] + .split(",") + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + const cleanName = name.split(" as ").pop()?.trim() || name.trim() + if (cleanName && !cleanName.startsWith("_") && !INTERNAL_EXPORTS.has(cleanName)) { + const finalName = cleanName.replace(/^type\s+/, "") + if (name.includes("type ")) { + exports.push(`type ${finalName}`) + } else { + exports.push(finalName) + } + } + } + } + return exports +} + +async function regeneratePluginIndex() { + if (!fs.existsSync(PLUGINS_DIR)) return + + const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true }) + const plugins = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + + const exports: string[] = [] + + for (const pluginName of plugins) { + const pluginDir = path.join(PLUGINS_DIR, pluginName) + const distIndex = path.join(pluginDir, "dist", "index.d.ts") + + if (!fs.existsSync(distIndex)) continue + + const dtsContent = await fs.promises.readFile(distIndex, "utf-8") + const exportedNames = parseExportsFromDts(dtsContent) + + if (exportedNames.length > 0) { + const namedExports = exportedNames.filter((e) => !e.startsWith("type ")) + const typeExports = exportedNames.filter((e) => e.startsWith("type ")).map((e) => e.slice(5)) + + if (namedExports.length > 0) { + exports.push(`export { ${namedExports.join(", ")} } from "./${pluginName}"`) + } + if (typeExports.length > 0) { + exports.push(`export type { ${typeExports.join(", ")} } from "./${pluginName}"`) + } + } + } + + const indexContent = exports.join("\n") + "\n" + const indexPath = path.join(PLUGINS_DIR, "index.ts") + await fs.promises.writeFile(indexPath, indexContent) +} + +export async function tuiPluginInstall(onProgress?: ProgressCallback): Promise { + const lockfile = readLockfile() as Lockfile | null + + if (!lockfile) { + const message = "⚠ No quartz.lock.json found. Run 'npx quartz plugin add ' first." + report(onProgress, message, "warning") + return { success: false, errors: [message] } + } + + if (!fs.existsSync(PLUGINS_DIR)) { + fs.mkdirSync(PLUGINS_DIR, { recursive: true }) + } + + report(onProgress, "→ Installing plugins from lockfile...", "info") + let installed = 0 + let failed = 0 + const errors: string[] = [] + const pluginsToBuild: Array<{ name: string; pluginDir: string }> = [] + + for (const [name, entry] of Object.entries(lockfile.plugins)) { + const pluginDir = path.join(PLUGINS_DIR, name) + + if (fs.existsSync(pluginDir)) { + try { + const currentCommit = await getGitCommitAsync(pluginDir) + if (currentCommit === entry.commit && !needsBuild(pluginDir)) { + report(onProgress, ` ✓ ${name}@${shortCommit(entry.commit)} already installed`, "info") + installed++ + continue + } + if (currentCommit !== entry.commit) { + report(onProgress, ` → ${name}: updating to ${shortCommit(entry.commit)}...`, "info") + await runGit(["fetch", "--depth", "1", "origin"], pluginDir) + await runGit(["reset", "--hard", entry.commit], pluginDir) + } + pluginsToBuild.push({ name, pluginDir }) + installed++ + } catch (error) { + const message = ` ✗ ${name}: failed to update` + report(onProgress, message, "error") + errors.push(error instanceof Error ? error.message : String(error)) + failed++ + } + } else { + try { + report(onProgress, ` → ${name}: cloning...`, "info") + await runGit(["clone", "--depth", "1", entry.resolved, pluginDir]) + if (entry.commit !== "unknown") { + await runGit(["fetch", "--depth", "1", "origin", entry.commit], pluginDir) + await runGit(["checkout", entry.commit], pluginDir) + } + report(onProgress, ` ✓ ${name}@${shortCommit(entry.commit)}`, "success") + pluginsToBuild.push({ name, pluginDir }) + installed++ + } catch (error) { + const message = ` ✗ ${name}: failed to clone` + report(onProgress, message, "error") + errors.push(error instanceof Error ? error.message : String(error)) + failed++ + } + } + } + + if (pluginsToBuild.length > 0) { + report(onProgress, "→ Building plugins...", "info") + for (const { name, pluginDir } of pluginsToBuild) { + if (!(await buildPlugin(pluginDir, name, onProgress))) { + failed++ + installed-- + } else { + report(onProgress, ` ✓ ${name} built`, "success") + } + } + } + + await regeneratePluginIndex() + + if (failed === 0) { + report(onProgress, `✓ Installed ${installed} plugin(s)`, "success") + } else { + report(onProgress, `⚠ Installed ${installed} plugin(s), ${failed} failed`, "warning") + } + + return { success: failed === 0, installed, failed, errors } +} + +export async function tuiPluginAdd( + sources: string[], + onProgress?: ProgressCallback, +): Promise { + let lockfile = readLockfile() as Lockfile | null + if (!lockfile) { + lockfile = { version: "1.0.0", plugins: {} } + } + + if (!fs.existsSync(PLUGINS_DIR)) { + fs.mkdirSync(PLUGINS_DIR, { recursive: true }) + } + + const addedPlugins: Array<{ name: string; pluginDir: string; source: string }> = [] + const errors: string[] = [] + let failed = 0 + + for (const source of sources) { + try { + const { name, url, ref } = parseGitSource(source) + const pluginDir = path.join(PLUGINS_DIR, name) + + if (fs.existsSync(pluginDir)) { + report(onProgress, `⚠ ${name} already exists. Use 'update' to refresh.`, "warning") + continue + } + + report(onProgress, `→ Adding ${name} from ${url}...`, "info") + + if (ref) { + await runGit(["clone", "--depth", "1", "--branch", ref, url, pluginDir]) + } else { + await runGit(["clone", "--depth", "1", url, pluginDir]) + } + + const commit = await getGitCommitAsync(pluginDir) + lockfile.plugins[name] = { + source, + resolved: url, + commit, + installedAt: new Date().toISOString(), + } + + addedPlugins.push({ name, pluginDir, source }) + report(onProgress, `✓ Added ${name}@${shortCommit(commit)}`, "success") + } catch (error) { + const message = `✗ Failed to add ${source}: ${error instanceof Error ? error.message : error}` + report(onProgress, message, "error") + errors.push(error instanceof Error ? error.message : String(error)) + failed++ + } + } + + if (addedPlugins.length > 0) { + report(onProgress, "→ Building plugins...", "info") + for (const { name, pluginDir } of addedPlugins) { + if (await buildPlugin(pluginDir, name, onProgress)) { + report(onProgress, ` ✓ ${name} built`, "success") + } + } + await regeneratePluginIndex() + } + + writeLockfile(lockfile) + const pluginsJson = readPluginsJson() as PluginsJson | null + if (pluginsJson?.plugins) { + for (const { pluginDir, source } of addedPlugins) { + const manifest = readManifestFromPackageJson(pluginDir) as PluginManifest | null + const newEntry: PluginsJsonEntry = { + source, + enabled: manifest?.defaultEnabled ?? true, + options: manifest?.defaultOptions ?? {}, + order: manifest?.defaultOrder ?? 50, + } + + if (manifest?.components) { + const firstComponentKey = Object.keys(manifest.components)[0] + const comp = manifest.components[firstComponentKey] + if (comp?.defaultPosition) { + newEntry.layout = { + position: comp.defaultPosition, + priority: comp.defaultPriority ?? 50, + display: "all", + } + } + } + + pluginsJson.plugins.push(newEntry) + } + writePluginsJson(pluginsJson as Record) + } + + report(onProgress, "Updated quartz.lock.json", "info") + return { success: failed === 0, installed: addedPlugins.length, failed, errors } +} + +export async function tuiPluginRemove( + names: string[], + onProgress?: ProgressCallback, +): Promise { + const lockfile = readLockfile() as Lockfile | null + if (!lockfile) { + const message = "⚠ No plugins installed" + report(onProgress, message, "warning") + return { success: false, errors: [message] } + } + + let removed = false + let removedCount = 0 + const errors: string[] = [] + + for (const name of names) { + const pluginDir = path.join(PLUGINS_DIR, name) + + if (!lockfile.plugins[name] && !fs.existsSync(pluginDir)) { + report(onProgress, `⚠ ${name} is not installed`, "warning") + continue + } + + report(onProgress, `→ Removing ${name}...`, "info") + + try { + if (fs.existsSync(pluginDir)) { + fs.rmSync(pluginDir, { recursive: true }) + } + + delete lockfile.plugins[name] + report(onProgress, `✓ Removed ${name}`, "success") + removed = true + removedCount++ + } catch (error) { + report(onProgress, `✗ Failed to remove ${name}`, "error") + errors.push(error instanceof Error ? error.message : String(error)) + } + } + + if (removed) { + await regeneratePluginIndex() + } + + writeLockfile(lockfile) + const pluginsJson = readPluginsJson() as PluginsJson | null + if (pluginsJson?.plugins) { + pluginsJson.plugins = pluginsJson.plugins.filter( + (plugin) => + !names.includes(extractPluginName(plugin.source)) && !names.includes(plugin.source), + ) + writePluginsJson(pluginsJson as Record) + } + + report(onProgress, "Updated quartz.lock.json", "info") + return { success: errors.length === 0, installed: removedCount, failed: errors.length, errors } +} + +export async function tuiPluginUpdate( + names?: string[], + onProgress?: ProgressCallback, +): Promise { + const lockfile = readLockfile() as Lockfile | null + if (!lockfile) { + const message = "⚠ No plugins installed" + report(onProgress, message, "warning") + return { success: false, errors: [message] } + } + + const pluginsToUpdate = names ?? Object.keys(lockfile.plugins) + const updatedPlugins: Array<{ name: string; pluginDir: string }> = [] + const errors: string[] = [] + let failed = 0 + + for (const name of pluginsToUpdate) { + const entry = lockfile.plugins[name] + if (!entry) { + report(onProgress, `⚠ ${name} is not installed`, "warning") + continue + } + + const pluginDir = path.join(PLUGINS_DIR, name) + if (!fs.existsSync(pluginDir)) { + report(onProgress, `⚠ ${name} directory missing. Run 'npx quartz plugin install'.`, "warning") + continue + } + + try { + report(onProgress, `→ Updating ${name}...`, "info") + await runGit(["fetch", "--depth", "1", "origin"], pluginDir) + await runGit(["reset", "--hard", "origin/HEAD"], pluginDir) + + const newCommit = await getGitCommitAsync(pluginDir) + if (newCommit !== entry.commit) { + entry.commit = newCommit + entry.installedAt = new Date().toISOString() + updatedPlugins.push({ name, pluginDir }) + report(onProgress, `✓ Updated ${name} to ${shortCommit(newCommit)}`, "success") + } else { + report(onProgress, `✓ ${name} already up to date`, "info") + } + } catch (error) { + const message = `✗ Failed to update ${name}: ${error instanceof Error ? error.message : error}` + report(onProgress, message, "error") + errors.push(error instanceof Error ? error.message : String(error)) + failed++ + } + } + + if (updatedPlugins.length > 0) { + report(onProgress, "→ Rebuilding updated plugins...", "info") + for (const { name, pluginDir } of updatedPlugins) { + if (await buildPlugin(pluginDir, name, onProgress)) { + report(onProgress, ` ✓ ${name} rebuilt`, "success") + } + } + await regeneratePluginIndex() + } + + writeLockfile(lockfile) + report(onProgress, "Updated quartz.lock.json", "info") + + return { + success: failed === 0, + updated: updatedPlugins.map((plugin) => plugin.name), + failed, + errors, + } +} + +export async function tuiPluginRestore(onProgress?: ProgressCallback): Promise { + const lockfile = readLockfile() as Lockfile | null + if (!lockfile) { + const message = "✗ No quartz.lock.json found. Cannot restore." + report(onProgress, message, "error") + report( + onProgress, + "Run 'npx quartz plugin add ' to install plugins from scratch.", + "info", + ) + return { success: false, errors: [message] } + } + + report(onProgress, "→ Restoring plugins from lockfile...", "info") + + if (!fs.existsSync(PLUGINS_DIR)) { + fs.mkdirSync(PLUGINS_DIR, { recursive: true }) + } + + let installed = 0 + let failed = 0 + const restoredPlugins: Array<{ name: string; pluginDir: string }> = [] + const errors: string[] = [] + + for (const [name, entry] of Object.entries(lockfile.plugins)) { + const pluginDir = path.join(PLUGINS_DIR, name) + + if (fs.existsSync(pluginDir)) { + report(onProgress, `⚠ ${name}: directory exists, skipping`, "warning") + continue + } + + try { + report( + onProgress, + `→ ${name}: cloning ${entry.resolved}@${shortCommit(entry.commit)}...`, + "info", + ) + await runGit(["clone", entry.resolved, pluginDir]) + await runGit(["checkout", entry.commit], pluginDir) + report(onProgress, `✓ ${name} restored`, "success") + restoredPlugins.push({ name, pluginDir }) + installed++ + } catch (error) { + report(onProgress, `✗ ${name}: failed to restore`, "error") + errors.push(error instanceof Error ? error.message : String(error)) + failed++ + } + } + + if (restoredPlugins.length > 0) { + report(onProgress, "→ Building restored plugins...", "info") + for (const { name, pluginDir } of restoredPlugins) { + if (!(await buildPlugin(pluginDir, name, onProgress))) { + failed++ + installed-- + } else { + report(onProgress, ` ✓ ${name} built`, "success") + } + } + await regeneratePluginIndex() + } + + if (failed === 0) { + report(onProgress, `✓ Restored ${installed} plugin(s)`, "success") + } else { + report(onProgress, `⚠ Restored ${installed} plugin(s), ${failed} failed`, "warning") + } + + return { success: failed === 0, installed, failed, errors } +} diff --git a/quartz/cli/tui/cli-modules.d.ts b/quartz/cli/tui/cli-modules.d.ts new file mode 100644 index 000000000..ca84add04 --- /dev/null +++ b/quartz/cli/tui/cli-modules.d.ts @@ -0,0 +1,175 @@ +declare module "../plugin-data.js" { + export function configExists(): boolean + export function createConfigFromDefault(): Record +} + +declare module "@opentui/core" { + export type SelectOption = { name: string; description: string; value?: any } + export type TabSelectOption = { name: string; description: string; value?: any } + export interface CliRendererOptions { + exitOnCtrlC?: boolean + useAlternateScreen?: boolean + } + export function createCliRenderer(options?: CliRendererOptions): Promise +} + +declare module "@opentui/react" { + export function createRoot(renderer: unknown): { render(element: unknown): void } + export function useKeyboard( + handler: (event: { + name: string + shift?: boolean + ctrl?: boolean + meta?: boolean + eventType?: string + repeated?: boolean + }) => void, + ): void + export function useOnResize(callback: (width: number, height: number) => void): void + export function useTimeline(options?: { + duration?: number + loop?: boolean + autoplay?: boolean + }): unknown + export function useRenderer(): { destroy(): void; console: { show(): void } } + export function useTerminalDimensions(): { width: number; height: number } +} + +declare module "../../plugin-data.js" { + export function readPluginsJson(): Record | null + export function writePluginsJson(data: Record): void + export function readDefaultPluginsJson(): Record | null + export function readLockfile(): Record | null + export function writeLockfile(lockfile: Record): void + export function extractPluginName(source: string): string + export function readManifestFromPackageJson(pluginDir: string): Record | null + export function parseGitSource(source: string): { name: string; url: string; ref?: string } + export function getGitCommit(pluginDir: string): string + export function getPluginDir(name: string): string + export function pluginDirExists(name: string): boolean + export function ensurePluginsDir(): void + export function getEnrichedPlugins(): Array<{ + index: number + name: string + displayName: string + source: string + enabled: boolean + options: Record + order: number + layout: { + position: string + priority: number + display: string + condition?: string + group?: string + groupOptions?: Record + } | null + category: string | string[] + installed: boolean + locked: { + source: string + resolved: string + commit: string + installedAt: string + } | null + manifest: Record | null + currentCommit: string | null + modified: boolean + }> + export function getLayoutConfig(): Record | null + export function getGlobalConfig(): Record | null + export function updatePluginEntry(index: number, updates: Record): boolean + export function updateGlobalConfig(updates: Record): boolean + export function updateLayoutConfig(layout: Record): boolean + export function reorderPlugin(fromIndex: number, toIndex: number): boolean + export function removePluginEntry(index: number): boolean + export function addPluginEntry(entry: Record): boolean + export function configExists(): boolean + export function createConfigFromDefault(): Record + export const LOCKFILE_PATH: string + export const PLUGINS_DIR: string + export const PLUGINS_JSON_PATH: string + export const DEFAULT_PLUGINS_JSON_PATH: string +} + +declare module "../../plugin-git-handlers.js" { + export function handlePluginInstall(): Promise + export function handlePluginAdd(sources: string[]): Promise + export function handlePluginRemove(names: string[]): Promise + export function handlePluginUpdate(names?: string[]): Promise + export function handlePluginRestore(): Promise + export function handlePluginList(): Promise + export function handlePluginEnable(names: string[]): Promise + export function handlePluginDisable(names: string[]): Promise + export function handlePluginConfig(name: string, options?: { set?: string }): Promise + export function handlePluginCheck(): Promise +} + +declare module "./async-plugin-ops.js" { + export type ProgressCallback = ( + message: string, + type: "info" | "success" | "error" | "warning", + ) => void + export interface OperationResult { + success: boolean + installed?: number + failed?: number + updated?: string[] + errors?: string[] + } + export function tuiPluginUpdate( + names?: string[], + onProgress?: ProgressCallback, + ): Promise + export function tuiPluginInstall(onProgress?: ProgressCallback): Promise + export function tuiPluginRestore(onProgress?: ProgressCallback): Promise + export function tuiPluginAdd( + sources: string[], + onProgress?: ProgressCallback, + ): Promise + export function tuiPluginRemove( + names: string[], + onProgress?: ProgressCallback, + ): Promise +} + +declare module "../async-plugin-ops.js" { + export type ProgressCallback = ( + message: string, + type: "info" | "success" | "error" | "warning", + ) => void + export interface OperationResult { + success: boolean + installed?: number + failed?: number + updated?: string[] + errors?: string[] + } + export function tuiPluginUpdate( + names?: string[], + onProgress?: ProgressCallback, + ): Promise + export function tuiPluginInstall(onProgress?: ProgressCallback): Promise + export function tuiPluginRestore(onProgress?: ProgressCallback): Promise + export function tuiPluginAdd( + sources: string[], + onProgress?: ProgressCallback, + ): Promise + export function tuiPluginRemove( + names: string[], + onProgress?: ProgressCallback, + ): Promise +} + +declare module "../constants.js" { + export const version: string + export const ORIGIN_NAME: string + export const UPSTREAM_NAME: string + export const QUARTZ_SOURCE_BRANCH: string + export const QUARTZ_SOURCE_REPO: string + export const cwd: string + export const cacheDir: string + export const cacheFile: string + export const fp: string + export const contentCacheFolder: string +} diff --git a/quartz/cli/tui/components/Notification.tsx b/quartz/cli/tui/components/Notification.tsx new file mode 100644 index 000000000..129a7adba --- /dev/null +++ b/quartz/cli/tui/components/Notification.tsx @@ -0,0 +1,24 @@ +export interface NotificationMessage { + message: string + type: "success" | "error" | "info" +} + +interface NotificationProps { + message: NotificationMessage +} + +const COLOR_MAP = { + success: "green", + error: "red", + info: "cyan", +} as const + +export function Notification({ message }: NotificationProps) { + return ( + + + {message.message} + + + ) +} diff --git a/quartz/cli/tui/components/SetupWizard.tsx b/quartz/cli/tui/components/SetupWizard.tsx new file mode 100644 index 000000000..23544ed7b --- /dev/null +++ b/quartz/cli/tui/components/SetupWizard.tsx @@ -0,0 +1,116 @@ +import { type SelectOption } from "@opentui/core" +import { + createConfigFromDefault, + readDefaultPluginsJson, + writePluginsJson, +} from "../../plugin-data.js" + +interface SetupWizardProps { + onComplete: () => void +} + +type Choice = "default" | "empty" + +export function SetupWizard({ onComplete }: SetupWizardProps) { + const hasDefault = readDefaultPluginsJson() !== null + const choices: { key: Choice; label: string; description: string }[] = [ + ...(hasDefault + ? [ + { + key: "default" as Choice, + label: "Use default configuration", + description: "Copy quartz.plugins.default.json as your starting config", + }, + ] + : []), + { + key: "empty", + label: "Start with empty configuration", + description: "Create a minimal config with no plugins", + }, + ] + + const selectOptions: SelectOption[] = choices.map((choice) => ({ + name: choice.label, + description: choice.description, + value: choice.key, + })) + + return ( + + + + + No configuration found + + + + + quartz.plugins.json does not exist yet. How would you like to set up your configuration? + + + + + { + const num = parseInt(value, 10) + if (!isNaN(num)) { + const arrIdx = findPluginArrayIndex(selectedComp.pluginIndex) + if (arrIdx >= 0) { + const plugin = plugins[arrIdx] + if (plugin.layout) { + updateLayout(arrIdx, { ...plugin.layout, priority: num }) + notify(`Priority set to ${num}`, "success") + } + } + } + exitView() + }} + /> + + + + ) + } + + if (view === "edit-display" && selectedComp) { + return ( + { + const arrIdx = findPluginArrayIndex(selectedComp.pluginIndex) + if (arrIdx >= 0) { + const plugin = plugins[arrIdx] + if (plugin.layout) { + updateLayout(arrIdx, { ...plugin.layout, display: mode }) + notify(`Display set to ${mode}`, "success") + } + } + exitView() + }} + onCancel={exitView} + /> + ) + } + + if (view === "edit-condition" && selectedComp) { + return ( + + + + {currentZone} › {selectedComp.displayName} › Condition + + + + Set Condition: {selectedComp.displayName} + + + + Available: not-index, has-tags, has-backlinks, has-toc (or empty to remove) + + + + Condition: + + { + const arrIdx = findPluginArrayIndex(selectedComp.pluginIndex) + if (arrIdx >= 0) { + const plugin = plugins[arrIdx] + if (plugin.layout) { + const newLayout = { ...plugin.layout } + if (value.trim()) { + newLayout.condition = value.trim() + } else { + delete newLayout.condition + } + updateLayout(arrIdx, newLayout) + notify( + value.trim() ? `Condition set to ${value.trim()}` : "Condition removed", + "success", + ) + } + } + exitView() + }} + /> + + + + ) + } + + if (view === "confirm-remove-layout" && selectedComp) { + return ( + + + + {currentZone} › {selectedComp.displayName} + + + + + Remove {selectedComp.displayName} from layout? + + + + The plugin will remain installed but won't appear in the layout. + + + Confirm removal? + { + const arrIdx = findPluginArrayIndex(selectedComp.pluginIndex) + if (arrIdx >= 0) { + updateLayout(arrIdx, null) + setSelectedComponent(Math.max(0, selectedComponent - 1)) + notify(`Removed ${selectedComp.displayName} from layout`, "success") + } + exitView() + }} + onCancel={() => exitView()} + /> + + + ) + } + + if (view === "groups") { + return ( + { + saveLayout(newLayout) + refreshPlugins() + notify("Groups updated", "success") + exitView() + }} + onCancel={exitView} + /> + ) + } + + if (view === "page-types") { + return ( + { + saveLayout(newLayout) + }} + onCancel={exitView} + /> + ) + } + + const renderZoneBox = (zone: Zone) => { + const comps = zoneComponents[zone] + const isFocused = getActiveZone() === zone + const isDrilledIn = isFocused && drillMode + const maxItems = maxItemsForZone(zone) + const visibleComps = comps.slice(0, maxItems) + const overflowCount = comps.length - visibleComps.length + + return ( + <> + + {isFocused ? ( + + + {zone} ({comps.length}) + + + ) : ( + + {zone} ({comps.length}) + + )} + + {isDrilledIn ? ( + comps.length === 0 ? ( + + Empty zone + + ) : ( + { + const zone = option?.value as Zone | undefined + if (zone) onSelect(zone) + }} + showDescription={false} + /> + + Enter: select │ Esc: cancel + + + ) +} + +interface DisplayModeViewProps { + component: ZoneComponent + fromZone: Zone + onSelect: (mode: string) => void + onCancel: () => void +} + +const DISPLAY_MODES = ["all", "desktop-only", "mobile-only"] as const + +function DisplayModeView({ component, fromZone, onSelect, onCancel }: DisplayModeViewProps) { + useKeyboard((event) => { + if (event.name === "escape") onCancel() + }) + + const modeOptions: SelectOption[] = DISPLAY_MODES.map((mode) => ({ + name: mode, + description: mode === component.display ? "(current)" : "", + value: mode, + })) + + return ( + + + + {fromZone} › {component.displayName} › Display + + + + Display mode for {component.displayName}: + + { + if (value.trim()) { + const newLayout = { ...(layout ?? {}) } + const currentGroups = + ((newLayout as Record).groups as Record< + string, + Record + >) ?? {} + currentGroups[value.trim()] = { direction: "row", gap: "0.5rem" } + ;(newLayout as Record).groups = currentGroups + onSave(newLayout) + } else { + setMode("list") + } + }} + /> + + + + ) + } + + if (mode === "confirm-delete" && selectedGroup) { + return ( + + + + Delete group "{selectedGroup[0]}"? + + + + { + const newLayout = { ...(layout ?? {}) } + const currentGroups = { + ...(((newLayout as Record).groups as Record< + string, + Record + >) ?? {}), + } + delete currentGroups[selectedGroup[0]] + ;(newLayout as Record).groups = currentGroups + onSave(newLayout) + }} + onCancel={() => setMode("list")} + /> + + + ) + } + + if (mode === "edit" && selectedGroup && editField) { + const [groupName, groupConfig] = selectedGroup + const currentValue = String((groupConfig as Record)[editField] ?? "") + + return ( + + + + Edit {groupName} → {editField} + + + + + {editField === "direction" + ? 'Values: "row" or "column"' + : 'Value: CSS gap (e.g. "0.5rem")'} + + + + {editField}: + + { + const newLayout = { ...(layout ?? {}) } + const currentGroups = { + ...(((newLayout as Record).groups as Record< + string, + Record + >) ?? {}), + } + currentGroups[groupName] = { ...currentGroups[groupName], [editField]: value } + ;(newLayout as Record).groups = currentGroups + + if (editField === "direction") { + setEditField("gap") + } else { + onSave(newLayout) + } + }} + /> + + + + ) + } + + const groupOptions: SelectOption[] = groupEntries.map(([name, config]) => { + const cfg = config as Record + return { + name, + description: `direction=${String(cfg.direction ?? "row")} gap=${String(cfg.gap ?? "0")}`, + value: name, + } + }) + + return ( + + + Layout Groups + + {groupEntries.length === 0 ? ( + + No groups defined + + ) : ( + { + const name = value.trim() + if (!name) { + setMode("list") + return + } + + if (byPageType[name]) { + notify(`Page type "${name}" already exists`, "error") + setMode("list") + return + } + + const newLayout = { ...(layout ?? {}) } + const currentByPageType = { + ...(((newLayout as LayoutConfig).byPageType as Record< + string, + PageTypeOverride + >) ?? {}), + } + currentByPageType[name] = {} + ;(newLayout as LayoutConfig).byPageType = currentByPageType + onSave(newLayout) + setSelected(pageTypeEntries.length) + setMode("list") + }} + /> + + + + ) + } + + if (mode === "confirm-delete" && selectedPageType) { + return ( + + + + Delete page type "{selectedPageType}"? + + + + { + const newLayout = { ...(layout ?? {}) } + const currentByPageType = { + ...(((newLayout as LayoutConfig).byPageType as Record) ?? + {}), + } + delete currentByPageType[selectedPageType] + ;(newLayout as LayoutConfig).byPageType = currentByPageType + onSave(newLayout) + setSelected(Math.max(0, selected - 1)) + setMode("list") + }} + onCancel={() => setMode("list")} + /> + + + ) + } + + if (mode === "exclude" && selectedPageType) { + return ( + { + updatePageType(selectedPageType, (current) => ({ ...current, exclude: nextExclude })) + }} + onCancel={() => setMode("detail")} + /> + ) + } + + if (mode === "positions" && selectedPageType) { + return ( + { + updatePageType(selectedPageType, (current) => ({ ...current, positions: nextPositions })) + }} + onCancel={() => setMode("detail")} + /> + ) + } + + if (mode === "detail" && selectedPageType) { + const excludeCount = selectedOverride.exclude?.length ?? 0 + const positionsCount = selectedOverride.positions + ? Object.keys(selectedOverride.positions).length + : 0 + + const options: SelectOption[] = [ + { + name: `Excluded plugins (${excludeCount} excluded)`, + description: "", + value: "exclude", + }, + { + name: `Position overrides (${positionsCount} overrides)`, + description: "", + value: "positions", + }, + ] + + return ( + + + Edit page type "{selectedPageType}" + + setSelected(Math.max(0, index))} + onSelect={() => { + if (!selectedEntry) return + setMode("detail") + }} + showDescription + showScrollIndicator + /> + )} + + + Enter: edit │ n: new │ d: delete │ Esc: back + + + + ) +} + +interface PageTypeExcludeViewProps { + pageType: string + exclude: string[] + plugins: EnrichedPlugin[] + onUpdate: (nextExclude: string[]) => void + onCancel: () => void +} + +function PageTypeExcludeView({ + pageType, + exclude, + plugins, + onUpdate, + onCancel, +}: PageTypeExcludeViewProps) { + const enabledPlugins = plugins.filter((plugin) => plugin.enabled) + + useKeyboard((event) => { + if (event.name === "escape") onCancel() + }) + + const options: SelectOption[] = enabledPlugins.map((plugin) => { + const isExcluded = exclude.includes(plugin.name) + return { + name: plugin.displayName, + description: isExcluded ? "excluded" : "included", + value: plugin.name, + } + }) + + return ( + + + Excluded plugins for "{pageType}": + + {enabledPlugins.length === 0 ? ( + + No enabled plugins + + ) : ( + { + const pluginName = option?.value as string | undefined + if (!pluginName) return + setActivePlugin(pluginName) + setMode("select-position") + }} + showDescription + showScrollIndicator + /> + )} + + + Enter: select │ Esc: back + + + + ) + } + + if (mode === "select-position" && activePlugin) { + const options: SelectOption[] = ZONES.map((zone) => ({ + name: zone, + description: "", + value: zone, + })) + + return ( + + + Select position for "{activePlugin}": + + setSelected(Math.max(0, index))} + onSelect={() => { + const entry = entries[selected] + if (!entry || entry.kind !== "plugin") return + setActivePlugin(entry.pluginName) + setMode("select-position") + }} + showDescription + showScrollIndicator + /> + )} + + + Enter: edit │ n: new │ d: delete │ Esc: back + + + + ) +} diff --git a/quartz/cli/tui/panels/PluginsPanel.tsx b/quartz/cli/tui/panels/PluginsPanel.tsx new file mode 100644 index 000000000..fbd122cb9 --- /dev/null +++ b/quartz/cli/tui/panels/PluginsPanel.tsx @@ -0,0 +1,1578 @@ +import { useState, useCallback, useMemo, useEffect } from "react" +import { useKeyboard } from "@opentui/react" +import { usePlugins } from "../hooks/usePlugins.js" + +type SortMode = "config" | "alpha" | "priority" + +const SORT_MODES: SortMode[] = ["config", "alpha", "priority"] + +type View = "list" | "add" | "confirm-remove" | "order" + +interface PluginsPanelProps { + notify: (message: string, type?: "success" | "error" | "info") => void + onFocusChange: (focused: boolean) => void + maxHeight?: number +} + +interface PluginSelectOption { + name: string + description: string + value?: unknown +} + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + +function Spinner({ label }: { label: string }) { + const [frame, setFrame] = useState(0) + + useEffect(() => { + const timer = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80) + return () => clearInterval(timer) + }, []) + + return ( + + {SPINNER_FRAMES[frame]} {label} + + ) +} + +function ConfirmPrompt({ onConfirm, onCancel }: { onConfirm: () => void; onCancel: () => void }) { + useKeyboard((event) => { + if (event.name === "y") onConfirm() + else if (event.name === "n" || event.name === "escape") onCancel() + }) + + return ( + + Confirm? (y/n): + + ) +} + +function parseJsonOrString(value: string): unknown { + try { + return JSON.parse(value) + } catch { + return value + } +} + +function getDefaultForKey( + manifest: Record | null, + keyPath: string[], +): unknown | undefined { + if (!manifest) return undefined + const defaults = manifest.defaultOptions as Record | undefined + if (!defaults) return undefined + let current: unknown = defaults + for (const key of keyPath) { + if (current === null || current === undefined || typeof current !== "object") return undefined + current = (current as Record)[key] + } + return current +} + +interface OptionSchemaEntry { + type: "enum" | "array" + values?: string[] + items?: { type: "enum"; values: string[] } +} + +function getOptionSchema( + manifest: Record | null, + key: string, +): OptionSchemaEntry | null { + if (!manifest) return null + const schema = manifest.optionSchema as Record | undefined + if (!schema) return null + const entry = schema[key] + if (!entry || typeof entry !== "object") return null + return entry as OptionSchemaEntry +} + +function isDefault(current: unknown, defaultVal: unknown): boolean { + if (defaultVal === undefined) return false + return JSON.stringify(current) === JSON.stringify(defaultVal) +} + +function formatValue(value: unknown): string { + if (value === null) return "null" + if (value === undefined) return "undefined" + if (typeof value === "string") return `"${value}"` + if (typeof value === "boolean" || typeof value === "number") return String(value) + if (Array.isArray(value)) return `[${value.map(formatValue).join(", ")}]` + if (typeof value === "object") { + const entries = Object.entries(value as Record) + if (entries.length === 0) return "{}" + return `{${entries.map(([k, v]) => `${k}: ${formatValue(v)}`).join(", ")}}` + } + return String(value) +} + +function isEditableObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function normalizeCategories(category: string | string[] | undefined): string[] { + if (!category) return ["unknown"] + if (Array.isArray(category)) return category.length > 0 ? category : ["unknown"] + return [category] +} + +interface CategorizedEntry { + plugin: ReturnType["plugins"][number] + sortedIndex: number + category: string +} + +export function PluginsPanel({ notify, onFocusChange, maxHeight }: PluginsPanelProps) { + const { plugins, refresh, toggleEnabled, setPluginOrder, setPluginOptions } = usePlugins() + + const [selectedIndex, setSelectedIndex] = useState(0) + const [view, setView] = useState("list") + const [loading, setLoading] = useState(false) + const [progressMessages, setProgressMessages] = useState([]) + const [sortMode, setSortMode] = useState("config") + const [editingKey, setEditingKey] = useState(null) + const [editingSubKey, setEditingSubKey] = useState(null) + const [editingArrayIndex, setEditingArrayIndex] = useState(null) + const [listKey, setListKey] = useState(0) + const [showOptions, setShowOptions] = useState(false) + const [highlightedOptionIndex, setHighlightedOptionIndex] = useState(0) + const [addingObjectKey, setAddingObjectKey] = useState(false) + const [newObjectKeyName, setNewObjectKeyName] = useState(null) + const [highlightedSubKeyIndex, setHighlightedSubKeyIndex] = useState(0) + const [highlightedArrayItemIndex, setHighlightedArrayItemIndex] = useState(0) + const [highlightedBoolIndex, setHighlightedBoolIndex] = useState(0) + const [highlightedEnumIndex, setHighlightedEnumIndex] = useState(0) + + const sortedPlugins = useMemo(() => { + const sorted = [...plugins] + sorted.sort((a, b) => { + const aCat = normalizeCategories(a.category)[0] + const bCat = normalizeCategories(b.category)[0] + const catCmp = aCat.localeCompare(bCat) + if (catCmp !== 0) return catCmp + switch (sortMode) { + case "alpha": + return a.displayName.localeCompare(b.displayName) + case "priority": + return a.order - b.order + case "config": + default: + return a.index - b.index + } + }) + return sorted + }, [plugins, sortMode]) + + const categorizedEntries = useMemo(() => { + const entries: CategorizedEntry[] = [] + for (let i = 0; i < sortedPlugins.length; i++) { + const plugin = sortedPlugins[i] + const cats = normalizeCategories(plugin.category) + for (const cat of cats) { + entries.push({ plugin, sortedIndex: i, category: cat }) + } + } + entries.sort((a, b) => { + const catCmp = a.category.localeCompare(b.category) + if (catCmp !== 0) return catCmp + switch (sortMode) { + case "alpha": + return a.plugin.displayName.localeCompare(b.plugin.displayName) + case "priority": + return a.plugin.order - b.plugin.order + case "config": + default: + return a.plugin.index - b.plugin.index + } + }) + return entries + }, [sortedPlugins, sortMode]) + + const listOptions = useMemo(() => { + const result: PluginSelectOption[] = [] + let lastCategory = "" + for (let i = 0; i < categorizedEntries.length; i++) { + const entry = categorizedEntries[i] + const cat = entry.category + + if (cat !== lastCategory) { + result.push({ + name: `── ${cat.toUpperCase()} ──`, + description: "", + value: { type: "separator" as const }, + }) + lastCategory = cat + } + + const plugin = entry.plugin + const status = plugin.enabled ? "● ON " : "○ OFF" + const configIcon = + plugin.options && + typeof plugin.options === "object" && + Object.keys(plugin.options).length > 0 + ? " ⚙" + : "" + const orderLabel = ` [${plugin.order}]` + const installedLabel = plugin.installed ? "" : " │ NOT INSTALLED" + result.push({ + name: ` ${status} ${plugin.displayName}${configIcon}${orderLabel}`, + description: `${plugin.source}${installedLabel}`, + value: { type: "plugin" as const, sortedIndex: entry.sortedIndex }, + }) + } + return result + }, [categorizedEntries]) + + const resolvePluginIndex = useCallback( + (selectIdx: number): number | null => { + const item = listOptions[selectIdx] + if (!item?.value || typeof item.value !== "object") return null + const val = item.value as { type: string; sortedIndex?: number } + if (val.type === "plugin" && typeof val.sortedIndex === "number") return val.sortedIndex + return null + }, + [listOptions], + ) + + const selectedPlugin = useMemo(() => { + const pluginIdx = resolvePluginIndex(selectedIndex) + return pluginIdx !== null ? (sortedPlugins[pluginIdx] ?? null) : null + }, [selectedIndex, resolvePluginIndex, sortedPlugins]) + + const enterView = useCallback( + (v: View) => { + setView(v) + onFocusChange(true) + }, + [onFocusChange], + ) + + const exitView = useCallback(() => { + setView("list") + setListKey((k) => k + 1) + onFocusChange(false) + }, [onFocusChange]) + + useEffect(() => { + if (selectedIndex >= sortedPlugins.length) { + setSelectedIndex(Math.max(0, sortedPlugins.length - 1)) + } + }, [selectedIndex, sortedPlugins.length]) + + useEffect(() => { + if (showOptions) { + setEditingKey(null) + setEditingSubKey(null) + setEditingArrayIndex(null) + setHighlightedOptionIndex(0) + setHighlightedSubKeyIndex(0) + setHighlightedArrayItemIndex(0) + setHighlightedBoolIndex(0) + setHighlightedEnumIndex(0) + setAddingObjectKey(false) + setNewObjectKeyName(null) + } + }, [showOptions]) + + useEffect(() => { + if (view === "confirm-remove" && !selectedPlugin) { + exitView() + } + }, [view, selectedPlugin, exitView]) + + useEffect(() => { + if (editingKey !== null && selectedPlugin) { + setHighlightedSubKeyIndex(0) + setHighlightedArrayItemIndex(0) + const currentValue = selectedPlugin.options[editingKey] + setHighlightedBoolIndex(currentValue === true ? 0 : 1) + const schema = getOptionSchema( + selectedPlugin.manifest as Record | null, + editingKey, + ) + if (schema?.type === "enum" && schema.values && typeof currentValue === "string") { + const idx = schema.values.indexOf(currentValue) + setHighlightedEnumIndex(idx >= 0 ? idx : 0) + } else { + setHighlightedEnumIndex(0) + } + } + }, [editingKey, selectedPlugin]) + + useEffect(() => { + if (editingArrayIndex !== null && editingKey !== null && selectedPlugin) { + const currentValue = selectedPlugin.options[editingKey] + if (Array.isArray(currentValue)) { + const currentItem = currentValue[editingArrayIndex] + const schema = getOptionSchema( + selectedPlugin.manifest as Record | null, + editingKey, + ) + if ( + schema?.type === "array" && + schema.items?.type === "enum" && + schema.items.values && + typeof currentItem === "string" + ) { + const idx = schema.items.values.indexOf(currentItem) + setHighlightedEnumIndex(idx >= 0 ? idx : 0) + } + } + } + }, [editingArrayIndex, editingKey, selectedPlugin]) + + useEffect(() => { + if (editingSubKey !== null && editingKey !== null && selectedPlugin) { + const parentValue = selectedPlugin.options[editingKey] + if (isEditableObject(parentValue)) { + const subVal = parentValue[editingSubKey] + setHighlightedBoolIndex(subVal === true ? 0 : 1) + } + } + }, [editingSubKey, editingKey, selectedPlugin]) + + useKeyboard((event) => { + if (view !== "list" || loading || showOptions) return + + if (event.name === "s") { + setSortMode((current: SortMode) => { + const nextIdx = (SORT_MODES.indexOf(current) + 1) % SORT_MODES.length + const next = SORT_MODES[nextIdx] + setSelectedIndex(0) + notify(`Sort: ${next}`, "info") + return next + }) + } + + if (event.name === "e" && selectedPlugin) { + if (!selectedPlugin.enabled) { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + toggleEnabled(origIdx) + notify(`Enabled ${selectedPlugin.displayName}`, "success") + } + } + } + if (event.name === "d" && selectedPlugin) { + if (selectedPlugin.enabled) { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + toggleEnabled(origIdx) + notify(`Disabled ${selectedPlugin.displayName}`, "success") + } + } + } + + if (event.name === "a") { + enterView("add") + } + + if (event.name === "r" && selectedPlugin) { + enterView("confirm-remove") + } + + if (event.name === "o" && selectedPlugin) { + const hasOptions = + selectedPlugin.options && + typeof selectedPlugin.options === "object" && + Object.keys(selectedPlugin.options).length > 0 + if (hasOptions) { + setShowOptions(true) + onFocusChange(true) + } else { + notify("No options to configure", "info") + } + } + + if (event.name === "O" && selectedPlugin) { + enterView("order") + } + + if (event.name === "u") { + setLoading(true) + setProgressMessages(["→ Updating plugins..."]) + notify("Updating plugins...", "info") + import("../async-plugin-ops.js").then(({ tuiPluginUpdate }) => { + tuiPluginUpdate(undefined, (msg: string) => { + setProgressMessages((prev) => [...prev.slice(-20), msg]) + }) + .then((result) => { + refresh() + if (result.success) { + const count = result.updated?.length ?? 0 + notify(`Updated ${count} plugin(s)`, "success") + } else { + notify("Some updates failed", "error") + } + }) + .catch(() => notify("Update failed", "error")) + .finally(() => { + setLoading(false) + setProgressMessages([]) + }) + }) + } + + if (event.name === "i") { + setLoading(true) + setProgressMessages(["→ Installing plugins from lockfile..."]) + import("../async-plugin-ops.js").then(({ tuiPluginInstall }) => { + tuiPluginInstall((msg: string) => { + setProgressMessages((prev) => [...prev.slice(-20), msg]) + }) + .then((result) => { + refresh() + if (result.success) { + notify(`Installed ${result.installed ?? 0} plugin(s)`, "success") + } else { + notify("Some installs failed", "error") + } + }) + .catch(() => notify("Install failed", "error")) + .finally(() => { + setLoading(false) + setProgressMessages([]) + }) + }) + } + }) + + useKeyboard((event) => { + if (view !== "add") return + if (event.name === "escape") exitView() + }) + + useKeyboard((event) => { + if (view !== "order") return + if (event.name === "escape") exitView() + }) + + useKeyboard((event) => { + if (!showOptions) return + if (event.name === "escape") { + if (addingObjectKey || newObjectKeyName !== null) { + setAddingObjectKey(false) + setNewObjectKeyName(null) + } else if (editingArrayIndex !== null) { + setEditingArrayIndex(null) + } else if (editingSubKey) { + setEditingSubKey(null) + } else if (editingKey) { + setEditingKey(null) + setAddingObjectKey(false) + setNewObjectKeyName(null) + } else { + setShowOptions(false) + onFocusChange(false) + } + } + if (!editingKey && selectedPlugin) { + const optionEntries = Object.entries(selectedPlugin.options) + const count = optionEntries.length + if (count === 0) return + if (event.name === "up") { + setHighlightedOptionIndex((prev) => (prev > 0 ? prev - 1 : count - 1)) + } + if (event.name === "down") { + setHighlightedOptionIndex((prev) => (prev < count - 1 ? prev + 1 : 0)) + } + if (event.name === "return") { + const key = optionEntries[highlightedOptionIndex]?.[0] + if (key) { + setEditingKey(key) + setEditingSubKey(null) + setEditingArrayIndex(null) + } + } + if (event.name === "d" && event.shift) { + const entry = optionEntries[highlightedOptionIndex] + if (!entry) return + const [key] = entry + const defaultVal = getDefaultForKey( + selectedPlugin.manifest as Record | null, + [key], + ) + if (defaultVal === undefined) { + notify("No default available for this option", "info") + return + } + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + setPluginOptions(origIdx, key, defaultVal) + notify(`Restored ${key} to default`, "success") + } + } + } else if (editingKey && selectedPlugin) { + const currentValue = selectedPlugin.options[editingKey] + + if (editingSubKey && isEditableObject(currentValue)) { + const parentValue = currentValue + const currentSubValue = parentValue[editingSubKey] + if (typeof currentSubValue === "boolean") { + if (event.name === "up" || event.name === "down") { + setHighlightedBoolIndex((prev) => (prev === 0 ? 1 : 0)) + } + if (event.name === "return") { + const newVal = highlightedBoolIndex === 0 + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + const updatedParent = { ...parentValue, [editingSubKey]: newVal } + setPluginOptions(origIdx, editingKey, updatedParent) + notify(`Set ${editingKey}.${editingSubKey} = ${newVal}`, "success") + } + setEditingSubKey(null) + } + } + } else if ( + editingArrayIndex === null && + !editingSubKey && + !addingObjectKey && + newObjectKeyName === null + ) { + if (isEditableObject(currentValue)) { + const subEntries = Object.entries(currentValue) + const totalItems = subEntries.length + 1 + if (event.name === "up") { + setHighlightedSubKeyIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)) + } + if (event.name === "down") { + setHighlightedSubKeyIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)) + } + if (event.name === "return") { + if (highlightedSubKeyIndex === subEntries.length) { + setAddingObjectKey(true) + } else { + const subKey = subEntries[highlightedSubKeyIndex]?.[0] + if (subKey) setEditingSubKey(subKey) + } + } + if (event.name === "x" && highlightedSubKeyIndex < subEntries.length) { + const subKey = subEntries[highlightedSubKeyIndex]?.[0] + if (subKey) { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + const updated = { ...currentValue } + delete updated[subKey] + setPluginOptions(origIdx, editingKey, updated) + notify(`Removed field ${editingKey}.${subKey}`, "success") + setHighlightedSubKeyIndex((prev) => + prev >= subEntries.length - 1 ? Math.max(0, subEntries.length - 2) : prev, + ) + } + } + } + } else if (Array.isArray(currentValue)) { + const totalItems = currentValue.length + 1 + if (event.name === "up" && !event.shift) { + setHighlightedArrayItemIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)) + } + if (event.name === "down" && !event.shift) { + setHighlightedArrayItemIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)) + } + if (event.name === "return") { + if (highlightedArrayItemIndex === currentValue.length) { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + const newArray = [...currentValue, ""] + setPluginOptions(origIdx, editingKey, newArray) + notify(`Added item to ${editingKey}`, "success") + setEditingArrayIndex(newArray.length - 1) + } + } else { + setEditingArrayIndex(highlightedArrayItemIndex) + } + } + if (event.name === "x" && highlightedArrayItemIndex < currentValue.length) { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + const newArray = [...currentValue] + newArray.splice(highlightedArrayItemIndex, 1) + setPluginOptions(origIdx, editingKey, newArray) + notify(`Removed ${editingKey}[${highlightedArrayItemIndex}]`, "success") + setHighlightedArrayItemIndex((prev) => + prev >= newArray.length ? Math.max(0, newArray.length - 1) : prev, + ) + } + } + if ( + event.name === "up" && + event.shift && + highlightedArrayItemIndex > 0 && + highlightedArrayItemIndex < currentValue.length + ) { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + const newArray = [...currentValue] + const idx = highlightedArrayItemIndex + ;[newArray[idx - 1], newArray[idx]] = [newArray[idx], newArray[idx - 1]] + setPluginOptions(origIdx, editingKey, newArray) + setHighlightedArrayItemIndex(idx - 1) + } + } + if ( + event.name === "down" && + event.shift && + highlightedArrayItemIndex < currentValue.length - 1 + ) { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + const newArray = [...currentValue] + const idx = highlightedArrayItemIndex + ;[newArray[idx], newArray[idx + 1]] = [newArray[idx + 1], newArray[idx]] + setPluginOptions(origIdx, editingKey, newArray) + setHighlightedArrayItemIndex(idx + 1) + } + } + } else if (typeof currentValue === "boolean") { + if (event.name === "up" || event.name === "down") { + setHighlightedBoolIndex((prev) => (prev === 0 ? 1 : 0)) + } + if (event.name === "return") { + const newVal = highlightedBoolIndex === 0 + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + setPluginOptions(origIdx, editingKey, newVal) + notify(`Set ${editingKey} = ${newVal}`, "success") + } + setEditingKey(null) + } + } else { + const optSchema = getOptionSchema( + selectedPlugin.manifest as Record | null, + editingKey, + ) + if (optSchema?.type === "enum" && optSchema.values) { + if (event.name === "up" || event.name === "down") { + const len = optSchema.values.length + setHighlightedEnumIndex((prev) => + event.name === "up" + ? prev > 0 + ? prev - 1 + : len - 1 + : prev < len - 1 + ? prev + 1 + : 0, + ) + } + if (event.name === "return") { + const newVal = optSchema.values[highlightedEnumIndex] + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0 && newVal !== undefined) { + setPluginOptions(origIdx, editingKey, newVal) + notify(`Set ${editingKey} = ${newVal}`, "success") + } + setEditingKey(null) + } + } + } + } else if (editingArrayIndex !== null && Array.isArray(currentValue)) { + const optSchema = getOptionSchema( + selectedPlugin.manifest as Record | null, + editingKey, + ) + if ( + optSchema?.type === "array" && + optSchema.items?.type === "enum" && + optSchema.items.values + ) { + const enumValues = optSchema.items.values + if (event.name === "up" || event.name === "down") { + const len = enumValues.length + setHighlightedEnumIndex((prev) => + event.name === "up" ? (prev > 0 ? prev - 1 : len - 1) : prev < len - 1 ? prev + 1 : 0, + ) + } + if (event.name === "return") { + const newVal = enumValues[highlightedEnumIndex] + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0 && newVal !== undefined) { + const newArray = [...currentValue] + newArray[editingArrayIndex] = newVal + setPluginOptions(origIdx, editingKey, newArray) + notify(`Set ${editingKey}[${editingArrayIndex}] = ${newVal}`, "success") + } + setEditingArrayIndex(null) + } + } + } + } + }) + + const optionSummary = useMemo(() => { + if (!selectedPlugin?.options || typeof selectedPlugin.options !== "object") return "none" + const keys = Object.keys(selectedPlugin.options) + if (keys.length === 0) return "none" + const defaults = (selectedPlugin.manifest as Record | null)?.defaultOptions as + | Record + | undefined + const summaryParts = keys.map((key) => { + const val = selectedPlugin.options[key] + const defVal = defaults ? defaults[key] : undefined + const tag = isDefault(val, defVal) ? "" : "*" + return `${tag}${key}` + }) + return summaryParts.join(", ") + }, [selectedPlugin]) + + if (loading) { + const progressHeight = maxHeight ? Math.max(3, maxHeight - 5) : undefined + return ( + + + Progress + + + + + + + {progressMessages.length === 0 ? ( + + Waiting for updates... + + ) : ( + progressMessages.map((message, index) => ( + {message} + )) + )} + + + + ) + } + + if (view === "add") { + return ( + + + Add Plugin + + + Enter a git source (e.g., github:owner/repo) + + + Source: + + { + const source = value.trim() + if (!source) { + exitView() + return + } + exitView() + setLoading(true) + setProgressMessages([`→ Adding ${source}...`]) + import("../async-plugin-ops.js").then(({ tuiPluginAdd }) => { + tuiPluginAdd([source], (msg: string) => { + setProgressMessages((prev) => [...prev.slice(-20), msg]) + }) + .then((result) => { + refresh() + if (result.success) { + notify(`Added ${result.installed ?? 0} plugin(s)`, "success") + } else { + notify("Failed to add plugin", "error") + } + }) + .catch(() => notify("Add failed", "error")) + .finally(() => { + setLoading(false) + setProgressMessages([]) + }) + }) + }} + /> + + + + Enter: confirm │ Esc: cancel + + + ) + } + + if (view === "confirm-remove" && selectedPlugin) { + return ( + + + + Remove {selectedPlugin.displayName}? + + + + Source: {selectedPlugin.source} + + + Confirm removal? + { + const pluginName = selectedPlugin.name + exitView() + setLoading(true) + setProgressMessages([`→ Removing ${selectedPlugin.displayName}...`]) + import("../async-plugin-ops.js").then(({ tuiPluginRemove }) => { + tuiPluginRemove([pluginName], (msg: string) => { + setProgressMessages((prev) => [...prev.slice(-20), msg]) + }) + .then((result) => { + refresh() + setSelectedIndex(Math.max(0, selectedIndex - 1)) + if (result.success) { + notify(`Removed ${selectedPlugin.displayName}`, "success") + } else { + notify("Remove failed", "error") + } + }) + .catch(() => notify("Remove failed", "error")) + .finally(() => { + setLoading(false) + setProgressMessages([]) + }) + }) + }} + onCancel={() => exitView()} + /> + + + ) + } + + if (view === "order" && selectedPlugin) { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + return ( + + + Set Order for {selectedPlugin.displayName} + + + Current: {selectedPlugin.order} + + + New order: + + { + const num = parseInt(value, 10) + if (!isNaN(num) && origIdx >= 0) { + setPluginOrder(origIdx, num) + notify(`Set order to ${num}`, "success") + } + exitView() + }} + /> + + + + ) + } + + if (view === "confirm-remove" && !selectedPlugin) { + exitView() + } + + const renderOptionsPanel = () => { + if (!selectedPlugin || !showOptions) return null + + const optionEntries = Object.entries(selectedPlugin.options) + const defaults = (selectedPlugin.manifest as Record | null)?.defaultOptions as + | Record + | undefined + + const renderEditPanel = () => { + if (!editingKey) { + // Show preview of highlighted option + const highlightedEntry = optionEntries[highlightedOptionIndex] + if (!highlightedEntry) { + return ( + + + Select an option to edit + + + ) + } + const [hKey, hValue] = highlightedEntry + const hDefault = getDefaultForKey( + selectedPlugin.manifest as Record | null, + [hKey], + ) + const hIsDefault = isDefault(hValue, hDefault) + const typeLabel = isEditableObject(hValue) + ? "object" + : Array.isArray(hValue) + ? "array" + : typeof hValue + return ( + + + {hKey} + ({typeLabel}) + + + Value: + {formatValue(hValue)} + + {hIsDefault ? ( + + ✓ Default value + + ) : hDefault !== undefined ? ( + + Default: {formatValue(hDefault)} + + ) : null} + + + Enter: edit │ D: restore default │ Esc: close options + + + + ) + } + + const currentValue = selectedPlugin.options[editingKey] + const defaultVal = getDefaultForKey( + selectedPlugin.manifest as Record | null, + [editingKey], + ) + const isDefaultVal = isDefault(currentValue, defaultVal) + + if (editingKey && editingSubKey && isEditableObject(currentValue)) { + const parentValue = currentValue + const currentSubValue = parentValue[editingSubKey] + const defaultParent = getDefaultForKey( + selectedPlugin.manifest as Record | null, + [editingKey], + ) + const defaultSubValue = isEditableObject(defaultParent) + ? defaultParent[editingSubKey] + : undefined + const isDefaultSub = isDefault(currentSubValue, defaultSubValue) + + if (typeof currentSubValue === "boolean") { + const boolItems = [ + { label: "true", isCurrent: currentSubValue === true }, + { label: "false", isCurrent: currentSubValue === false }, + ] + return ( + + + + {editingKey} → {editingSubKey} + + (boolean) + + + Current: {String(currentSubValue)} + {isDefaultSub && (default)} + + {defaultSubValue !== undefined && !isDefaultSub && ( + + Default: {formatValue(defaultSubValue)} + + )} + + {boolItems.map((item, i) => { + const isHighlighted = i === highlightedBoolIndex + const fg = isHighlighted ? "#FFFFFF" : "#888888" + const marker = isHighlighted ? "▸ " : " " + const currentTag = item.isCurrent ? " ◀" : "" + return ( + + + {marker} + {item.label} + {currentTag} + + + ) + })} + + + ↑↓: toggle │ Enter: select │ Esc: back + + + ) + } + + return ( + + + + {editingKey} → {editingSubKey} + + + + Current: {formatValue(currentSubValue)} + {isDefaultSub && (default)} + + {defaultSubValue !== undefined && !isDefaultSub && ( + + Default: {formatValue(defaultSubValue)} + + )} + + New value: + + { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + const updatedParent = { + ...parentValue, + [editingSubKey]: parseJsonOrString(value), + } + setPluginOptions(origIdx, editingKey, updatedParent) + notify(`Set ${editingKey}.${editingSubKey}`, "success") + } + setEditingSubKey(null) + }} + /> + + + + Enter: save │ Esc: back + + + ) + } + + if (editingKey && editingArrayIndex !== null && Array.isArray(currentValue)) { + const currentItem = currentValue[editingArrayIndex] + const defaultArr = Array.isArray(defaultVal) ? defaultVal : undefined + const defaultItem = defaultArr ? defaultArr[editingArrayIndex] : undefined + const isDefaultItem = isDefault(currentItem, defaultItem) + + const optSchema = getOptionSchema( + selectedPlugin.manifest as Record | null, + editingKey, + ) + if ( + optSchema?.type === "array" && + optSchema.items?.type === "enum" && + optSchema.items.values + ) { + const enumValues = optSchema.items.values + return ( + + + + {editingKey} → [{editingArrayIndex}] + + (enum) + + + Current: {formatValue(currentItem)} + {isDefaultItem && (default)} + + {defaultItem !== undefined && !isDefaultItem && ( + + Default: {formatValue(defaultItem)} + + )} + + {enumValues.map((val, i) => { + const isHighlighted = i === highlightedEnumIndex + const isCurrent = currentItem === val + const fg = isHighlighted ? "#FFFFFF" : "#888888" + const marker = isHighlighted ? "▸ " : " " + const currentTag = isCurrent ? " ◀" : "" + return ( + + + {marker} + {val} + {currentTag} + + + ) + })} + + + ↑↓: navigate │ Enter: select │ Esc: back + + + ) + } + + return ( + + + + {editingKey} → [{editingArrayIndex}] + + + + Current: {formatValue(currentItem)} + {isDefaultItem && (default)} + + {defaultItem !== undefined && !isDefaultItem && ( + + Default: {formatValue(defaultItem)} + + )} + + New value: + + { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + const newArray = [...currentValue] + newArray[editingArrayIndex] = parseJsonOrString(value) + setPluginOptions(origIdx, editingKey, newArray) + notify(`Set ${editingKey}[${editingArrayIndex}]`, "success") + } + setEditingArrayIndex(null) + }} + /> + + + + Enter: save │ Esc: back + + + ) + } + + if (isEditableObject(currentValue)) { + // Adding a new field: step 2 — enter the value for the new key + if (newObjectKeyName !== null) { + return ( + + + + {editingKey} → {newObjectKeyName} + + (new field) + + + Value: + + { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + const updatedParent = { + ...currentValue, + [newObjectKeyName]: parseJsonOrString(value), + } + setPluginOptions(origIdx, editingKey, updatedParent) + notify(`Added ${editingKey}.${newObjectKeyName}`, "success") + } + setNewObjectKeyName(null) + setAddingObjectKey(false) + }} + /> + + + + Enter: save │ Esc: cancel + + + ) + } + + // Adding a new field: step 1 — enter the key name + if (addingObjectKey) { + return ( + + + {editingKey} + — Add new field + + + Key name: + + { + const keyName = value.trim() + if (!keyName) { + setAddingObjectKey(false) + return + } + if (keyName in currentValue) { + notify(`Field "${keyName}" already exists`, "error") + return + } + setNewObjectKeyName(keyName) + }} + /> + + + + Enter: next │ Esc: cancel + + + ) + } + + const subEntries = Object.entries(currentValue) + const addFieldLabel = "+ Add field" + return ( + + + {editingKey} + {`{…} ${subEntries.length} field(s)`} + {isDefaultVal && (all defaults)} + + + + {subEntries.map(([subKey, subVal], i) => { + const subDefault = isEditableObject(defaultVal) ? defaultVal[subKey] : undefined + const defaultTag = isDefault(subVal, subDefault) ? " ✓" : "" + const isHighlighted = i === highlightedSubKeyIndex + const fg = isHighlighted ? "#FFFFFF" : "#888888" + const marker = isHighlighted ? "▸ " : " " + return ( + + + {marker} + {subKey} + {defaultTag} + + {formatValue(subVal)} + + ) + })} + + + {highlightedSubKeyIndex === subEntries.length ? "▸ " : " "} + {addFieldLabel} + + + + + + ↑↓: navigate │ Enter: edit field │ x: delete │ Esc: back + + + ) + } + + if (Array.isArray(currentValue)) { + const addItemLabel = "+ Add item" + return ( + + + {editingKey} + {`[…] ${currentValue.length} item(s)`} + {isDefaultVal && (all defaults)} + + + + {currentValue.map((item, idx) => { + const defaultArr = Array.isArray(defaultVal) ? defaultVal : undefined + const defaultItem = defaultArr ? defaultArr[idx] : undefined + const defaultTag = isDefault(item, defaultItem) ? " ✓" : "" + const isHighlighted = idx === highlightedArrayItemIndex + const fg = isHighlighted ? "#FFFFFF" : "#888888" + const marker = isHighlighted ? "▸ " : " " + return ( + + + {marker}[{idx}]{defaultTag} + + {formatValue(item)} + + ) + })} + + + {highlightedArrayItemIndex === currentValue.length ? "▸ " : " "} + {addItemLabel} + + + + + + + ↑↓: navigate │ ⇧↑↓: move │ Enter: edit │ x: delete │ Esc: back + + + + ) + } + + if (typeof currentValue === "boolean") { + const boolItems = [ + { label: "true", isCurrent: currentValue === true }, + { label: "false", isCurrent: currentValue === false }, + ] + return ( + + + {editingKey} + (boolean) + + + Current: {String(currentValue)} + {isDefaultVal && (default)} + + {defaultVal !== undefined && !isDefaultVal && ( + + Default: {formatValue(defaultVal)} + + )} + + {boolItems.map((item, i) => { + const isHighlighted = i === highlightedBoolIndex + const fg = isHighlighted ? "#FFFFFF" : "#888888" + const marker = isHighlighted ? "▸ " : " " + const currentTag = item.isCurrent ? " ◀" : "" + return ( + + + {marker} + {item.label} + {currentTag} + + + ) + })} + + + ↑↓: toggle │ Enter: select │ Esc: back + + + ) + } + + const optSchema = getOptionSchema( + selectedPlugin.manifest as Record | null, + editingKey, + ) + if (optSchema?.type === "enum" && optSchema.values) { + const enumValues = optSchema.values + return ( + + + {editingKey} + (enum) + + + Current: {formatValue(currentValue)} + {isDefaultVal && (default)} + + {defaultVal !== undefined && !isDefaultVal && ( + + Default: {formatValue(defaultVal)} + + )} + + {enumValues.map((val, i) => { + const isHighlighted = i === highlightedEnumIndex + const isCurrent = currentValue === val + const fg = isHighlighted ? "#FFFFFF" : "#888888" + const marker = isHighlighted ? "▸ " : " " + const currentTag = isCurrent ? " ◀" : "" + return ( + + + {marker} + {val} + {currentTag} + + + ) + })} + + + ↑↓: navigate │ Enter: select │ Esc: back + + + ) + } + + return ( + + + {editingKey} + + + Current: {formatValue(currentValue)} + {isDefaultVal && (default)} + + {defaultVal !== undefined && !isDefaultVal && ( + + Default: {formatValue(defaultVal)} + + )} + + New value: + + { + const origIdx = plugins.findIndex((p) => p.index === selectedPlugin.index) + if (origIdx >= 0) { + const parsed = parseJsonOrString(value) + if (typeof currentValue === "number" && typeof parsed !== "number") { + notify("Invalid: expected a number", "error") + return + } + setPluginOptions(origIdx, editingKey, parsed) + notify(`Set ${editingKey}`, "success") + } + setEditingKey(null) + }} + /> + + + + Enter: save │ Esc: back + + + ) + } + + const renderOptionsList = () => { + return ( + + + {optionEntries.map(([key, value], i) => { + const defaultVal = defaults ? defaults[key] : undefined + const defaultTag = isDefault(value, defaultVal) ? " ✓" : "" + const typeTag = isEditableObject(value) ? " {…}" : Array.isArray(value) ? " […]" : "" + const isActive = editingKey === key + const isHighlighted = !editingKey && i === highlightedOptionIndex + const fg = isActive ? "#FFFF00" : isHighlighted ? "#FFFFFF" : "#888888" + const marker = isActive ? "▶ " : isHighlighted ? "▸ " : " " + return ( + + + {marker} + {key} + {typeTag} + {defaultTag} + + + ) + })} + + + ) + } + + return ( + + + Options: {selectedPlugin.displayName} + │ Esc: back + + {optionEntries.length === 0 ? ( + + No options configured + + ) : ( + + + {renderOptionsList()} + + + {renderEditPanel()} + + + )} + + ) + } + + return ( + + + { + const trimmed = value.trim() + if (!trimmed) { + setAddingArrayItem(false) + setEditingArrayItemIndex(null) + return + } + if (editingArrayItemIndex !== null) { + const nextItems = [...arrayItems] + nextItems[editingArrayItemIndex] = trimmed + setArrayItems(nextItems) + applyValue(editingEntry.keyPath, nextItems, false) + setEditingArrayItemIndex(null) + return + } + const nextItems = [...arrayItems, trimmed] + setArrayItems(nextItems) + setHighlightedArrayIndex(Math.max(0, nextItems.length - 1)) + applyValue(editingEntry.keyPath, nextItems, false) + setAddingArrayItem(false) + }} + /> + + + + {controlsLabel} + + + ) + } + + if (view === "edit-color") { + const currentValue = String(editingEntry.value ?? "") + const showSwatch = isValidColorValue(currentValue) + const errorText = colorError ?? "" + return ( + + + Edit: {pathLabel} + + + Current: {currentValue} + {showSwatch ? : null} + + + Value: + + { + const trimmed = value.trim() + if (!isValidColorValue(trimmed)) { + setColorError( + "Invalid color. Use #RGB, #RRGGBB, #RRGGBBAA, or a CSS color function like rgba(...)", + ) + return + } + setColorError(null) + applyValue(editingEntry.keyPath, trimmed) + }} + /> + + + + {errorText} + + + Enter: save │ Esc: cancel + + + ) + } + + if (view === "edit-string") { + const currentLabel = formatStringValue(editingEntry.value) + return ( + + + Edit: {pathLabel} + + + Current: {currentLabel} + + + Value: + + { + const parsed = parseJsonOrString(value) + applyValue(editingEntry.keyPath, parsed) + }} + /> + + + + Enter: save │ Esc: cancel + + + ) + } + + return null + } + + if (view !== "list" && editingEntry) { + return ( + + + + Global Configuration + + + {renderTree(true)} + + + + {renderEditPanel()} + + + ) + } + + return ( + + + Global Configuration + + + {renderTree(false)} + + + ) +} diff --git a/quartz/cli/tui/tsconfig.json b/quartz/cli/tui/tsconfig.json new file mode 100644 index 000000000..a63a4fa35 --- /dev/null +++ b/quartz/cli/tui/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "jsxImportSource": "@opentui/react", + "lib": ["ESNext", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowJs": true, + "outDir": "dist", + "rootDir": "..", + "declaration": false, + "types": ["node", "react"] + }, + "include": ["*.tsx", "**/*.tsx", "**/*.ts", "cli-modules.d.ts"] +}