diff --git a/.husky/pre-commit b/.husky/pre-commit index 36af2198..5daac2b4 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npx lint-staged +npx lint-staged --no-stash diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000..abf4d51d --- /dev/null +++ b/bun.lock @@ -0,0 +1,986 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "codex-multi-auth", + "dependencies": { + "@codex-ai/plugin": "file:vendor/codex-ai-plugin", + "@openauthjs/openauth": "^0.4.3", + "@opentui/core": "^0.1.87", + "@opentui/solid": "^0.1.87", + "hono": "4.12.3", + "ink": "5.2.1", + "react": "18.3.1", + "solid-js": "^1.9.11", + "zod": "^4.3.6", + }, + "devDependencies": { + "@codex-ai/sdk": "file:vendor/codex-ai-sdk", + "@fast-check/vitest": "^0.2.4", + "@types/node": "^25.3.0", + "@types/react": "18.3.28", + "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/parser": "^8.56.0", + "@vitest/coverage-v8": "^4.0.18", + "@vitest/ui": "^4.0.18", + "bun-types": "^1.3.10", + "eslint": "^10.0.0", + "fast-check": "^4.5.3", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", + "typescript": "^5.9.3", + "typescript-language-server": "^5.1.3", + "vitest": "^4.0.18", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "overrides": { + "hono": "4.12.3", + "minimatch": "10.2.4", + "rollup": "4.59.0", + "vite": "^7.3.1", + }, + "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], + + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + + "@codex-ai/plugin": ["@codex-ai/plugin@file:vendor/codex-ai-plugin", {}], + + "@codex-ai/sdk": ["@codex-ai/sdk@file:vendor/codex-ai-sdk", {}], + + "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.3", "", { "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.3", "", { "dependencies": { "@eslint/core": "^1.1.1" } }, "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw=="], + + "@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="], + + "@fast-check/vitest": ["@fast-check/vitest@0.2.4", "", { "dependencies": { "fast-check": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "vitest": "^1 || ^2 || ^3 || ^4" } }, "sha512-Ilcr+JAIPhb1s6FRm4qoglQYSGXXrS+zAupZeNuWAA3qHVGDA1d1Gb84Hb/+otL3GzVZjFJESg5/1SfIvrgssA=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jimp/core": ["@jimp/core@1.6.0", "", { "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" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + + "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + + "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + + "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + + "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "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" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "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" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "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" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "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" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "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" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "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" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + + "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="], + + "@opentui/core": ["@opentui/core@0.1.87", "", { "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.87", "@opentui/core-darwin-x64": "0.1.87", "@opentui/core-linux-arm64": "0.1.87", "@opentui/core-linux-x64": "0.1.87", "@opentui/core-win32-arm64": "0.1.87", "@opentui/core-win32-x64": "0.1.87", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dhsmMv0IqKftwG7J/pBrLBj2armsYIg5R3LBvciRQI/6X89GufP4l1u0+QTACAx6iR4SYJJNVNQ2tdX8LM9rMw=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="], + + "@opentui/solid": ["@opentui/solid@0.1.87", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.87", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-lRT9t30l8+FtgOjjWJcdb2MT6hP8/RKqwGgYwTI7fXrOqdhxxwdP2SM+rH2l3suHeASheiTdlvPAo230iUcsvg=="], + + "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], + + "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], + + "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="], + + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + + "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/type-utils": "8.57.0", "@typescript-eslint/utils": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0" } }, "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="], + + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="], + + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="], + + "@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="], + + "@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + + "@vitest/ui": ["@vitest/ui@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.18" } }, "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ=="], + + "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], + + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + + "arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], + + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="], + + "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], + + "babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], + + "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + + "bun-webgpu": ["bun-webgpu@0.1.5", "", { "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" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], + + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qM7W5IaFpWYGPDcNiQ8DOng3noQ97gxpH2MFH1mGsdKwI0T4oy++egSh5Z7s6AQx8WKgc9GzAsTUM4KZkFdacw=="], + + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-oVoIsme27pcXB68YxnQSAgdNGCa4A3PGWYIBUewOh9VnJaoik4JenGb5Yy+svGE+ETFhQXV9nhHqgMPsDRrO6A=="], + + "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+SYt09k+xDEl/GfcU7L1zdNgm7IlvAFKV5Xl/auBwuprKG5UwXNhjRlRAWfhTMCUZWN+NDf8E+ZQx0cQi9K2/g=="], + + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], + + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.0.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fast-check": ["fast-check@4.6.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.1", "", {}, "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + + "glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], + + "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + + "ink": ["ink@5.2.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.22.0", "indent-string": "^5.0.0", "is-in-ci": "^1.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.29.0", "scheduler": "^0.23.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0", "react-devtools-core": "^4.19.1" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + + "jimp": ["jimp@1.6.0", "", { "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" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + + "jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lint-staged": ["lint-staged@16.3.2", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "micromatch": "^4.0.8", "string-argv": "^0.3.2", "tinyexec": "^1.0.2", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-xKqhC2AeXLwiAHXguxBjuChoTTWFC6Pees0SHPwOpwlvI3BH7ZADFPddAdN3pgo3aiKgPUx/bxE78JfUnxQnlg=="], + + "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], + + "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + + "planck": ["planck@1.4.3", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "pure-rand": ["pure-rand@8.0.0", "", {}, "sha512-7rgWlxG2gAvFPIQfUreo1XYlNvrQ9VnQPFWdncPkdl3icucLK0InOxsaafbvxGTnI6Bk/Rxmslg0lQlRCuzOXw=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-reconciler": ["react-reconciler@0.29.2", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + + "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], + + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="], + + "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-language-server": ["typescript-language-server@5.1.3", "", { "bin": { "typescript-language-server": "lib/cli.mjs" } }, "sha512-r+pAcYtWdN8tKlYZPwiiHNA2QPjXnI02NrW5Sf2cVM3TRtuQ3V9EKKwOxqwaQ0krsaEXk/CbN90I5erBuf84Vg=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-color/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-contain/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-cover/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-crop/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-displace/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-fisheye/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-flip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-mask/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-print/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-quantize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-resize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-rotate/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-threshold/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + + "@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + + "listr2/cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + + "log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + + "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + + "listr2/cli-truncate/slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + + "listr2/cli-truncate/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + + "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + + "listr2/cli-truncate/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + + "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..337d1925 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,7 @@ +preload = ["@opentui/solid/preload"] + +[test] +root = "test/tui" + +[build] +target = "bun" diff --git a/eslint.config.js b/eslint.config.js index 96b3dac6..83a00668 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ export default [ ignores: ["dist/**", "coverage/**", "node_modules/**", "winston/**", ".tmp*/**", "vendor/**", "*.cjs", "*.mjs"], }, { - files: ["index.ts", "lib/**/*.ts"], + files: ["index.ts", "lib/**/*.ts", "runtime/**/*.ts"], languageOptions: { parser: tsparser, parserOptions: { @@ -39,6 +39,35 @@ export default [ "no-duplicate-imports": "error", }, }, + { + files: ["runtime/**/*.ts"], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + project: "./tsconfig.runtime-opentui.json", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + }, + rules: { + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-non-null-assertion": "warn", + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": "error", + "@typescript-eslint/await-thenable": "error", + "@typescript-eslint/require-await": "warn", + "no-console": "off", + "prefer-const": "error", + "no-var": "error", + "eqeqeq": ["error", "always"], + "no-duplicate-imports": "error", + }, + }, { files: ["scripts/**/*.js"], languageOptions: { diff --git a/lib/cli.ts b/lib/cli.ts index d223c14c..95fcf2d7 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -7,7 +7,15 @@ import { isTTY, type AccountStatus, } from "./ui/auth-menu.js"; +import { confirm } from "./ui/confirm.js"; import { UI_COPY } from "./ui/copy.js"; +import { + resolveAuthAccountDetailSelection, + resolveAuthDashboardSelection, + settleAuthConfirmation, + type AuthConfirmationModalViewModel, + type AuthDashboardInteractionResolution, +} from "./codex-manager/auth-ui-controller.js"; /** * Detect if running in host Desktop/TUI mode where readline prompts don't work. @@ -110,23 +118,6 @@ function formatAccountLabel(account: ExistingAccountInfo, index: number): string return `${num}. Account`; } -function resolveAccountSourceIndex(account: ExistingAccountInfo): number { - const sourceIndex = - typeof account.sourceIndex === "number" && Number.isFinite(account.sourceIndex) - ? Math.max(0, Math.floor(account.sourceIndex)) - : undefined; - if (typeof sourceIndex === "number") return sourceIndex; - if (typeof account.index === "number" && Number.isFinite(account.index)) { - return Math.max(0, Math.floor(account.index)); - } - return -1; -} - -function warnUnresolvableAccountSelection(account: ExistingAccountInfo): void { - const label = account.email?.trim() || account.accountId?.trim() || `index ${account.index + 1}`; - console.log(`Unable to resolve saved account for action: ${label}`); -} - async function promptDeleteAllTypedConfirm(): Promise { const rl = createInterface({ input, output }); try { @@ -137,6 +128,30 @@ async function promptDeleteAllTypedConfirm(): Promise { } } +async function promptAuthConfirmation(modal: AuthConfirmationModalViewModel): Promise { + if (modal.confirmStyle === "typed-delete") { + return promptDeleteAllTypedConfirm(); + } + return confirm(modal.message); +} + +async function resolveAuthInteraction( + resolution: AuthDashboardInteractionResolution, +): Promise { + if (resolution.type === "detail") { + const action = await showAccountDetails(resolution.detail.account); + return resolveAuthAccountDetailSelection(resolution.detail.account, action); + } + if (resolution.type === "confirm") { + const confirmed = await promptAuthConfirmation(resolution.modal); + if (!confirmed && resolution.modal.cancelMessage) { + console.log(resolution.modal.cancelMessage); + } + return settleAuthConfirmation(resolution.modal, confirmed); + } + return resolution; +} + async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): Promise { const rl = createInterface({ input, output }); try { @@ -195,95 +210,23 @@ export async function promptLoginMode( } while (true) { - const action = await showAuthMenu(existingAccounts, { + let resolution = resolveAuthDashboardSelection(await showAuthMenu(existingAccounts, { flaggedCount: options.flaggedCount ?? 0, statusMessage: options.statusMessage, - }); + })); - switch (action.type) { - case "add": - return { mode: "add" }; - case "forecast": - return { mode: "forecast" }; - case "fix": - return { mode: "fix" }; - case "settings": - return { mode: "settings" }; - case "fresh": - if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete all cancelled.\n"); - continue; - } - return { mode: "fresh", deleteAll: true }; - case "check": - return { mode: "check" }; - case "deep-check": - return { mode: "deep-check" }; - case "verify-flagged": - return { mode: "verify-flagged" }; - case "select-account": { - const accountAction = await showAccountDetails(action.account); - if (accountAction === "delete") { - const index = resolveAccountSourceIndex(action.account); - if (index >= 0) return { mode: "manage", deleteAccountIndex: index }; - warnUnresolvableAccountSelection(action.account); - continue; - } - if (accountAction === "set-current") { - const index = resolveAccountSourceIndex(action.account); - if (index >= 0) return { mode: "manage", switchAccountIndex: index }; - warnUnresolvableAccountSelection(action.account); - continue; - } - if (accountAction === "refresh") { - const index = resolveAccountSourceIndex(action.account); - if (index >= 0) return { mode: "manage", refreshAccountIndex: index }; - warnUnresolvableAccountSelection(action.account); - continue; - } - if (accountAction === "toggle") { - const index = resolveAccountSourceIndex(action.account); - if (index >= 0) return { mode: "manage", toggleAccountIndex: index }; - warnUnresolvableAccountSelection(action.account); - continue; - } - continue; - } - case "set-current-account": { - const index = resolveAccountSourceIndex(action.account); - if (index >= 0) return { mode: "manage", switchAccountIndex: index }; - warnUnresolvableAccountSelection(action.account); - continue; - } - case "refresh-account": { - const index = resolveAccountSourceIndex(action.account); - if (index >= 0) return { mode: "manage", refreshAccountIndex: index }; - warnUnresolvableAccountSelection(action.account); - continue; + while (true) { + resolution = await resolveAuthInteraction(resolution); + if (resolution.type === "result") { + return resolution.result; } - case "toggle-account": { - const index = resolveAccountSourceIndex(action.account); - if (index >= 0) return { mode: "manage", toggleAccountIndex: index }; - warnUnresolvableAccountSelection(action.account); - continue; + if (resolution.type === "warning") { + console.log(resolution.message); + break; } - case "delete-account": { - const index = resolveAccountSourceIndex(action.account); - if (index >= 0) return { mode: "manage", deleteAccountIndex: index }; - warnUnresolvableAccountSelection(action.account); - continue; + if (resolution.type === "continue") { + break; } - case "search": - // Search is handled in showAuthMenu; keep the main loop active. - continue; - case "delete-all": - if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete all cancelled.\n"); - continue; - } - return { mode: "fresh", deleteAll: true }; - case "cancel": - return { mode: "cancel" }; } } } diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b4b1e7e6..67a3271e 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -13,7 +13,7 @@ import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js"; import { isNonInteractiveMode, promptAddAnotherAccount, - promptLoginMode, + type LoginMenuResult, } from "./cli.js"; import { extractAccountEmail, @@ -81,20 +81,21 @@ import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; import { getUiRuntimeOptions } from "./ui/runtime.js"; import { select, type MenuItem } from "./ui/select.js"; import { - buildAuthDashboardViewModel, + buildAuthDashboardScreenState, formatCompactQuotaSnapshot, formatRateLimitEntry, getQuotaCacheEntryForAccount, resolveActiveIndex, resolveAuthDashboardCommand, } from "./codex-manager/auth-ui-controller.js"; -import { applyUiThemeFromDashboardSettings, configureUnifiedSettings } from "./codex-manager/settings-hub.js"; +import { applyUiThemeFromDashboardSettings, configureUnifiedSettings, persistOpenTuiSettingsSave } from "./codex-manager/settings-hub.js"; import { configureInkUnifiedSettings, promptInkAuthDashboard, promptInkRestoreForLogin, type InkShellTone, } from "./ui-ink/index.js"; +import { promptOpenTuiAuthDashboard } from "../runtime/opentui/prompt.js"; type TokenSuccess = Extract; type TokenSuccessWithAccount = TokenSuccess & { @@ -3557,7 +3558,7 @@ async function clearAccountsAndReset(): Promise { async function handleManageAction( storage: AccountStorageV3, - menuResult: Awaited>, + menuResult: LoginMenuResult, ): Promise { if (typeof menuResult.switchAccountIndex === "number") { const index = menuResult.switchAccountIndex; @@ -3636,6 +3637,9 @@ async function runAuthLogin(): Promise { let recoveryStatusText = loginStorage.statusText; let recoveryStatusTone = loginStorage.statusTone; if (existingStorage && existingStorage.accounts.length > 0) { + if (isNonInteractiveMode()) { + existingStorage = null; + } else { while (true) { existingStorage = await loadAccounts(); if (!existingStorage || existingStorage.accounts.length === 0) { @@ -3672,22 +3676,40 @@ async function runAuthLogin(): Promise { } } const flaggedStorage = await loadFlaggedAccounts(); - const dashboardViewModel = buildAuthDashboardViewModel({ + const dashboardState = buildAuthDashboardScreenState({ storage: currentStorage, quotaCache, displaySettings, flaggedCount: flaggedStorage.accounts.length, statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, }); - - const menuResult = await promptInkAuthDashboard({ + const dashboardViewModel = dashboardState.dashboard; + const openTuiDashboardViewModel = recoveryStatusText + ? { + ...dashboardViewModel, + menuOptions: { + ...dashboardViewModel.menuOptions, + statusMessage: recoveryStatusText, + }, + } + : dashboardViewModel; + + const menuResult = await promptOpenTuiAuthDashboard({ + dashboard: openTuiDashboardViewModel, + onSettingsSave: (event) => { + void persistOpenTuiSettingsSave(event).catch((error: unknown) => { + console.warn( + `OpenTUI settings save failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }, + }) ?? await promptInkAuthDashboard({ dashboard: dashboardViewModel, statusTextOverride: recoveryStatusText, statusToneOverride: recoveryStatusTone, - }) ?? await promptLoginMode( - dashboardViewModel.accounts, - dashboardViewModel.menuOptions, - ); + }) ?? (isNonInteractiveMode() + ? { mode: "add" as const } + : { mode: "cancel" as const }); recoveryStatusText = undefined; recoveryStatusTone = undefined; const command = resolveAuthDashboardCommand(menuResult); @@ -3756,6 +3778,7 @@ async function runAuthLogin(): Promise { break; } } + } } const refreshedStorage = await loadAccounts(); @@ -3792,6 +3815,9 @@ async function runAuthLogin(): Promise { if (!addAnother) break; forceNewLogin = true; } + if (isNonInteractiveMode()) { + return 0; + } continue loginFlow; } } diff --git a/lib/codex-manager/auth-ui-controller.ts b/lib/codex-manager/auth-ui-controller.ts index 8beec128..0f7fa83e 100644 --- a/lib/codex-manager/auth-ui-controller.ts +++ b/lib/codex-manager/auth-ui-controller.ts @@ -97,6 +97,43 @@ export interface AuthDashboardActionPanelViewModel { stage: string; } +export type AuthAccountDetailActionId = "back" | "toggle" | "set-current" | "refresh" | "delete"; + +export interface AuthAccountDetailActionViewModel { + id: AuthAccountDetailActionId; + label: string; + tone: "green" | "yellow" | "red"; +} + +export interface AuthAccountDetailViewModel { + account: AuthAccountViewModel; + title: string; + subtitle: string; + actions: AuthAccountDetailActionViewModel[]; +} + +export interface AuthConfirmationModalViewModel { + id: "delete-all" | "delete-account" | "refresh-account"; + message: string; + confirmStyle: "confirm" | "typed-delete"; + result: LoginMenuResult; + cancelMessage?: string; +} + +export interface AuthDashboardScreenStateViewModel { + dashboard: AuthDashboardViewModel; + selectedAccountIndex: number | null; + detailPane: AuthAccountDetailViewModel | null; + modal: AuthConfirmationModalViewModel | null; +} + +export type AuthDashboardInteractionResolution = + | { type: "result"; result: LoginMenuResult } + | { type: "detail"; detail: AuthAccountDetailViewModel } + | { type: "confirm"; modal: AuthConfirmationModalViewModel } + | { type: "warning"; message: string } + | { type: "continue" }; + export type AuthDashboardCommand = | { type: "cancel" } | { type: "add-account" } @@ -118,6 +155,89 @@ export type AuthDashboardCommand = panel?: AuthDashboardActionPanelViewModel; }; +function sanitizeTerminalText(value: string | undefined): string | undefined { + if (!value) return undefined; + const ansiPattern = new RegExp("\\u001B\\[[0-?]*[ -/]*[@-~]", "g"); + const controlPattern = new RegExp("[\\u0000-\\u001F\\u007F]", "g"); + return value + .replace(ansiPattern, "") + .replace(controlPattern, "") + .trim(); +} + +function formatRelativeTime(timestamp: number | undefined): string { + if (!timestamp) return "never"; + const days = Math.floor((Date.now() - timestamp) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d ago`; + if (days < 30) return `${Math.floor(days / 7)}w ago`; + return new Date(timestamp).toLocaleDateString(); +} + +function formatDate(timestamp: number | undefined): string { + if (!timestamp) return "unknown"; + return new Date(timestamp).toLocaleDateString(); +} + +function formatAccountTitle(account: AuthAccountViewModel): string { + const accountNumber = account.quickSwitchNumber ?? (account.index + 1); + const base = + sanitizeTerminalText(account.email) || + sanitizeTerminalText(account.accountLabel) || + sanitizeTerminalText(account.accountId) || + `Account ${accountNumber}`; + return `${accountNumber}. ${base}`; +} + +function resolveManagedAccountIndex(account: Pick): number { + const sourceIndex = + typeof account.sourceIndex === "number" && Number.isFinite(account.sourceIndex) + ? Math.max(0, Math.floor(account.sourceIndex)) + : undefined; + if (typeof sourceIndex === "number") return sourceIndex; + if (typeof account.index === "number" && Number.isFinite(account.index)) { + return Math.max(0, Math.floor(account.index)); + } + return -1; +} + +function buildUnresolvableAccountMessage( + account: Pick, +): string { + const label = account.email?.trim() || account.accountId?.trim() || `index ${account.index + 1}`; + return `Unable to resolve saved account for action: ${label}`; +} + +function resolveAccountManageResult( + account: Pick, + buildResult: (index: number) => LoginMenuResult, +): AuthDashboardInteractionResolution { + const index = resolveManagedAccountIndex(account); + if (index >= 0) { + return { type: "result", result: buildResult(index) }; + } + return { type: "warning", message: buildUnresolvableAccountMessage(account) }; +} + +function resolveAccountConfirmModal( + account: Pick, + modal: Pick, + buildResult: (index: number) => LoginMenuResult, +): AuthDashboardInteractionResolution { + const index = resolveManagedAccountIndex(account); + if (index < 0) { + return { type: "warning", message: buildUnresolvableAccountMessage(account) }; + } + return { + type: "confirm", + modal: { + ...modal, + result: buildResult(index), + }, + }; +} + export function resolveActiveIndex( storage: AccountStorageV3, family: ModelFamily = "codex", @@ -464,6 +584,178 @@ export function buildAuthDashboardViewModel( }; } +export function buildAuthAccountDetailViewModel( + account: AuthAccountViewModel, +): AuthAccountDetailViewModel { + const title = + `${formatAccountTitle(account)} [${account.status ?? "unknown"}]` + + (account.enabled === false ? " [disabled]" : ""); + const statusLabel = account.status ?? "unknown"; + return { + account, + title, + subtitle: `Added: ${formatDate(account.addedAt)} | Used: ${formatRelativeTime(account.lastUsed)} | Status: ${statusLabel}`, + actions: [ + { id: "back", label: UI_COPY.accountDetails.back, tone: "yellow" }, + { + id: "toggle", + label: account.enabled === false ? UI_COPY.accountDetails.enable : UI_COPY.accountDetails.disable, + tone: account.enabled === false ? "green" : "yellow", + }, + { id: "set-current", label: UI_COPY.accountDetails.setCurrent, tone: "green" }, + { id: "refresh", label: UI_COPY.accountDetails.refresh, tone: "green" }, + { id: "delete", label: UI_COPY.accountDetails.remove, tone: "red" }, + ], + }; +} + +export function buildAuthDashboardScreenState( + options: BuildAuthDashboardViewModelOptions, +): AuthDashboardScreenStateViewModel { + const dashboard = buildAuthDashboardViewModel(options); + const selectedAccount = dashboard.accounts.find((account) => account.isCurrentAccount) ?? dashboard.accounts[0] ?? null; + return { + dashboard, + selectedAccountIndex: selectedAccount?.index ?? null, + detailPane: selectedAccount ? buildAuthAccountDetailViewModel(selectedAccount) : null, + modal: null, + }; +} + +export function resolveAuthDashboardSelection( + action: { + type: + | "add" + | "forecast" + | "fix" + | "settings" + | "fresh" + | "check" + | "deep-check" + | "verify-flagged" + | "select-account" + | "set-current-account" + | "refresh-account" + | "toggle-account" + | "delete-account" + | "search" + | "delete-all" + | "cancel"; + account?: AuthAccountViewModel; + }, +): AuthDashboardInteractionResolution { + switch (action.type) { + case "add": + return { type: "result", result: { mode: "add" } }; + case "forecast": + return { type: "result", result: { mode: "forecast" } }; + case "fix": + return { type: "result", result: { mode: "fix" } }; + case "settings": + return { type: "result", result: { mode: "settings" } }; + case "check": + return { type: "result", result: { mode: "check" } }; + case "deep-check": + return { type: "result", result: { mode: "deep-check" } }; + case "verify-flagged": + return { type: "result", result: { mode: "verify-flagged" } }; + case "cancel": + return { type: "result", result: { mode: "cancel" } }; + case "search": + return { type: "continue" }; + case "fresh": + case "delete-all": + return { + type: "confirm", + modal: { + id: "delete-all", + message: "Delete all accounts?", + confirmStyle: "typed-delete", + result: { mode: "fresh", deleteAll: true }, + cancelMessage: "\nDelete all cancelled.\n", + }, + }; + case "select-account": + if (!action.account) return { type: "continue" }; + return { type: "detail", detail: buildAuthAccountDetailViewModel(action.account) }; + case "set-current-account": + if (!action.account) return { type: "continue" }; + return resolveAccountManageResult(action.account, (index) => ({ mode: "manage", switchAccountIndex: index })); + case "toggle-account": + if (!action.account) return { type: "continue" }; + return resolveAccountManageResult(action.account, (index) => ({ mode: "manage", toggleAccountIndex: index })); + case "refresh-account": + if (!action.account) return { type: "continue" }; + return resolveAccountConfirmModal( + action.account, + { + id: "refresh-account", + message: `Re-authenticate ${formatAccountTitle(action.account)}?`, + confirmStyle: "confirm", + }, + (index) => ({ mode: "manage", refreshAccountIndex: index }), + ); + case "delete-account": + if (!action.account) return { type: "continue" }; + return resolveAccountConfirmModal( + action.account, + { + id: "delete-account", + message: `Delete ${formatAccountTitle(action.account)}?`, + confirmStyle: "confirm", + }, + (index) => ({ mode: "manage", deleteAccountIndex: index }), + ); + default: + return { type: "continue" }; + } +} + +export function resolveAuthAccountDetailSelection( + account: AuthAccountViewModel, + action: AuthAccountDetailActionId | "cancel", +): AuthDashboardInteractionResolution { + switch (action) { + case "back": + case "cancel": + return { type: "continue" }; + case "set-current": + return resolveAccountManageResult(account, (index) => ({ mode: "manage", switchAccountIndex: index })); + case "toggle": + return resolveAccountManageResult(account, (index) => ({ mode: "manage", toggleAccountIndex: index })); + case "refresh": + return resolveAccountConfirmModal( + account, + { + id: "refresh-account", + message: `Re-authenticate ${formatAccountTitle(account)}?`, + confirmStyle: "confirm", + }, + (index) => ({ mode: "manage", refreshAccountIndex: index }), + ); + case "delete": + return resolveAccountConfirmModal( + account, + { + id: "delete-account", + message: `Delete ${formatAccountTitle(account)}?`, + confirmStyle: "confirm", + }, + (index) => ({ mode: "manage", deleteAccountIndex: index }), + ); + } +} + +export function settleAuthConfirmation( + modal: AuthConfirmationModalViewModel, + confirmed: boolean, +): AuthDashboardInteractionResolution { + if (!confirmed) { + return { type: "continue" }; + } + return { type: "result", result: modal.result }; +} + export function resolveAuthDashboardCommand(menuResult: LoginMenuResult): AuthDashboardCommand { switch (menuResult.mode) { case "cancel": diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index c67daa9c..297d33d4 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -20,6 +20,15 @@ import { persistDashboardSettingsSelection, withQueuedRetry, } from "./settings-persistence.js"; +import { + ACCOUNT_LIST_PANEL_KEYS, + BEHAVIOR_PANEL_KEYS, + buildSettingsHubViewModel, + resolveSettingsHubCommand, + STATUSLINE_PANEL_KEYS, + THEME_PANEL_KEYS, + type SettingsHubAction, +} from "./settings-ui-controller.js"; type DashboardDisplaySettingKey = @@ -240,14 +249,6 @@ type BackendSettingsHubAction = | { type: "save" } | { type: "cancel" }; -type SettingsHubAction = - | { type: "account-list" } - | { type: "summary-fields" } - | { type: "behavior" } - | { type: "theme" } - | { type: "backend" } - | { type: "back" }; - const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [ { key: "liveAccountSync", @@ -519,32 +520,6 @@ const BACKEND_CATEGORY_OPTIONS: BackendCategoryOption[] = [ type DashboardSettingKey = keyof DashboardDisplaySettings; -const ACCOUNT_LIST_PANEL_KEYS = [ - "menuShowStatusBadge", - "menuShowCurrentBadge", - "menuShowLastUsed", - "menuShowQuotaSummary", - "menuShowQuotaCooldown", - "menuShowFetchStatus", - "menuShowDetailsForUnselectedRows", - "menuHighlightCurrentRow", - "menuSortEnabled", - "menuSortMode", - "menuSortPinCurrent", - "menuSortQuickSwitchVisibleRow", - "menuLayoutMode", -] as const satisfies readonly DashboardSettingKey[]; - -const STATUSLINE_PANEL_KEYS = ["menuStatuslineFields"] as const satisfies readonly DashboardSettingKey[]; -const BEHAVIOR_PANEL_KEYS = [ - "actionAutoReturnMs", - "actionPauseOnKey", - "menuAutoFetchLimits", - "menuShowFetchStatus", - "menuQuotaTtlMs", -] as const satisfies readonly DashboardSettingKey[]; -const THEME_PANEL_KEYS = ["uiThemePreset", "uiAccentColor"] as const satisfies readonly DashboardSettingKey[]; - function copyDashboardSettingValue( target: DashboardDisplaySettings, source: DashboardDisplaySettings, @@ -971,6 +946,36 @@ async function persistBackendConfigSelectionForTests( }); } +async function persistOpenTuiSettingsSave(event: { + kind: "dashboard"; + panel: "account-list" | "summary-fields" | "behavior" | "theme"; + selected: DashboardDisplaySettings; +} | { + kind: "backend"; + selected: PluginConfig; +}): Promise { + if (event.kind === "backend") { + await persistBackendConfigSelection(event.selected, "backend", { + cloneConfig: cloneBackendPluginConfig, + buildPatch: buildBackendConfigPatch, + }); + return; + } + + const keys = event.panel === "account-list" + ? ACCOUNT_LIST_PANEL_KEYS + : event.panel === "summary-fields" + ? STATUSLINE_PANEL_KEYS + : event.panel === "behavior" + ? BEHAVIOR_PANEL_KEYS + : THEME_PANEL_KEYS; + + await persistDashboardSettingsSelection(event.selected, keys, event.panel, { + cloneSettings: cloneDashboardSettings, + mergeSettingsForKeys: mergeDashboardSettingsForKeys, + }); +} + const __testOnly = { clampBackendNumber: clampBackendNumberForTests, formatMenuLayoutMode, @@ -1950,19 +1955,21 @@ async function promptSettingsHub( ): Promise { if (!input.isTTY || !output.isTTY) return null; const ui = getUiRuntimeOptions(); - const items: MenuItem[] = [ - { label: UI_COPY.settings.sectionTitle, value: { type: "back" }, kind: "heading" }, - { label: UI_COPY.settings.accountList, value: { type: "account-list" }, color: "green" }, - { label: UI_COPY.settings.summaryFields, value: { type: "summary-fields" }, color: "green" }, - { label: UI_COPY.settings.behavior, value: { type: "behavior" }, color: "green" }, - { label: UI_COPY.settings.theme, value: { type: "theme" }, color: "green" }, - { label: "", value: { type: "back" }, separator: true }, - { label: UI_COPY.settings.advancedTitle, value: { type: "back" }, kind: "heading" }, - { label: UI_COPY.settings.backend, value: { type: "backend" }, color: "green" }, - { label: "", value: { type: "back" }, separator: true }, - { label: UI_COPY.settings.exitTitle, value: { type: "back" }, kind: "heading" }, - { label: UI_COPY.settings.back, value: { type: "back" }, color: "red" }, - ]; + const viewModel = buildSettingsHubViewModel(); + const items: MenuItem[] = []; + for (const section of viewModel.sections) { + items.push({ label: section.title, value: { type: "back" }, kind: "heading" }); + for (const action of section.actions) { + items.push({ + label: action.label, + value: { type: action.id }, + color: action.tone, + }); + } + if (section.id !== "exit") { + items.push({ label: "", value: { type: "back" }, separator: true }); + } + } const initialCursor = items.findIndex((item) => { if (item.separator || item.disabled || item.kind === "heading") return false; return item.value.type === initialFocus; @@ -1994,19 +2001,23 @@ async function configureUnifiedSettings( let hubFocus: SettingsHubAction["type"] = "account-list"; while (true) { const action = await promptSettingsHub(hubFocus); - if (!action || action.type === "back") { + if (!action) { return current; } hubFocus = action.type; - if (action.type === "account-list") { + const command = resolveSettingsHubCommand(action); + if (command.type === "back") { + return current; + } + if (command.type === "open-dashboard-panel" && command.panel === "account-list") { current = await configureDashboardDisplaySettings(current); continue; } - if (action.type === "summary-fields") { + if (command.type === "open-dashboard-panel" && command.panel === "summary-fields") { current = await configureStatuslineSettings(current); continue; } - if (action.type === "behavior") { + if (command.type === "open-dashboard-panel" && command.panel === "behavior") { const selected = await promptBehaviorSettings(current); if (selected && !dashboardSettingsEqual(current, selected)) { current = await persistDashboardSettingsSelection(selected, BEHAVIOR_PANEL_KEYS, "behavior", { @@ -2016,7 +2027,7 @@ async function configureUnifiedSettings( } continue; } - if (action.type === "theme") { + if (command.type === "open-dashboard-panel" && command.panel === "theme") { const selected = await promptThemeSettings(current); if (selected && !dashboardSettingsEqual(current, selected)) { current = await persistDashboardSettingsSelection(selected, THEME_PANEL_KEYS, "theme", { @@ -2027,11 +2038,29 @@ async function configureUnifiedSettings( } continue; } - if (action.type === "backend") { + if (command.type === "open-backend-settings") { backendConfig = await configureBackendSettings(backendConfig); } } } -export { configureUnifiedSettings, applyUiThemeFromDashboardSettings, resolveMenuLayoutMode, __testOnly }; +export { + configureUnifiedSettings, + persistOpenTuiSettingsSave, + applyDashboardDefaultsForKeys, + applyUiThemeFromDashboardSettings, + clampBackendNumber, + cloneBackendPluginConfig, + cloneDashboardSettings, + dashboardSettingsEqual, + backendSettingsEqual, + formatBackendNumberValue, + formatDashboardSettingState, + formatMenuLayoutMode, + formatMenuQuotaTtl, + formatMenuSortMode, + normalizeStatuslineFields, + resolveMenuLayoutMode, + __testOnly, +}; diff --git a/lib/codex-manager/settings-ui-controller.ts b/lib/codex-manager/settings-ui-controller.ts new file mode 100644 index 00000000..fc092c66 --- /dev/null +++ b/lib/codex-manager/settings-ui-controller.ts @@ -0,0 +1,531 @@ +import type { + DashboardAccentColor, + DashboardDisplaySettings, + DashboardStatuslineField, + DashboardThemePreset, +} from "../dashboard-settings.js"; +import { UI_COPY } from "../ui/copy.js"; + +type DashboardSettingKey = keyof DashboardDisplaySettings; + +export type DashboardDisplaySettingKey = + | "menuShowStatusBadge" + | "menuShowCurrentBadge" + | "menuShowLastUsed" + | "menuShowQuotaSummary" + | "menuShowQuotaCooldown" + | "menuShowDetailsForUnselectedRows" + | "menuShowFetchStatus" + | "menuHighlightCurrentRow" + | "menuSortEnabled" + | "menuSortPinCurrent" + | "menuSortQuickSwitchVisibleRow"; + +export interface DashboardDisplaySettingOption { + key: DashboardDisplaySettingKey; + label: string; + description: string; +} + +export const DASHBOARD_DISPLAY_OPTIONS: DashboardDisplaySettingOption[] = [ + { + key: "menuShowStatusBadge", + label: "Show Status Badges", + description: "Show [ok], [active], and similar badges.", + }, + { + key: "menuShowCurrentBadge", + label: "Show [current]", + description: "Mark the account active in Codex.", + }, + { + key: "menuShowLastUsed", + label: "Show Last Used", + description: "Show relative usage like 'today'.", + }, + { + key: "menuShowQuotaSummary", + label: "Show Limits (5h / 7d)", + description: "Show limit bars in each row.", + }, + { + key: "menuShowQuotaCooldown", + label: "Show Limit Cooldowns", + description: "Show reset timers next to 5h/7d bars.", + }, + { + key: "menuShowFetchStatus", + label: "Show Fetch Status", + description: "Show background limit refresh status in the menu subtitle.", + }, + { + key: "menuHighlightCurrentRow", + label: "Highlight Current Row", + description: "Use stronger color on the current row.", + }, + { + key: "menuSortEnabled", + label: "Enable Smart Sort", + description: "Sort accounts by readiness (view only).", + }, + { + key: "menuSortPinCurrent", + label: "Pin [current] when tied", + description: "Keep current at top only when it is equally ready.", + }, + { + key: "menuSortQuickSwitchVisibleRow", + label: "Quick Switch Uses Visible Rows", + description: "Number keys (1-9) follow what you see in the list.", + }, +]; + +export const DEFAULT_STATUSLINE_FIELDS: DashboardStatuslineField[] = ["last-used", "limits", "status"]; +export const STATUSLINE_FIELD_OPTIONS: Array<{ + key: DashboardStatuslineField; + label: string; + description: string; +}> = [ + { + key: "last-used", + label: "Show Last Used", + description: "Example: 'today' or '2d ago'.", + }, + { + key: "limits", + label: "Show Limits (5h / 7d)", + description: "Uses cached limit data from checks.", + }, + { + key: "status", + label: "Show Status Text", + description: "Visible when badges are hidden.", + }, +]; + +export const AUTO_RETURN_OPTIONS_MS = [1_000, 2_000, 4_000] as const; +export const MENU_QUOTA_TTL_OPTIONS_MS = [60_000, 5 * 60_000, 10 * 60_000] as const; +export const THEME_PRESET_OPTIONS: DashboardThemePreset[] = ["green", "blue"]; +export const ACCENT_COLOR_OPTIONS: DashboardAccentColor[] = ["green", "cyan", "blue", "yellow"]; + +export type BackendToggleSettingKey = + | "liveAccountSync" + | "sessionAffinity" + | "proactiveRefreshGuardian" + | "retryAllAccountsRateLimited" + | "parallelProbing" + | "storageBackupEnabled" + | "preemptiveQuotaEnabled" + | "fastSession" + | "sessionRecovery" + | "autoResume" + | "perProjectAccounts"; + +export type BackendNumberSettingKey = + | "liveAccountSyncDebounceMs" + | "liveAccountSyncPollMs" + | "sessionAffinityTtlMs" + | "sessionAffinityMaxEntries" + | "proactiveRefreshIntervalMs" + | "proactiveRefreshBufferMs" + | "parallelProbingMaxConcurrency" + | "fastSessionMaxInputItems" + | "networkErrorCooldownMs" + | "serverErrorCooldownMs" + | "fetchTimeoutMs" + | "streamStallTimeoutMs" + | "tokenRefreshSkewMs" + | "preemptiveQuotaRemainingPercent5h" + | "preemptiveQuotaRemainingPercent7d" + | "preemptiveQuotaMaxDeferralMs"; + +export interface BackendToggleSettingOption { + key: BackendToggleSettingKey; + label: string; + description: string; +} + +export interface BackendNumberSettingOption { + key: BackendNumberSettingKey; + label: string; + description: string; + min: number; + max: number; + step: number; + unit: "ms" | "percent" | "count"; +} + +export type BackendCategoryKey = + | "session-sync" + | "rotation-quota" + | "refresh-recovery" + | "performance-timeouts"; + +export interface BackendCategoryOption { + key: BackendCategoryKey; + label: string; + description: string; + toggleKeys: BackendToggleSettingKey[]; + numberKeys: BackendNumberSettingKey[]; +} + +export const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [ + { + key: "liveAccountSync", + label: "Enable Live Sync", + description: "Keep accounts synced when files change in another window.", + }, + { + key: "sessionAffinity", + label: "Enable Session Affinity", + description: "Try to keep each conversation on the same account.", + }, + { + key: "proactiveRefreshGuardian", + label: "Enable Token Refresh Guard", + description: "Refresh tokens early in the background.", + }, + { + key: "retryAllAccountsRateLimited", + label: "Retry When All Rate-Limited", + description: "If all accounts are limited, wait and try again.", + }, + { + key: "parallelProbing", + label: "Enable Parallel Probing", + description: "Check multiple accounts at the same time.", + }, + { + key: "storageBackupEnabled", + label: "Enable Storage Backups", + description: "Create a backup before account data changes.", + }, + { + key: "preemptiveQuotaEnabled", + label: "Enable Quota Deferral", + description: "Delay requests before limits are fully exhausted.", + }, + { + key: "fastSession", + label: "Enable Fast Session Mode", + description: "Use lighter request handling for faster responses.", + }, + { + key: "sessionRecovery", + label: "Enable Session Recovery", + description: "Restore recoverable sessions after restart.", + }, + { + key: "autoResume", + label: "Enable Auto Resume", + description: "Automatically continue sessions when possible.", + }, + { + key: "perProjectAccounts", + label: "Enable Per-Project Accounts", + description: "Keep separate account lists for each project.", + }, +]; + +export const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ + { + key: "liveAccountSyncDebounceMs", + label: "Live Sync Debounce", + description: "Wait this long before applying sync file changes.", + min: 50, + max: 10_000, + step: 50, + unit: "ms", + }, + { + key: "liveAccountSyncPollMs", + label: "Live Sync Poll", + description: "How often to check files for account updates.", + min: 500, + max: 60_000, + step: 500, + unit: "ms", + }, + { + key: "sessionAffinityTtlMs", + label: "Session Affinity TTL", + description: "How long conversation-to-account mapping is kept.", + min: 1_000, + max: 24 * 60 * 60_000, + step: 60_000, + unit: "ms", + }, + { + key: "sessionAffinityMaxEntries", + label: "Session Affinity Max Entries", + description: "Maximum stored conversation mappings.", + min: 8, + max: 4_096, + step: 32, + unit: "count", + }, + { + key: "proactiveRefreshIntervalMs", + label: "Refresh Guard Interval", + description: "How often to scan for tokens near expiry.", + min: 5_000, + max: 10 * 60_000, + step: 5_000, + unit: "ms", + }, + { + key: "proactiveRefreshBufferMs", + label: "Refresh Guard Buffer", + description: "How early to refresh before expiry.", + min: 30_000, + max: 10 * 60_000, + step: 30_000, + unit: "ms", + }, + { + key: "parallelProbingMaxConcurrency", + label: "Parallel Probe Concurrency", + description: "Maximum checks running at once.", + min: 1, + max: 5, + step: 1, + unit: "count", + }, + { + key: "fastSessionMaxInputItems", + label: "Fast Session Max Inputs", + description: "Max number of input items kept in fast mode.", + min: 8, + max: 200, + step: 2, + unit: "count", + }, + { + key: "networkErrorCooldownMs", + label: "Network Error Cooldown", + description: "Wait time after network errors before retry.", + min: 0, + max: 120_000, + step: 500, + unit: "ms", + }, + { + key: "serverErrorCooldownMs", + label: "Server Error Cooldown", + description: "Wait time after server errors before retry.", + min: 0, + max: 120_000, + step: 500, + unit: "ms", + }, + { + key: "fetchTimeoutMs", + label: "Request Timeout", + description: "Max time to wait for a request.", + min: 1_000, + max: 10 * 60_000, + step: 5_000, + unit: "ms", + }, + { + key: "streamStallTimeoutMs", + label: "Stream Stall Timeout", + description: "Max wait before a stuck stream is retried.", + min: 1_000, + max: 10 * 60_000, + step: 5_000, + unit: "ms", + }, + { + key: "tokenRefreshSkewMs", + label: "Token Refresh Buffer", + description: "Refresh this long before token expiry.", + min: 0, + max: 10 * 60_000, + step: 10_000, + unit: "ms", + }, + { + key: "preemptiveQuotaRemainingPercent5h", + label: "5h Remaining Threshold", + description: "Start delaying when 5h remaining reaches this percent.", + min: 0, + max: 100, + step: 1, + unit: "percent", + }, + { + key: "preemptiveQuotaRemainingPercent7d", + label: "7d Remaining Threshold", + description: "Start delaying when weekly remaining reaches this percent.", + min: 0, + max: 100, + step: 1, + unit: "percent", + }, + { + key: "preemptiveQuotaMaxDeferralMs", + label: "Max Preemptive Deferral", + description: "Maximum time allowed for quota-based delay.", + min: 1_000, + max: 24 * 60 * 60_000, + step: 60_000, + unit: "ms", + }, +]; + +export const BACKEND_CATEGORY_OPTIONS: BackendCategoryOption[] = [ + { + key: "session-sync", + label: "Session & Sync", + description: "Sync and session behavior.", + toggleKeys: [ + "liveAccountSync", + "sessionAffinity", + "perProjectAccounts", + "sessionRecovery", + "autoResume", + ], + numberKeys: [ + "liveAccountSyncDebounceMs", + "liveAccountSyncPollMs", + "sessionAffinityTtlMs", + "sessionAffinityMaxEntries", + ], + }, + { + key: "rotation-quota", + label: "Rotation & Quota", + description: "Quota and retry behavior.", + toggleKeys: ["preemptiveQuotaEnabled", "retryAllAccountsRateLimited"], + numberKeys: [ + "preemptiveQuotaRemainingPercent5h", + "preemptiveQuotaRemainingPercent7d", + "preemptiveQuotaMaxDeferralMs", + ], + }, + { + key: "refresh-recovery", + label: "Refresh & Recovery", + description: "Token refresh and recovery safety.", + toggleKeys: ["proactiveRefreshGuardian", "storageBackupEnabled"], + numberKeys: [ + "proactiveRefreshIntervalMs", + "proactiveRefreshBufferMs", + "tokenRefreshSkewMs", + ], + }, + { + key: "performance-timeouts", + label: "Performance & Timeouts", + description: "Speed, probing, and timeout controls.", + toggleKeys: ["fastSession", "parallelProbing"], + numberKeys: [ + "fastSessionMaxInputItems", + "parallelProbingMaxConcurrency", + "fetchTimeoutMs", + "streamStallTimeoutMs", + "networkErrorCooldownMs", + "serverErrorCooldownMs", + ], + }, +]; + +export const ACCOUNT_LIST_PANEL_KEYS = [ + "menuShowStatusBadge", + "menuShowCurrentBadge", + "menuShowLastUsed", + "menuShowQuotaSummary", + "menuShowQuotaCooldown", + "menuShowFetchStatus", + "menuShowDetailsForUnselectedRows", + "menuHighlightCurrentRow", + "menuSortEnabled", + "menuSortMode", + "menuSortPinCurrent", + "menuSortQuickSwitchVisibleRow", + "menuLayoutMode", +] as const satisfies readonly DashboardSettingKey[]; + +export const STATUSLINE_PANEL_KEYS = ["menuStatuslineFields"] as const satisfies readonly DashboardSettingKey[]; + +export const BEHAVIOR_PANEL_KEYS = [ + "actionAutoReturnMs", + "actionPauseOnKey", + "menuAutoFetchLimits", + "menuShowFetchStatus", + "menuQuotaTtlMs", +] as const satisfies readonly DashboardSettingKey[]; + +export const THEME_PANEL_KEYS = ["uiThemePreset", "uiAccentColor"] as const satisfies readonly DashboardSettingKey[]; + +export type SettingsHubSectionId = "display" | "advanced" | "exit"; +export type SettingsHubAction = + | { type: "account-list" } + | { type: "summary-fields" } + | { type: "behavior" } + | { type: "theme" } + | { type: "backend" } + | { type: "back" }; + +export interface SettingsHubActionViewModel { + id: SettingsHubAction["type"]; + label: string; + tone: "green" | "red"; +} + +export interface SettingsHubSectionViewModel { + id: SettingsHubSectionId; + title: string; + actions: SettingsHubActionViewModel[]; +} + +export interface SettingsHubViewModel { + sections: SettingsHubSectionViewModel[]; +} + +export type SettingsHubCommand = + | { type: "back" } + | { type: "open-dashboard-panel"; panel: "account-list" | "summary-fields" | "behavior" | "theme" } + | { type: "open-backend-settings" }; + +export function buildSettingsHubViewModel(): SettingsHubViewModel { + return { + sections: [ + { + id: "display", + title: UI_COPY.settings.sectionTitle, + actions: [ + { id: "account-list", label: UI_COPY.settings.accountList, tone: "green" }, + { id: "summary-fields", label: UI_COPY.settings.summaryFields, tone: "green" }, + { id: "behavior", label: UI_COPY.settings.behavior, tone: "green" }, + { id: "theme", label: UI_COPY.settings.theme, tone: "green" }, + ], + }, + { + id: "advanced", + title: UI_COPY.settings.advancedTitle, + actions: [{ id: "backend", label: UI_COPY.settings.backend, tone: "green" }], + }, + { + id: "exit", + title: UI_COPY.settings.exitTitle, + actions: [{ id: "back", label: UI_COPY.settings.back, tone: "red" }], + }, + ], + }; +} + +export function resolveSettingsHubCommand(action: SettingsHubAction): SettingsHubCommand { + switch (action.type) { + case "account-list": + case "summary-fields": + case "behavior": + case "theme": + return { type: "open-dashboard-panel", panel: action.type }; + case "backend": + return { type: "open-backend-settings" }; + case "back": + return { type: "back" }; + } +} diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index 76de00f6..52cebe40 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -8,6 +8,13 @@ const log = createLogger("response-handler"); const MAX_SSE_SIZE = 10 * 1024 * 1024; // 10MB limit to prevent memory exhaustion const DEFAULT_STREAM_STALL_TIMEOUT_MS = 45_000; +type MinimalReadableStreamReader = Pick< + ReadableStreamDefaultReader, + "read" | "cancel" | "releaseLock" +>; + +type StreamReadResult = Awaited>; + /** * Parse SSE stream to extract final response @@ -135,9 +142,9 @@ export function ensureContentType(headers: Headers): Headers { } async function readWithTimeout( - reader: ReadableStreamDefaultReader, + reader: MinimalReadableStreamReader, timeoutMs: number, -): Promise<{ done: boolean; value?: Uint8Array }> { +): Promise { let timeoutId: ReturnType | undefined; try { return await Promise.race([ diff --git a/lib/request/stream-failover.ts b/lib/request/stream-failover.ts index 0dced4e1..cdb65efd 100644 --- a/lib/request/stream-failover.ts +++ b/lib/request/stream-failover.ts @@ -13,6 +13,9 @@ const DEFAULT_STALL_TIMEOUT_MS = 45_000; const DEFAULT_SOFT_TIMEOUT_MS = 15_000; const MAX_REQUEST_INSTANCE_ID_LENGTH = 64; +type StreamReader = Pick, "read" | "cancel" | "releaseLock">; +type StreamReadResult = Awaited>; + class StallTimeoutError extends Error { readonly isStallTimeout = true; @@ -32,9 +35,9 @@ class StallTimeoutError extends Error { * @returns The result of `reader.read()`: an object with `done` and `value` (`Uint8Array | undefined`). */ async function readChunkWithTimeout( - readPromise: Promise["read"]>>>, + readPromise: Promise, timeoutMs: number, -): Promise["read"]>>> { +): Promise { let timeoutId: ReturnType | undefined; try { return await Promise.race([ @@ -85,10 +88,11 @@ function normalizeRequestInstanceId(value: string | undefined): string | null { } async function readChunkWithSoftHardTimeout( - reader: ReadableStreamDefaultReader, + reader: StreamReader, softTimeoutMs: number, hardTimeoutMs: number, -): Promise["read"]>>> { + +): Promise { const readPromise = reader.read(); try { return await readChunkWithTimeout(readPromise, softTimeoutMs); diff --git a/lib/storage.ts b/lib/storage.ts index 5f9696d6..bc3628a9 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2037,18 +2037,14 @@ export async function importAccounts(filePath: string): Promise<{ imported: numb const existingActiveIndex = existing?.activeIndex ?? 0; const merged = [...existingAccounts, ...normalized.accounts]; + const deduplicatedAccounts = deduplicateAccountsByEmail(deduplicateAccounts(merged)); - if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - const deduped = deduplicateAccountsByEmail(deduplicateAccounts(merged)); - if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - throw new Error( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})` - ); - } + if (deduplicatedAccounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + throw new Error( + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduplicatedAccounts.length})` + ); } - const deduplicatedAccounts = deduplicateAccountsByEmail(deduplicateAccounts(merged)); - const newStorage: AccountStorageV3 = { version: 3, accounts: deduplicatedAccounts, diff --git a/lib/ui-ink/auth-shell.ts b/lib/ui-ink/auth-shell.ts new file mode 100644 index 00000000..a04ec51a --- /dev/null +++ b/lib/ui-ink/auth-shell.ts @@ -0,0 +1,243 @@ +import { createElement, useMemo, useState } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { UI_COPY } from "../ui/copy.js"; +import type { + AuthAccountViewModel, + AuthDashboardActionViewModel, + AuthDashboardSectionId, + AuthDashboardViewModel, +} from "../codex-manager/auth-ui-controller.js"; +import { + InkShellFrame, + InkShellPanel, + InkShellRow, + InkShellSectionTab, + createInkShellTheme, + type InkShellTone, +} from "./layout.js"; + +export interface AuthInkShellEntry { + id: string; + label: string; + detail?: string; + tone: InkShellTone; + kind: "action" | "account"; +} + +export interface AuthInkShellFocusState { + sectionIndex: number; + entryIndex: number; +} + +export type AuthInkShellFocusAction = + | { type: "move-section"; direction: 1 | -1 } + | { type: "move-entry"; direction: 1 | -1 } + | { type: "reset" }; + +function mapActionTone(action: AuthDashboardActionViewModel): InkShellTone { + switch (action.tone) { + case "red": + return "danger"; + case "yellow": + return "warning"; + default: + return "success"; + } +} + +function formatAccountDetail(account: AuthAccountViewModel): string | undefined { + const parts: string[] = []; + if (account.isCurrentAccount) parts.push("current"); + if (account.status) parts.push(`status ${account.status}`); + if (account.quotaSummary) parts.push(account.quotaSummary); + return parts.length > 0 ? parts.join(" | ") : undefined; +} + +function toneForAccount(account: AuthAccountViewModel): InkShellTone { + switch (account.status) { + case "active": + case "ok": + return "success"; + case "rate-limited": + case "cooldown": + return "warning"; + case "disabled": + case "error": + case "flagged": + return "danger"; + default: + return "muted"; + } +} + +function entriesForSection( + dashboard: AuthDashboardViewModel, + sectionId: AuthDashboardSectionId, +): AuthInkShellEntry[] { + if (sectionId === "saved-accounts") { + return dashboard.accounts.map((account, index) => ({ + id: `account:${account.sourceIndex ?? index}`, + label: account.email ?? account.accountLabel ?? account.accountId ?? `Account ${index + 1}`, + detail: formatAccountDetail(account), + tone: toneForAccount(account), + kind: "account", + })); + } + + const section = dashboard.sections.find((candidate) => candidate.id === sectionId); + return (section?.actions ?? []).map((action) => ({ + id: `action:${action.id}`, + label: action.label, + tone: mapActionTone(action), + kind: "action", + })); +} + +function normalizeFocus( + dashboard: AuthDashboardViewModel, + focus: AuthInkShellFocusState, +): AuthInkShellFocusState { + const sectionCount = dashboard.sections.length; + if (sectionCount === 0) { + return { sectionIndex: 0, entryIndex: 0 }; + } + const sectionIndex = Math.max(0, Math.min(focus.sectionIndex, sectionCount - 1)); + const section = dashboard.sections[sectionIndex]; + const entries = section ? entriesForSection(dashboard, section.id) : []; + const entryIndex = entries.length === 0 ? 0 : Math.max(0, Math.min(focus.entryIndex, entries.length - 1)); + return { sectionIndex, entryIndex }; +} + +export function createAuthInkShellFocusState( + dashboard: AuthDashboardViewModel, +): AuthInkShellFocusState { + return normalizeFocus(dashboard, { sectionIndex: 0, entryIndex: 0 }); +} + +export function reduceAuthInkShellFocus( + dashboard: AuthDashboardViewModel, + focus: AuthInkShellFocusState, + action: AuthInkShellFocusAction, +): AuthInkShellFocusState { + const normalized = normalizeFocus(dashboard, focus); + const sectionCount = dashboard.sections.length; + if (sectionCount === 0) return normalized; + const currentSection = dashboard.sections[normalized.sectionIndex]; + const currentEntries = currentSection ? entriesForSection(dashboard, currentSection.id) : []; + + switch (action.type) { + case "reset": + return createAuthInkShellFocusState(dashboard); + case "move-section": { + const sectionIndex = (normalized.sectionIndex + action.direction + sectionCount) % sectionCount; + return normalizeFocus(dashboard, { sectionIndex, entryIndex: 0 }); + } + case "move-entry": { + if (currentEntries.length === 0) return normalized; + const entryIndex = (normalized.entryIndex + action.direction + currentEntries.length) % currentEntries.length; + return normalizeFocus(dashboard, { sectionIndex: normalized.sectionIndex, entryIndex }); + } + } + return normalized; +} + +function resolveStatusText(dashboard: AuthDashboardViewModel, explicitStatus?: string): string | undefined { + if (explicitStatus && explicitStatus.trim().length > 0) return explicitStatus.trim(); + const raw = dashboard.menuOptions.statusMessage; + if (typeof raw === "function") { + const resolved = raw(); + return typeof resolved === "string" && resolved.trim().length > 0 ? resolved.trim() : undefined; + } + if (typeof raw === "string" && raw.trim().length > 0) return raw.trim(); + return dashboard.accounts.length > 0 ? `${dashboard.accounts.length} saved account(s)` : undefined; +} + +function resolveStatusTone(dashboard: AuthDashboardViewModel): InkShellTone { + return (dashboard.menuOptions.flaggedCount ?? 0) > 0 ? "warning" : "accent"; +} + +function focusActionFromInput(input: string, key: Key): AuthInkShellFocusAction | null { + if (key.leftArrow || (key.shift && key.tab)) { + return { type: "move-section", direction: -1 }; + } + if (key.rightArrow || key.tab) { + return { type: "move-section", direction: 1 }; + } + if (key.upArrow) { + return { type: "move-entry", direction: -1 }; + } + if (key.downArrow) { + return { type: "move-entry", direction: 1 }; + } + if (input.toLowerCase() === "r") { + return { type: "reset" }; + } + return null; +} + +export interface AuthInkShellProps { + dashboard: AuthDashboardViewModel; + title?: string; + subtitle?: string; + statusText?: string; + footerText?: string; +} + +export function AuthInkShell(props: AuthInkShellProps) { + const theme = createInkShellTheme(); + const [focus, setFocus] = useState(() => createAuthInkShellFocusState(props.dashboard)); + const normalizedFocus = useMemo(() => normalizeFocus(props.dashboard, focus), [focus, props.dashboard]); + const activeSection = props.dashboard.sections[normalizedFocus.sectionIndex]; + const activeEntries = activeSection ? entriesForSection(props.dashboard, activeSection.id) : []; + + useInput((input, key) => { + const action = focusActionFromInput(input, key); + if (!action) return; + setFocus((current) => reduceAuthInkShellFocus(props.dashboard, current, action)); + }); + + return createElement( + InkShellFrame, + { + title: props.title ?? UI_COPY.mainMenu.title, + subtitle: props.subtitle ?? "Ink auth shell foundation for dashboard and settings migration", + status: resolveStatusText(props.dashboard, props.statusText), + statusTone: resolveStatusTone(props.dashboard), + footer: props.footerText ?? "Left/Right switch sections | Up/Down move focus | R reset focus", + theme, + }, + createElement( + Box, + { marginBottom: 1, flexDirection: "row", columnGap: 1 }, + ...props.dashboard.sections.map((section, index) => + createElement(InkShellSectionTab, { + key: section.id, + label: section.title, + active: index === normalizedFocus.sectionIndex, + tone: section.id === "danger-zone" ? "danger" : section.id === "advanced-checks" ? "warning" : "accent", + theme, + }), + ), + ), + createElement( + InkShellPanel, + { title: activeSection?.title ?? UI_COPY.mainMenu.title, theme }, + activeEntries.length > 0 + ? createElement( + Box, + { flexDirection: "column" }, + ...activeEntries.map((entry, index) => + createElement(InkShellRow, { + key: entry.id, + label: entry.label, + detail: entry.detail, + active: index === normalizedFocus.entryIndex, + tone: entry.tone, + theme, + }), + ), + ) + : createElement(Text, { color: theme.mutedColor }, "No items yet"), + ), + ); +} diff --git a/lib/ui-ink/bootstrap.ts b/lib/ui-ink/bootstrap.ts new file mode 100644 index 00000000..cd8862cf --- /dev/null +++ b/lib/ui-ink/bootstrap.ts @@ -0,0 +1,85 @@ +import { createElement } from "react"; +import { render, type Instance, type RenderOptions } from "ink"; +import type { AuthDashboardViewModel } from "../codex-manager/auth-ui-controller.js"; +import { AuthInkShell, type AuthInkShellProps } from "./auth-shell.js"; + +export type InkAuthShellBootstrapReason = + | "stdin-not-tty" + | "stdout-not-tty" + | "host-managed-ui"; + +export interface InkAuthShellEnvironment { + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + env?: NodeJS.ProcessEnv; +} + +export interface InkAuthShellBootstrapResult { + supported: boolean; + reason?: InkAuthShellBootstrapReason; +} + +function isHostManagedUi(env: NodeJS.ProcessEnv): boolean { + if (env.FORCE_INTERACTIVE_MODE === "1") return false; + if (env.CODEX_TUI === "1") return true; + if (env.CODEX_DESKTOP === "1") return true; + if ((env.TERM_PROGRAM ?? "").trim().toLowerCase() === "codex") return true; + if (env.ELECTRON_RUN_AS_NODE === "1") return true; + return false; +} + +export function resolveInkAuthShellBootstrap( + environment: InkAuthShellEnvironment = {}, +): InkAuthShellBootstrapResult { + const stdin = environment.stdin ?? process.stdin; + const stdout = environment.stdout ?? process.stdout; + const env = environment.env ?? process.env; + if (!stdin.isTTY) { + return { supported: false, reason: "stdin-not-tty" }; + } + if (!stdout.isTTY) { + return { supported: false, reason: "stdout-not-tty" }; + } + if (isHostManagedUi(env)) { + return { supported: false, reason: "host-managed-ui" }; + } + return { supported: true }; +} + +export interface StartInkAuthShellOptions extends InkAuthShellEnvironment { + dashboard: AuthDashboardViewModel; + title?: string; + subtitle?: string; + statusText?: string; + footerText?: string; + stderr?: NodeJS.WriteStream; + debug?: boolean; + patchConsole?: boolean; + exitOnCtrlC?: boolean; +} + +export function startInkAuthShell(options: StartInkAuthShellOptions): Instance | null { + const support = resolveInkAuthShellBootstrap(options); + if (!support.supported) { + return null; + } + + const props: AuthInkShellProps = { + dashboard: options.dashboard, + title: options.title, + subtitle: options.subtitle, + statusText: options.statusText, + footerText: options.footerText, + }; + + const renderOptions: RenderOptions = { + stdin: options.stdin ?? process.stdin, + stdout: options.stdout ?? process.stdout, + stderr: options.stderr ?? process.stderr, + debug: options.debug ?? false, + patchConsole: options.patchConsole ?? false, + exitOnCtrlC: options.exitOnCtrlC ?? false, + }; + + return render(createElement(AuthInkShell, props), renderOptions); +} diff --git a/lib/ui-ink/dashboard.ts b/lib/ui-ink/dashboard.ts new file mode 100644 index 00000000..10c5477a --- /dev/null +++ b/lib/ui-ink/dashboard.ts @@ -0,0 +1,681 @@ +import { createElement, useEffect, useMemo, useState } from "react"; +import { createInterface } from "node:readline/promises"; +import { Box, Text, render, useApp, useInput, type Instance, type RenderOptions } from "ink"; +import type { LoginMenuResult } from "../cli.js"; +import type { + AuthAccountViewModel, + AuthDashboardActionViewModel, + AuthDashboardSectionId, + AuthDashboardViewModel, +} from "../codex-manager/auth-ui-controller.js"; +import { UI_COPY } from "../ui/copy.js"; +import { + InkShellFrame, + InkShellPanel, + InkShellRow, + InkShellSectionTab, + createInkShellTheme, + type InkShellTone, +} from "./layout.js"; +import { + resolveInkAuthShellBootstrap, + type InkAuthShellEnvironment, +} from "./bootstrap.js"; +import { + promptInkAccountDetails, + promptInkConfirmAccountDelete, + promptInkConfirmAccountRefresh, + promptInkConfirmDeleteAll, +} from "./detail-flows.js"; + +interface AuthInkDashboardEntry { + id: string; + label: string; + detail?: string; + tone: InkShellTone; + kind: "action" | "account"; + actionId?: AuthDashboardActionViewModel["id"]; + account?: AuthAccountViewModel; +} + +interface AuthInkDashboardFocusState { + sectionIndex: number; + entryIndex: number; +} + +type AuthInkDashboardOutcome = + | { type: "menu-result"; result: LoginMenuResult } + | { type: "saved-account"; account: AuthAccountViewModel }; + +interface AuthInkDashboardResolution { + result: LoginMenuResult | null; + statusText?: string; + statusTone?: InkShellTone; +} + +export interface PromptInkAuthDashboardOptions extends InkAuthShellEnvironment { + dashboard: AuthDashboardViewModel; + statusTextOverride?: string; + statusToneOverride?: InkShellTone; + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + stderr?: NodeJS.WriteStream; + debug?: boolean; + patchConsole?: boolean; + exitOnCtrlC?: boolean; +} + +function resolveCliVersionLabel(env: NodeJS.ProcessEnv = process.env): string | null { + const raw = (env.CODEX_MULTI_AUTH_CLI_VERSION ?? "").trim(); + if (raw.length === 0) return null; + return raw.startsWith("v") ? raw : `v${raw}`; +} + +function resolveDashboardTitle(env: NodeJS.ProcessEnv = process.env): string { + const versionLabel = resolveCliVersionLabel(env); + if (!versionLabel) return UI_COPY.mainMenu.title; + return `${UI_COPY.mainMenu.title} (${versionLabel})`; +} + +function formatRelativeTime(timestamp: number | undefined): string { + if (!timestamp) return "never"; + const days = Math.floor((Date.now() - timestamp) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d ago`; + if (days < 30) return `${Math.floor(days / 7)}w ago`; + return new Date(timestamp).toLocaleDateString(); +} + +function actionTone(action: AuthDashboardActionViewModel): InkShellTone { + switch (action.tone) { + case "red": + return "danger"; + case "yellow": + return "warning"; + default: + return "success"; + } +} + +function accountTone(account: AuthAccountViewModel): InkShellTone { + switch (account.status) { + case "active": + case "ok": + return "success"; + case "rate-limited": + case "cooldown": + return "warning"; + case "disabled": + case "error": + case "flagged": + return "danger"; + default: + return "muted"; + } +} + +function formatAccountLabel(account: AuthAccountViewModel, displayIndex: number): string { + const number = account.quickSwitchNumber ?? (displayIndex + 1); + const base = account.email ?? account.accountLabel ?? account.accountId ?? `Account ${number}`; + return `${number}. ${base}`; +} + +function formatAccountDetail(account: AuthAccountViewModel): string | undefined { + const parts: string[] = []; + if (account.isCurrentAccount) parts.push("current"); + if (account.status) parts.push(`status ${account.status}`); + if (account.showLastUsed !== false) { + parts.push(`last used ${formatRelativeTime(account.lastUsed)}`); + } + if (account.quotaSummary) parts.push(account.quotaSummary); + return parts.length > 0 ? parts.join(" | ") : undefined; +} + +export function filterAuthInkDashboardAccounts( + dashboard: AuthDashboardViewModel, + searchQuery: string, +): AuthAccountViewModel[] { + const normalized = searchQuery.trim().toLowerCase(); + if (normalized.length === 0) return dashboard.accounts; + return dashboard.accounts.filter((account) => { + const candidates = [ + account.email, + account.accountLabel, + account.accountId, + String(account.quickSwitchNumber ?? (account.index + 1)), + ]; + return candidates.some((candidate) => + typeof candidate === "string" && candidate.toLowerCase().includes(normalized) + ); + }); +} + +function filteredDashboard( + dashboard: AuthDashboardViewModel, + searchQuery: string, +): AuthDashboardViewModel { + return { + ...dashboard, + accounts: filterAuthInkDashboardAccounts(dashboard, searchQuery), + }; +} + +function entriesForSection( + dashboard: AuthDashboardViewModel, + sectionId: AuthDashboardSectionId, +): AuthInkDashboardEntry[] { + if (sectionId === "saved-accounts") { + return dashboard.accounts.map((account, index) => ({ + id: `account:${account.sourceIndex ?? index}`, + label: formatAccountLabel(account, index), + detail: formatAccountDetail(account), + tone: accountTone(account), + kind: "account", + account, + })); + } + + const section = dashboard.sections.find((candidate) => candidate.id === sectionId); + return (section?.actions ?? []).map((action) => ({ + id: `action:${action.id}`, + label: action.label, + tone: actionTone(action), + kind: "action", + actionId: action.id, + })); +} + +function normalizeFocus( + dashboard: AuthDashboardViewModel, + focus: AuthInkDashboardFocusState, +): AuthInkDashboardFocusState { + const sectionCount = dashboard.sections.length; + if (sectionCount === 0) return { sectionIndex: 0, entryIndex: 0 }; + const sectionIndex = Math.max(0, Math.min(focus.sectionIndex, sectionCount - 1)); + const section = dashboard.sections[sectionIndex]; + const entries = section ? entriesForSection(dashboard, section.id) : []; + const entryIndex = entries.length === 0 ? 0 : Math.max(0, Math.min(focus.entryIndex, entries.length - 1)); + return { sectionIndex, entryIndex }; +} + +function resolveSelectedEntry( + dashboard: AuthDashboardViewModel, + focus: AuthInkDashboardFocusState, +): AuthInkDashboardEntry | null { + const normalized = normalizeFocus(dashboard, focus); + const section = dashboard.sections[normalized.sectionIndex]; + if (!section) return null; + const entries = entriesForSection(dashboard, section.id); + return entries[normalized.entryIndex] ?? null; +} + +function focusSectionById( + dashboard: AuthDashboardViewModel, + sectionId: AuthDashboardSectionId, +): AuthInkDashboardFocusState { + const index = dashboard.sections.findIndex((section) => section.id === sectionId); + return normalizeFocus(dashboard, { + sectionIndex: index >= 0 ? index : 0, + entryIndex: 0, + }); +} + +function moveFocus( + dashboard: AuthDashboardViewModel, + focus: AuthInkDashboardFocusState, + action: { type: "move-section"; direction: 1 | -1 } | { type: "move-entry"; direction: 1 | -1 } | { type: "reset" }, +): AuthInkDashboardFocusState { + const normalized = normalizeFocus(dashboard, focus); + const sectionCount = dashboard.sections.length; + if (sectionCount === 0) return normalized; + if (action.type === "reset") { + return { sectionIndex: 0, entryIndex: 0 }; + } + if (action.type === "move-section") { + return normalizeFocus(dashboard, { + sectionIndex: (normalized.sectionIndex + action.direction + sectionCount) % sectionCount, + entryIndex: 0, + }); + } + const section = dashboard.sections[normalized.sectionIndex]; + const entries = section ? entriesForSection(dashboard, section.id) : []; + if (entries.length === 0) return normalized; + return normalizeFocus(dashboard, { + sectionIndex: normalized.sectionIndex, + entryIndex: (normalized.entryIndex + action.direction + entries.length) % entries.length, + }); +} + +function resolveMenuResultFromAction(actionId: AuthDashboardActionViewModel["id"]): LoginMenuResult { + switch (actionId) { + case "add": + return { mode: "add" }; + case "check": + return { mode: "check" }; + case "forecast": + return { mode: "forecast" }; + case "fix": + return { mode: "fix" }; + case "settings": + return { mode: "settings" }; + case "deep-check": + return { mode: "deep-check" }; + case "verify-flagged": + return { mode: "verify-flagged" }; + case "delete-all": + return { mode: "fresh" }; + } + return { mode: "cancel" }; +} + +function resolveVisibleQuickSwitchMap(accounts: AuthAccountViewModel[]): { + byNumber: Map; + duplicates: Set; +} { + const byNumber = new Map(); + const duplicates = new Set(); + for (const account of accounts) { + const quickSwitchNumber = account.quickSwitchNumber ?? (account.index + 1); + if (byNumber.has(quickSwitchNumber)) { + duplicates.add(quickSwitchNumber); + continue; + } + byNumber.set(quickSwitchNumber, account); + } + return { byNumber, duplicates }; +} + +export function resolveAuthInkQuickSwitch( + dashboard: AuthDashboardViewModel, + searchQuery: string, + raw: string, +): LoginMenuResult | null { + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 9) return null; + const visibleAccounts = filterAuthInkDashboardAccounts(dashboard, searchQuery); + const { byNumber, duplicates } = resolveVisibleQuickSwitchMap(visibleAccounts); + if (duplicates.has(parsed)) return null; + const account = byNumber.get(parsed); + if (!account) return null; + const sourceIndex = typeof account.sourceIndex === "number" ? account.sourceIndex : account.index; + return { mode: "manage", switchAccountIndex: sourceIndex }; +} + + +function resolveStatusText(dashboard: AuthDashboardViewModel, statusOverride?: string): string | undefined { + if (statusOverride && statusOverride.trim().length > 0) return statusOverride.trim(); + const raw = dashboard.menuOptions.statusMessage; + if (typeof raw === "function") { + const resolved = raw(); + return typeof resolved === "string" && resolved.trim().length > 0 ? resolved.trim() : undefined; + } + if (typeof raw === "string" && raw.trim().length > 0) return raw.trim(); + return dashboard.accounts.length > 0 ? `${dashboard.accounts.length} saved account(s)` : undefined; +} + + +function resolveStatusTone( + dashboard: AuthDashboardViewModel, + statusOverride?: string, + statusToneOverride?: InkShellTone, +): InkShellTone { + if (statusToneOverride) return statusToneOverride; + const normalized = statusOverride?.trim().toLowerCase() ?? ""; + if (normalized.includes("restored")) return "success"; + if (normalized.includes("cancelled") || normalized.includes("skipping") || normalized.includes("restore")) { + return "warning"; + } + return (dashboard.menuOptions.flaggedCount ?? 0) > 0 ? "warning" : "accent"; +} + +function compactFooter(): string { + return "Left/Right Sections | Up/Down Move | Enter Select | / Search | 1-9 Switch | Q Back"; +} + +function detailedFooter(): string { + return "Arrow keys move, Left/Right switches sections, Enter selects, / filters saved accounts, 1-9 quick switches, Q goes back"; +} + +function searchFooter(): string { + return "Search mode: type to filter | Enter apply | Backspace delete | Esc cancel"; +} + +function resolveSectionTone(sectionId: AuthDashboardSectionId): InkShellTone { + if (sectionId === "danger-zone") return "danger"; + if (sectionId === "advanced-checks") return "warning"; + if (sectionId === "saved-accounts") return "success"; + return "accent"; +} + +function resolveAccountSourceIndex(account: AuthAccountViewModel): number { + if (typeof account.sourceIndex === "number" && Number.isFinite(account.sourceIndex)) { + return Math.max(0, Math.floor(account.sourceIndex)); + } + if (typeof account.index === "number" && Number.isFinite(account.index)) { + return Math.max(0, Math.floor(account.index)); + } + return -1; +} + +function warnUnresolvableAccountSelection(account: AuthAccountViewModel): void { + const label = account.email?.trim() || account.accountId?.trim() || `index ${account.index + 1}`; + console.log(`Unable to resolve saved account for action: ${label}`); +} + +async function resolveSavedAccountOutcome( + account: AuthAccountViewModel, + options: PromptInkAuthDashboardOptions, +): Promise { + const accountAction = await promptInkAccountDetails({ + ...options, + account, + }); + if (accountAction === null || accountAction === "back" || accountAction === "cancel") { + return { result: null }; + } + const sourceIndex = resolveAccountSourceIndex(account); + if (sourceIndex < 0) { + warnUnresolvableAccountSelection(account); + return { + result: null, + statusText: "Could not resolve the selected account.", + statusTone: "danger", + }; + } + if (accountAction === "delete") { + const confirmed = await promptInkConfirmAccountDelete({ + ...options, + account, + }); + if (!confirmed) { + return { result: null, statusText: "Delete cancelled.", statusTone: "warning" }; + } + return { result: { mode: "manage", deleteAccountIndex: sourceIndex } }; + } + if (accountAction === "refresh") { + const confirmed = await promptInkConfirmAccountRefresh({ + ...options, + account, + }); + if (!confirmed) { + return { result: null, statusText: "Re-login cancelled.", statusTone: "warning" }; + } + return { result: { mode: "manage", refreshAccountIndex: sourceIndex } }; + } + if (accountAction === "toggle") { + return { result: { mode: "manage", toggleAccountIndex: sourceIndex } }; + } + if (accountAction === "set-current") { + return { result: { mode: "manage", switchAccountIndex: sourceIndex } }; + } + return { result: null }; +} + +async function promptDeleteAllTypedConfirm( + stdin: NodeJS.ReadStream, + stdout: NodeJS.WriteStream, +): Promise { + const rl = createInterface({ input: stdin, output: stdout }); + try { + const answer = await rl.question("Type DELETE to remove all saved accounts: "); + return answer.trim() === "DELETE"; + } finally { + rl.close(); + } +} + +interface AuthInkDashboardAppProps { + dashboard: AuthDashboardViewModel; + title: string; + statusTextOverride?: string; + statusToneOverride?: InkShellTone; + onResolve: (outcome: AuthInkDashboardOutcome) => void; +} + +function AuthInkDashboardApp(props: AuthInkDashboardAppProps) { + const { exit } = useApp(); + const theme = createInkShellTheme(); + const [focus, setFocus] = useState({ sectionIndex: 0, entryIndex: 0 }); + const [searchQuery, setSearchQuery] = useState(""); + const [searchMode, setSearchMode] = useState(false); + const [showDetailedHelp, setShowDetailedHelp] = useState(false); + const [, setPulse] = useState(0); + + useEffect(() => { + if (typeof props.dashboard.menuOptions.statusMessage !== "function") return undefined; + const timer = setInterval(() => { + setPulse((current) => current + 1); + }, 200); + return () => clearInterval(timer); + }, [props.dashboard.menuOptions.statusMessage]); + + const visibleDashboard = useMemo( + () => filteredDashboard(props.dashboard, searchQuery), + [props.dashboard, searchQuery], + ); + const normalizedFocus = normalizeFocus(visibleDashboard, focus); + const activeSection = visibleDashboard.sections[normalizedFocus.sectionIndex]; + const activeEntries = activeSection ? entriesForSection(visibleDashboard, activeSection.id) : []; + const selectedEntry = resolveSelectedEntry(visibleDashboard, normalizedFocus); + const subtitle = searchQuery.trim().length > 0 + ? `${UI_COPY.mainMenu.searchSubtitlePrefix} ${searchQuery.trim()}` + : undefined; + const footer = searchMode ? searchFooter() : showDetailedHelp ? detailedFooter() : compactFooter(); + + useInput((input, key) => { + if (searchMode) { + if (key.escape) { + setSearchMode(false); + return; + } + if (key.return) { + setSearchMode(false); + return; + } + if (key.backspace || key.delete) { + setSearchQuery((current) => current.slice(0, -1)); + return; + } + if (input && !key.ctrl && !key.meta) { + setSearchQuery((current) => `${current}${input}`.toLowerCase()); + } + return; + } + + if (key.leftArrow || (key.shift && key.tab)) { + setFocus((current) => moveFocus(visibleDashboard, current, { type: "move-section", direction: -1 })); + return; + } + if (key.rightArrow || key.tab) { + setFocus((current) => moveFocus(visibleDashboard, current, { type: "move-section", direction: 1 })); + return; + } + if (key.upArrow) { + setFocus((current) => moveFocus(visibleDashboard, current, { type: "move-entry", direction: -1 })); + return; + } + if (key.downArrow) { + setFocus((current) => moveFocus(visibleDashboard, current, { type: "move-entry", direction: 1 })); + return; + } + + const lower = input.toLowerCase(); + if (lower === "?") { + setShowDetailedHelp((current) => !current); + return; + } + if (lower === "q") { + props.onResolve({ type: "menu-result", result: { mode: "cancel" } }); + exit(); + return; + } + if (lower === "/") { + setSearchMode(true); + setFocus(focusSectionById(visibleDashboard, "saved-accounts")); + return; + } + if (lower === "r") { + setFocus({ sectionIndex: 0, entryIndex: 0 }); + return; + } + + const quickSwitch = resolveAuthInkQuickSwitch(visibleDashboard, "", input); + if (quickSwitch) { + props.onResolve({ type: "menu-result", result: quickSwitch }); + exit(); + return; + } + + if (!key.return || !selectedEntry) return; + if (selectedEntry.kind === "account" && selectedEntry.account) { + props.onResolve({ type: "saved-account", account: selectedEntry.account }); + exit(); + return; + } + if (selectedEntry.kind === "action" && selectedEntry.actionId) { + props.onResolve({ + type: "menu-result", + result: resolveMenuResultFromAction(selectedEntry.actionId), + }); + exit(); + } + }); + + return createElement( + InkShellFrame, + { + title: props.title, + subtitle, + status: resolveStatusText(visibleDashboard, props.statusTextOverride), + statusTone: resolveStatusTone(visibleDashboard, props.statusTextOverride, props.statusToneOverride), + footer, + theme, + }, + createElement( + Box, + { marginBottom: 1, flexDirection: "row", columnGap: 1 }, + ...visibleDashboard.sections.map((section, index) => + createElement(InkShellSectionTab, { + key: section.id, + label: section.title, + active: index === normalizedFocus.sectionIndex, + tone: resolveSectionTone(section.id), + theme, + }), + ), + ), + createElement( + InkShellPanel, + { title: activeSection?.title ?? UI_COPY.mainMenu.title, theme }, + activeEntries.length > 0 + ? createElement( + Box, + { flexDirection: "column" }, + ...activeEntries.map((entry, index) => + createElement(InkShellRow, { + key: entry.id, + label: entry.label, + detail: entry.detail, + active: index === normalizedFocus.entryIndex, + tone: entry.tone, + theme, + }), + ), + ) + : createElement( + Text, + { color: theme.mutedColor }, + searchQuery.trim().length > 0 ? UI_COPY.mainMenu.noSearchMatches : "No items yet", + ), + ), + ); +} + +async function renderInkDashboardOnce( + options: PromptInkAuthDashboardOptions, +): Promise { + return await new Promise((resolve) => { + let instance: Instance | null = null; + let settled = false; + const finish = (outcome: AuthInkDashboardOutcome) => { + if (settled) return; + settled = true; + instance?.unmount(); + instance?.cleanup(); + resolve(outcome); + }; + + const renderOptions: RenderOptions = { + stdin: options.stdin ?? process.stdin, + stdout: options.stdout ?? process.stdout, + stderr: options.stderr ?? process.stderr, + debug: options.debug ?? false, + patchConsole: options.patchConsole ?? false, + exitOnCtrlC: options.exitOnCtrlC ?? false, + }; + + instance = render( + createElement(AuthInkDashboardApp, { + dashboard: options.dashboard, + title: resolveDashboardTitle(options.env ?? process.env), + statusTextOverride: options.statusTextOverride, + statusToneOverride: options.statusToneOverride, + onResolve: finish, + }), + renderOptions, + ); + }); +} + +export async function promptInkAuthDashboard( + options: PromptInkAuthDashboardOptions, +): Promise { + const support = resolveInkAuthShellBootstrap(options); + if (!support.supported) return null; + + const stdin = options.stdin ?? process.stdin; + const stdout = options.stdout ?? process.stdout; + let statusTextOverride = options.statusTextOverride; + let statusToneOverride = options.statusToneOverride; + + while (true) { + const outcome = await renderInkDashboardOnce({ + ...options, + statusTextOverride, + statusToneOverride, + }); + statusTextOverride = undefined; + statusToneOverride = undefined; + if (outcome.type === "menu-result") { + if (outcome.result.mode !== "fresh") { + return outcome.result; + } + const confirmed = await promptInkConfirmDeleteAll({ + ...options, + stdin, + stdout, + }); + if (confirmed === null) { + const fallbackConfirmed = await promptDeleteAllTypedConfirm(stdin, stdout); + if (fallbackConfirmed) { + return { mode: "fresh", deleteAll: true }; + } + statusTextOverride = "Delete all cancelled."; + statusToneOverride = "warning"; + continue; + } + if (confirmed) { + return { mode: "fresh", deleteAll: true }; + } + statusTextOverride = "Delete all cancelled."; + statusToneOverride = "warning"; + continue; + } + + const savedAccountResolution = await resolveSavedAccountOutcome(outcome.account, options); + if (savedAccountResolution.result) return savedAccountResolution.result; + statusTextOverride = savedAccountResolution.statusText; + statusToneOverride = savedAccountResolution.statusTone; + } +} diff --git a/lib/ui-ink/detail-flows.ts b/lib/ui-ink/detail-flows.ts new file mode 100644 index 00000000..8a2be9b2 --- /dev/null +++ b/lib/ui-ink/detail-flows.ts @@ -0,0 +1,464 @@ +import { createElement, useRef, useState } from "react"; +import { Box, Text, render, useApp, useInput, type RenderOptions } from "ink"; +import type { AuthAccountViewModel } from "../codex-manager/auth-ui-controller.js"; +import { + InkShellFrame, + InkShellPanel, + InkShellRow, + createInkShellTheme, + type InkShellTone, +} from "./layout.js"; +import { + resolveInkAuthShellBootstrap, + type InkAuthShellEnvironment, +} from "./bootstrap.js"; + +type InkChoiceValue = string; + +interface InkChoiceItem { + value: InkChoiceValue; + label: string; + detail?: string; + tone: InkShellTone; + hotkeys?: string[]; +} + +interface PromptInkChoiceOptions extends InkAuthShellEnvironment { + title: string; + panelTitle?: string; + subtitle?: string; + status?: string; + statusTone?: InkShellTone; + footer: string; + items: InkChoiceItem[]; + cancelValue?: InkChoiceValue | null; + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + stderr?: NodeJS.WriteStream; + debug?: boolean; + patchConsole?: boolean; + exitOnCtrlC?: boolean; +} + +interface InkChoiceAppProps extends PromptInkChoiceOptions { + onResolve: (value: InkChoiceValue | null) => void; +} + +function formatRelativeTime(timestamp: number | undefined): string { + if (!timestamp) return "never"; + const days = Math.floor((Date.now() - timestamp) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d ago`; + if (days < 30) return `${Math.floor(days / 7)}w ago`; + return new Date(timestamp).toLocaleDateString(); +} + +function formatDate(timestamp: number | undefined): string { + if (!timestamp) return "unknown"; + return new Date(timestamp).toLocaleDateString(); +} + +function formatAccountTitle(account: AuthAccountViewModel): string { + const accountNumber = account.quickSwitchNumber ?? (account.index + 1); + const base = account.email ?? account.accountLabel ?? account.accountId ?? `Account ${accountNumber}`; + return `${accountNumber}. ${base}`; +} + +function formatAccountSubtitle(account: AuthAccountViewModel): string { + const status = account.status ?? "unknown"; + const parts = [ + `Added: ${formatDate(account.addedAt)}`, + `Used: ${formatRelativeTime(account.lastUsed)}`, + `Status: ${status}`, + ]; + if (account.quotaSummary) { + parts.push(`Limits: ${account.quotaSummary}`); + } + return parts.join(" | "); +} + +function InkChoiceApp(props: InkChoiceAppProps) { + const { exit } = useApp(); + const theme = createInkShellTheme(); + const [cursor, setCursor] = useState(0); + + const resolve = (value: InkChoiceValue | null) => { + props.onResolve(value); + exit(); + }; + + useInput((input, key) => { + if (key.upArrow) { + setCursor((current) => (current + props.items.length - 1) % props.items.length); + return; + } + if (key.downArrow) { + setCursor((current) => (current + 1) % props.items.length); + return; + } + + const lower = input.toLowerCase(); + if (key.escape || lower === "q") { + resolve(props.cancelValue ?? null); + return; + } + for (const item of props.items) { + if (item.hotkeys?.includes(lower)) { + resolve(item.value); + return; + } + } + if (key.return) { + resolve(props.items[cursor]?.value ?? props.cancelValue ?? null); + } + }); + + return createElement( + InkShellFrame, + { + title: props.title, + subtitle: props.subtitle, + status: props.status, + statusTone: props.statusTone, + footer: props.footer, + theme, + }, + createElement( + InkShellPanel, + { title: props.panelTitle ?? props.title, theme }, + createElement( + Box, + { flexDirection: "column" }, + ...props.items.map((item, index) => + createElement(InkShellRow, { + key: `${item.value}:${index}`, + label: item.label, + detail: item.detail, + active: index === cursor, + tone: item.tone, + theme, + }), + ), + ), + ), + ); +} + +async function promptInkChoice(options: PromptInkChoiceOptions): Promise { + const support = resolveInkAuthShellBootstrap(options); + if (!support.supported) return null; + + return await new Promise((resolve) => { + let settled = false; + const finish = (value: InkChoiceValue | null) => { + if (settled) return; + settled = true; + resolve(value); + }; + + const renderOptions: RenderOptions = { + stdin: options.stdin ?? process.stdin, + stdout: options.stdout ?? process.stdout, + stderr: options.stderr ?? process.stderr, + debug: options.debug ?? false, + patchConsole: options.patchConsole ?? false, + exitOnCtrlC: options.exitOnCtrlC ?? false, + }; + + render(createElement(InkChoiceApp, { ...options, onResolve: finish }), renderOptions); + }); +} + +interface PromptInkTextConfirmOptions extends InkAuthShellEnvironment { + title: string; + panelTitle?: string; + subtitle?: string; + status?: string; + statusTone?: InkShellTone; + prompt: string; + confirmText: string; + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + stderr?: NodeJS.WriteStream; + debug?: boolean; + patchConsole?: boolean; + exitOnCtrlC?: boolean; +} + +interface InkTextConfirmAppProps extends PromptInkTextConfirmOptions { + onResolve: (value: boolean | null) => void; +} + +function InkTextConfirmApp(props: InkTextConfirmAppProps) { + const { exit } = useApp(); + const theme = createInkShellTheme(); + const [value, setValue] = useState(""); + const valueRef = useRef(""); + const confirmText = props.confirmText.toUpperCase(); + const current = value.toUpperCase(); + const ready = current === confirmText; + + const resolve = (result: boolean | null) => { + props.onResolve(result); + exit(); + }; + + useInput((input, key) => { + const lower = input.toLowerCase(); + if (key.escape || lower === "q") { + resolve(false); + return; + } + if (key.backspace || key.delete) { + const nextValue = valueRef.current.slice(0, -1); + valueRef.current = nextValue; + setValue(nextValue); + return; + } + if (key.return) { + if (valueRef.current.toUpperCase() === confirmText) { + resolve(true); + } + return; + } + if (!input || key.ctrl || key.meta) return; + const nextLetters = input.toUpperCase().replace(/[^A-Z]/g, ""); + if (nextLetters.length === 0) return; + const nextValue = `${valueRef.current}${nextLetters}`.slice(0, confirmText.length); + valueRef.current = nextValue; + setValue(nextValue); + if (nextValue.toUpperCase() === confirmText) { + resolve(true); + } + }); + + return createElement( + InkShellFrame, + { + title: props.title, + subtitle: props.subtitle, + status: props.status ?? (ready ? "Confirmation ready" : `Type ${confirmText}`), + statusTone: props.statusTone ?? (ready ? "success" : "warning"), + footer: `Type ${confirmText} then Enter | Backspace delete | Q Back`, + theme, + }, + createElement( + InkShellPanel, + { title: props.panelTitle ?? props.title, theme }, + createElement(Text, { color: theme.textColor }, props.prompt), + createElement(Box, { marginTop: 1, flexDirection: "column" }, + createElement(Text, { color: theme.mutedColor }, `Required: ${confirmText}`), + createElement(Text, { color: ready ? theme.successColor : theme.headingColor, bold: true }, `Typed: ${current || "_"}`), + ), + ), + ); +} + +async function promptInkTextConfirm(options: PromptInkTextConfirmOptions): Promise { + const support = resolveInkAuthShellBootstrap(options); + if (!support.supported) return null; + + return await new Promise((resolve) => { + let settled = false; + const finish = (value: boolean | null) => { + if (settled) return; + settled = true; + resolve(value); + }; + + const renderOptions: RenderOptions = { + stdin: options.stdin ?? process.stdin, + stdout: options.stdout ?? process.stdout, + stderr: options.stderr ?? process.stderr, + debug: options.debug ?? false, + patchConsole: options.patchConsole ?? false, + exitOnCtrlC: options.exitOnCtrlC ?? false, + }; + + render(createElement(InkTextConfirmApp, { ...options, onResolve: finish }), renderOptions); + }); +} + +export type InkAccountDetailAction = "back" | "delete" | "refresh" | "toggle" | "set-current" | "cancel"; + +export interface PromptInkAccountDetailsOptions extends InkAuthShellEnvironment { + account: AuthAccountViewModel; + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + stderr?: NodeJS.WriteStream; + debug?: boolean; + patchConsole?: boolean; + exitOnCtrlC?: boolean; +} + +export async function promptInkAccountDetails( + options: PromptInkAccountDetailsOptions, +): Promise { + const account = options.account; + const action = await promptInkChoice({ + ...options, + title: formatAccountTitle(account), + panelTitle: "Account Details", + subtitle: formatAccountSubtitle(account), + footer: "Up/Down Move | Enter Select | S Use | R Sign In | E Toggle | D Delete | Q Back", + items: [ + { value: "back", label: "Back", tone: "muted", hotkeys: ["b"] }, + { + value: "toggle", + label: account.enabled === false ? "Enable Account" : "Disable Account", + detail: account.enabled === false ? "Bring this account back into rotation" : "Keep this account out of rotation until re-enabled", + tone: account.enabled === false ? "success" : "warning", + hotkeys: ["e", "t", "x"], + }, + { + value: "set-current", + label: "Set As Current", + detail: "Switch active selection to this saved account", + tone: "success", + hotkeys: ["s"], + }, + { + value: "refresh", + label: "Re-Login", + detail: "Refresh this account via the OAuth sign-in flow", + tone: "success", + hotkeys: ["r"], + }, + { + value: "delete", + label: "Delete Account", + detail: "Remove this saved account from the current pool", + tone: "danger", + hotkeys: ["d"], + }, + ], + cancelValue: "cancel", + }); + return (action as InkAccountDetailAction | null) ?? null; +} + +export interface PromptInkRestoreForLoginOptions extends InkAuthShellEnvironment { + reasonText: string; + snapshotInfo: string; + snapshotCount: number; + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + stderr?: NodeJS.WriteStream; + debug?: boolean; + patchConsole?: boolean; + exitOnCtrlC?: boolean; +} + +export async function promptInkRestoreForLogin( + options: PromptInkRestoreForLoginOptions, +): Promise { + const action = await promptInkChoice({ + ...options, + title: "Restore saved accounts before signing in?", + panelTitle: "Recovery Available", + subtitle: `${options.reasonText}\n${options.snapshotInfo}`, + status: "Backup snapshot ready", + statusTone: "warning", + footer: "Up/Down Move | Enter Select | R Restore | S Continue | Q Continue", + items: [ + { + value: "restore", + label: `Restore ${options.snapshotCount} saved account${options.snapshotCount === 1 ? "" : "s"}`, + detail: "Use the latest recovery snapshot before opening the dashboard", + tone: "success", + hotkeys: ["r"], + }, + { + value: "continue", + label: "Continue to sign in", + detail: "Skip restore and open the normal login flow", + tone: "warning", + hotkeys: ["s"], + }, + ], + cancelValue: "continue", + }); + if (action === null) return null; + return action === "restore"; +} + +export interface PromptInkAccountConfirmOptions extends InkAuthShellEnvironment { + account: AuthAccountViewModel; + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + stderr?: NodeJS.WriteStream; + debug?: boolean; + patchConsole?: boolean; + exitOnCtrlC?: boolean; +} + +export async function promptInkConfirmAccountRefresh( + options: PromptInkAccountConfirmOptions, +): Promise { + const action = await promptInkChoice({ + ...options, + title: "Confirm Re-Login", + panelTitle: "Re-Login Account", + subtitle: `Re-authenticate ${formatAccountTitle(options.account)}?\n${formatAccountSubtitle(options.account)}`, + status: "OAuth will open next", + statusTone: "accent", + footer: "Up/Down Move | Enter Select | R Confirm | Q Back", + items: [ + { + value: "refresh", + label: "Re-Login Now", + detail: "Start a fresh OAuth sign-in for this account", + tone: "success", + hotkeys: ["r"], + }, + { + value: "back", + label: "Go Back", + detail: "Return to account details without changing anything", + tone: "muted", + hotkeys: ["b"], + }, + ], + cancelValue: "back", + }); + if (action === null) return null; + return action === "refresh"; +} + +export async function promptInkConfirmAccountDelete( + options: PromptInkAccountConfirmOptions, +): Promise { + return await promptInkTextConfirm({ + ...options, + title: "Confirm Account Deletion", + panelTitle: "Delete Account", + subtitle: `${formatAccountTitle(options.account)}\n${formatAccountSubtitle(options.account)}`, + status: "Destructive action", + statusTone: "danger", + prompt: "Type DELETE to remove this saved account from the active pool.", + confirmText: "DELETE", + }); +} + +export async function promptInkConfirmDeleteAll( + options: InkAuthShellEnvironment & { + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + stderr?: NodeJS.WriteStream; + debug?: boolean; + patchConsole?: boolean; + exitOnCtrlC?: boolean; + }, +): Promise { + return await promptInkTextConfirm({ + ...options, + title: "Confirm Delete All Accounts", + panelTitle: "Delete All Accounts", + subtitle: "This clears the current saved account pool. Recovery snapshots remain separate.", + status: "Destructive action", + statusTone: "danger", + prompt: "Type DELETE to remove all saved accounts from the active pool.", + confirmText: "DELETE", + }); +} diff --git a/lib/ui-ink/index.ts b/lib/ui-ink/index.ts new file mode 100644 index 00000000..15ddc7c3 --- /dev/null +++ b/lib/ui-ink/index.ts @@ -0,0 +1,6 @@ +export * from "./layout.js"; +export * from "./auth-shell.js"; +export * from "./bootstrap.js"; +export * from "./dashboard.js"; +export * from "./detail-flows.js"; +export * from "./settings.js"; diff --git a/lib/ui-ink/layout.ts b/lib/ui-ink/layout.ts new file mode 100644 index 00000000..81485918 --- /dev/null +++ b/lib/ui-ink/layout.ts @@ -0,0 +1,202 @@ +import { createElement, type ReactNode } from "react"; +import { Box, Text } from "ink"; +import { getUiRuntimeOptions } from "../ui/runtime.js"; + +export type InkShellTone = "normal" | "success" | "warning" | "danger" | "accent" | "muted"; + +export interface InkShellTheme { + borderColor: string; + sectionBorderColor: string; + headingColor: string; + textColor: string; + mutedColor: string; + accentColor: string; + successColor: string; + warningColor: string; + dangerColor: string; + focusTextColor: string; + focusBackgroundColor: string; +} + +const GREEN_THEME: InkShellTheme = { + borderColor: "#22c55e", + sectionBorderColor: "#15803d", + headingColor: "#f0fdf4", + textColor: "#dcfce7", + mutedColor: "#94a3b8", + accentColor: "#4ade80", + successColor: "#4ade80", + warningColor: "#f59e0b", + dangerColor: "#ef4444", + focusTextColor: "#f8fafc", + focusBackgroundColor: "#166534", +}; + +const BLUE_THEME: InkShellTheme = { + borderColor: "#3b82f6", + sectionBorderColor: "#2563eb", + headingColor: "#eff6ff", + textColor: "#dbeafe", + mutedColor: "#94a3b8", + accentColor: "#22d3ee", + successColor: "#60a5fa", + warningColor: "#f59e0b", + dangerColor: "#ef4444", + focusTextColor: "#f8fafc", + focusBackgroundColor: "#1d4ed8", +}; + +export function createInkShellTheme(): InkShellTheme { + const ui = getUiRuntimeOptions(); + return ui.palette === "blue" ? BLUE_THEME : GREEN_THEME; +} + +function colorForTone(theme: InkShellTheme, tone: InkShellTone): string { + switch (tone) { + case "success": + return theme.successColor; + case "warning": + return theme.warningColor; + case "danger": + return theme.dangerColor; + case "accent": + return theme.accentColor; + case "muted": + return theme.mutedColor; + default: + return theme.textColor; + } +} + +export interface InkShellFrameProps { + title: string; + subtitle?: string; + status?: string; + statusTone?: InkShellTone; + footer?: string; + theme: InkShellTheme; + children?: ReactNode; +} + +export function InkShellFrame(props: InkShellFrameProps) { + return createElement( + Box, + { + flexDirection: "column", + borderStyle: "round", + borderColor: props.theme.borderColor, + paddingX: 1, + paddingY: 0, + }, + createElement( + Box, + { justifyContent: "space-between" }, + createElement(Text, { color: props.theme.headingColor, bold: true }, props.title), + props.status + ? createElement(Text, { color: colorForTone(props.theme, props.statusTone ?? "accent"), bold: true }, props.status) + : null, + ), + props.subtitle + ? createElement(Text, { color: props.theme.mutedColor }, props.subtitle) + : null, + createElement(Box, { marginTop: 1, flexDirection: "column" }, props.children), + props.footer + ? createElement( + Box, + { marginTop: 1 }, + createElement(Text, { color: props.theme.mutedColor }, props.footer), + ) + : null, + ); +} + +export interface InkShellSectionTabProps { + label: string; + active?: boolean; + tone?: InkShellTone; + theme: InkShellTheme; +} + +export function InkShellSectionTab(props: InkShellSectionTabProps) { + if (props.active) { + return createElement( + Text, + { + backgroundColor: props.theme.focusBackgroundColor, + color: props.theme.focusTextColor, + bold: true, + }, + ` ${props.label} `, + ); + } + + return createElement( + Text, + { color: colorForTone(props.theme, props.tone ?? "muted") }, + ` ${props.label} `, + ); +} + +export interface InkShellRowProps { + label: string; + detail?: string; + active?: boolean; + tone?: InkShellTone; + theme: InkShellTheme; +} + +export function InkShellRow(props: InkShellRowProps) { + const color = colorForTone(props.theme, props.tone ?? "normal"); + if (props.active) { + return createElement( + Box, + { flexDirection: "column", marginBottom: 1 }, + createElement( + Text, + { + backgroundColor: props.theme.focusBackgroundColor, + color: props.theme.focusTextColor, + bold: true, + }, + ` ${props.label} `, + ), + props.detail + ? createElement( + Text, + { color: props.theme.focusTextColor }, + ` ${props.detail}`, + ) + : null, + ); + } + + return createElement( + Box, + { flexDirection: "column", marginBottom: 1 }, + createElement(Text, { color }, props.label), + props.detail + ? createElement(Text, { color: props.theme.mutedColor }, props.detail) + : null, + ); +} + +export interface InkShellPanelProps { + title: string; + theme: InkShellTheme; + children?: ReactNode; +} + +export function InkShellPanel(props: InkShellPanelProps) { + return createElement( + Box, + { + flexDirection: "column", + borderStyle: "round", + borderColor: props.theme.sectionBorderColor, + paddingX: 1, + paddingY: 0, + }, + createElement(Text, { color: props.theme.headingColor, bold: true }, props.title), + createElement(Box, { flexDirection: "column", marginTop: 1 }, props.children), + ); +} diff --git a/lib/ui-ink/settings.ts b/lib/ui-ink/settings.ts new file mode 100644 index 00000000..02b021fd --- /dev/null +++ b/lib/ui-ink/settings.ts @@ -0,0 +1,1773 @@ +import { createElement, useMemo, useState } from "react"; +import { Box, Text, render, useApp, useInput, type Key, type RenderOptions } from "ink"; +import { + loadDashboardDisplaySettings, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + type DashboardAccentColor, + type DashboardAccountSortMode, + type DashboardDisplaySettings, + type DashboardLayoutMode, + type DashboardStatuslineField, + type DashboardThemePreset, +} from "../dashboard-settings.js"; +import { getDefaultPluginConfig, loadPluginConfig } from "../config.js"; +import type { PluginConfig } from "../types.js"; +import { UI_COPY } from "../ui/copy.js"; +import { + applyUiThemeFromDashboardSettings, + resolveMenuLayoutMode, +} from "../codex-manager/settings-hub.js"; +import { + persistBackendConfigSelection, + persistDashboardSettingsSelection, +} from "../codex-manager/settings-persistence.js"; +import { + InkShellFrame, + InkShellPanel, + InkShellRow, + createInkShellTheme, + type InkShellTone, +} from "./layout.js"; +import { + resolveInkAuthShellBootstrap, + type InkAuthShellEnvironment, +} from "./bootstrap.js"; + +type DashboardDisplaySettingKey = + | "menuShowStatusBadge" + | "menuShowCurrentBadge" + | "menuShowLastUsed" + | "menuShowQuotaSummary" + | "menuShowQuotaCooldown" + | "menuShowDetailsForUnselectedRows" + | "menuShowFetchStatus" + | "menuHighlightCurrentRow" + | "menuSortEnabled" + | "menuSortPinCurrent" + | "menuSortQuickSwitchVisibleRow"; + +interface DashboardDisplaySettingOption { + key: DashboardDisplaySettingKey; + label: string; + description: string; +} + +const DASHBOARD_DISPLAY_OPTIONS: DashboardDisplaySettingOption[] = [ + { + key: "menuShowStatusBadge", + label: "Show Status Badges", + description: "Show [ok], [active], and similar badges.", + }, + { + key: "menuShowCurrentBadge", + label: "Show [current]", + description: "Mark the account active in Codex.", + }, + { + key: "menuShowLastUsed", + label: "Show Last Used", + description: "Show relative usage like 'today'.", + }, + { + key: "menuShowQuotaSummary", + label: "Show Limits (5h / 7d)", + description: "Show limit bars in each row.", + }, + { + key: "menuShowQuotaCooldown", + label: "Show Limit Cooldowns", + description: "Show reset timers next to 5h/7d bars.", + }, + { + key: "menuShowFetchStatus", + label: "Show Fetch Status", + description: "Show background limit refresh status in the menu subtitle.", + }, + { + key: "menuHighlightCurrentRow", + label: "Highlight Current Row", + description: "Use stronger color on the current row.", + }, + { + key: "menuSortEnabled", + label: "Enable Smart Sort", + description: "Sort accounts by readiness (view only).", + }, + { + key: "menuSortPinCurrent", + label: "Pin [current] when tied", + description: "Keep current at top only when it is equally ready.", + }, + { + key: "menuSortQuickSwitchVisibleRow", + label: "Quick Switch Uses Visible Rows", + description: "Number keys (1-9) follow what you see in the list.", + }, +]; + +const DEFAULT_STATUSLINE_FIELDS: DashboardStatuslineField[] = ["last-used", "limits", "status"]; +const STATUSLINE_FIELD_OPTIONS: Array<{ + key: DashboardStatuslineField; + label: string; + description: string; +}> = [ + { + key: "last-used", + label: "Show Last Used", + description: "Example: 'today' or '2d ago'.", + }, + { + key: "limits", + label: "Show Limits (5h / 7d)", + description: "Uses cached limit data from checks.", + }, + { + key: "status", + label: "Show Status Text", + description: "Visible when badges are hidden.", + }, +]; + +const AUTO_RETURN_OPTIONS_MS = [1_000, 2_000, 4_000] as const; +const MENU_QUOTA_TTL_OPTIONS_MS = [60_000, 5 * 60_000, 10 * 60_000] as const; +const THEME_PRESET_OPTIONS: DashboardThemePreset[] = ["green", "blue"]; +const ACCENT_COLOR_OPTIONS: DashboardAccentColor[] = ["green", "cyan", "blue", "yellow"]; + +type BackendToggleSettingKey = + | "liveAccountSync" + | "sessionAffinity" + | "proactiveRefreshGuardian" + | "retryAllAccountsRateLimited" + | "parallelProbing" + | "storageBackupEnabled" + | "preemptiveQuotaEnabled" + | "fastSession" + | "sessionRecovery" + | "autoResume" + | "perProjectAccounts"; + +type BackendNumberSettingKey = + | "liveAccountSyncDebounceMs" + | "liveAccountSyncPollMs" + | "sessionAffinityTtlMs" + | "sessionAffinityMaxEntries" + | "proactiveRefreshIntervalMs" + | "proactiveRefreshBufferMs" + | "parallelProbingMaxConcurrency" + | "fastSessionMaxInputItems" + | "networkErrorCooldownMs" + | "serverErrorCooldownMs" + | "fetchTimeoutMs" + | "streamStallTimeoutMs" + | "tokenRefreshSkewMs" + | "preemptiveQuotaRemainingPercent5h" + | "preemptiveQuotaRemainingPercent7d" + | "preemptiveQuotaMaxDeferralMs"; + +interface BackendToggleSettingOption { + key: BackendToggleSettingKey; + label: string; + description: string; +} + +interface BackendNumberSettingOption { + key: BackendNumberSettingKey; + label: string; + description: string; + min: number; + max: number; + step: number; + unit: "ms" | "percent" | "count"; +} + +type BackendCategoryKey = + | "session-sync" + | "rotation-quota" + | "refresh-recovery" + | "performance-timeouts"; + +interface BackendCategoryOption { + key: BackendCategoryKey; + label: string; + description: string; + toggleKeys: BackendToggleSettingKey[]; + numberKeys: BackendNumberSettingKey[]; +} + +type SettingsHubActionType = "account-list" | "summary-fields" | "behavior" | "theme" | "backend" | "back"; + +const BACKEND_TOGGLE_OPTIONS: BackendToggleSettingOption[] = [ + { + key: "liveAccountSync", + label: "Enable Live Sync", + description: "Keep accounts synced when files change in another window.", + }, + { + key: "sessionAffinity", + label: "Enable Session Affinity", + description: "Try to keep each conversation on the same account.", + }, + { + key: "proactiveRefreshGuardian", + label: "Enable Token Refresh Guard", + description: "Refresh tokens early in the background.", + }, + { + key: "retryAllAccountsRateLimited", + label: "Retry When All Rate-Limited", + description: "If all accounts are limited, wait and try again.", + }, + { + key: "parallelProbing", + label: "Enable Parallel Probing", + description: "Check multiple accounts at the same time.", + }, + { + key: "storageBackupEnabled", + label: "Enable Storage Backups", + description: "Create a backup before account data changes.", + }, + { + key: "preemptiveQuotaEnabled", + label: "Enable Quota Deferral", + description: "Delay requests before limits are fully exhausted.", + }, + { + key: "fastSession", + label: "Enable Fast Session Mode", + description: "Use lighter request handling for faster responses.", + }, + { + key: "sessionRecovery", + label: "Enable Session Recovery", + description: "Restore recoverable sessions after restart.", + }, + { + key: "autoResume", + label: "Enable Auto Resume", + description: "Automatically continue sessions when possible.", + }, + { + key: "perProjectAccounts", + label: "Enable Per-Project Accounts", + description: "Keep separate account lists for each project.", + }, +]; + +const BACKEND_NUMBER_OPTIONS: BackendNumberSettingOption[] = [ + { + key: "liveAccountSyncDebounceMs", + label: "Live Sync Debounce", + description: "Wait this long before applying sync file changes.", + min: 50, + max: 10_000, + step: 50, + unit: "ms", + }, + { + key: "liveAccountSyncPollMs", + label: "Live Sync Poll", + description: "How often to check files for account updates.", + min: 500, + max: 60_000, + step: 500, + unit: "ms", + }, + { + key: "sessionAffinityTtlMs", + label: "Session Affinity TTL", + description: "How long conversation-to-account mapping is kept.", + min: 1_000, + max: 24 * 60 * 60_000, + step: 60_000, + unit: "ms", + }, + { + key: "sessionAffinityMaxEntries", + label: "Session Affinity Max Entries", + description: "Maximum stored conversation mappings.", + min: 8, + max: 4_096, + step: 32, + unit: "count", + }, + { + key: "proactiveRefreshIntervalMs", + label: "Refresh Guard Interval", + description: "How often to scan for tokens near expiry.", + min: 5_000, + max: 10 * 60_000, + step: 5_000, + unit: "ms", + }, + { + key: "proactiveRefreshBufferMs", + label: "Refresh Guard Buffer", + description: "How early to refresh before expiry.", + min: 30_000, + max: 10 * 60_000, + step: 30_000, + unit: "ms", + }, + { + key: "parallelProbingMaxConcurrency", + label: "Parallel Probe Concurrency", + description: "Maximum checks running at once.", + min: 1, + max: 5, + step: 1, + unit: "count", + }, + { + key: "fastSessionMaxInputItems", + label: "Fast Session Max Inputs", + description: "Max number of input items kept in fast mode.", + min: 8, + max: 200, + step: 2, + unit: "count", + }, + { + key: "networkErrorCooldownMs", + label: "Network Error Cooldown", + description: "Wait time after network errors before retry.", + min: 0, + max: 120_000, + step: 500, + unit: "ms", + }, + { + key: "serverErrorCooldownMs", + label: "Server Error Cooldown", + description: "Wait time after server errors before retry.", + min: 0, + max: 120_000, + step: 500, + unit: "ms", + }, + { + key: "fetchTimeoutMs", + label: "Request Timeout", + description: "Max time to wait for a request.", + min: 1_000, + max: 10 * 60_000, + step: 5_000, + unit: "ms", + }, + { + key: "streamStallTimeoutMs", + label: "Stream Stall Timeout", + description: "Max wait before a stuck stream is retried.", + min: 1_000, + max: 10 * 60_000, + step: 5_000, + unit: "ms", + }, + { + key: "tokenRefreshSkewMs", + label: "Token Refresh Buffer", + description: "Refresh this long before token expiry.", + min: 0, + max: 10 * 60_000, + step: 10_000, + unit: "ms", + }, + { + key: "preemptiveQuotaRemainingPercent5h", + label: "5h Remaining Threshold", + description: "Start delaying when 5h remaining reaches this percent.", + min: 0, + max: 100, + step: 1, + unit: "percent", + }, + { + key: "preemptiveQuotaRemainingPercent7d", + label: "7d Remaining Threshold", + description: "Start delaying when weekly remaining reaches this percent.", + min: 0, + max: 100, + step: 1, + unit: "percent", + }, + { + key: "preemptiveQuotaMaxDeferralMs", + label: "Max Preemptive Deferral", + description: "Maximum time allowed for quota-based delay.", + min: 1_000, + max: 24 * 60 * 60_000, + step: 60_000, + unit: "ms", + }, +]; + +const BACKEND_DEFAULTS = getDefaultPluginConfig(); + +const BACKEND_TOGGLE_OPTION_BY_KEY = new Map( + BACKEND_TOGGLE_OPTIONS.map((option) => [option.key, option]), +); + +const BACKEND_NUMBER_OPTION_BY_KEY = new Map( + BACKEND_NUMBER_OPTIONS.map((option) => [option.key, option]), +); + +const BACKEND_CATEGORY_OPTIONS: BackendCategoryOption[] = [ + { + key: "session-sync", + label: "Session & Sync", + description: "Sync and session behavior.", + toggleKeys: [ + "liveAccountSync", + "sessionAffinity", + "perProjectAccounts", + "sessionRecovery", + "autoResume", + ], + numberKeys: [ + "liveAccountSyncDebounceMs", + "liveAccountSyncPollMs", + "sessionAffinityTtlMs", + "sessionAffinityMaxEntries", + ], + }, + { + key: "rotation-quota", + label: "Rotation & Quota", + description: "Quota and retry behavior.", + toggleKeys: ["preemptiveQuotaEnabled", "retryAllAccountsRateLimited"], + numberKeys: [ + "preemptiveQuotaRemainingPercent5h", + "preemptiveQuotaRemainingPercent7d", + "preemptiveQuotaMaxDeferralMs", + ], + }, + { + key: "refresh-recovery", + label: "Refresh & Recovery", + description: "Token refresh and recovery safety.", + toggleKeys: ["proactiveRefreshGuardian", "storageBackupEnabled"], + numberKeys: [ + "proactiveRefreshIntervalMs", + "proactiveRefreshBufferMs", + "tokenRefreshSkewMs", + ], + }, + { + key: "performance-timeouts", + label: "Performance & Timeouts", + description: "Speed, probing, and timeout controls.", + toggleKeys: ["fastSession", "parallelProbing"], + numberKeys: [ + "fastSessionMaxInputItems", + "parallelProbingMaxConcurrency", + "fetchTimeoutMs", + "streamStallTimeoutMs", + "networkErrorCooldownMs", + "serverErrorCooldownMs", + ], + }, +]; + +type DashboardSettingKey = keyof DashboardDisplaySettings; + +const ACCOUNT_LIST_PANEL_KEYS = [ + "menuShowStatusBadge", + "menuShowCurrentBadge", + "menuShowLastUsed", + "menuShowQuotaSummary", + "menuShowQuotaCooldown", + "menuShowFetchStatus", + "menuShowDetailsForUnselectedRows", + "menuHighlightCurrentRow", + "menuSortEnabled", + "menuSortMode", + "menuSortPinCurrent", + "menuSortQuickSwitchVisibleRow", + "menuLayoutMode", +] as const satisfies readonly DashboardSettingKey[]; + +const STATUSLINE_PANEL_KEYS = ["menuStatuslineFields"] as const satisfies readonly DashboardSettingKey[]; +const BEHAVIOR_PANEL_KEYS = [ + "actionAutoReturnMs", + "actionPauseOnKey", + "menuAutoFetchLimits", + "menuShowFetchStatus", + "menuQuotaTtlMs", +] as const satisfies readonly DashboardSettingKey[]; +const THEME_PANEL_KEYS = ["uiThemePreset", "uiAccentColor"] as const satisfies readonly DashboardSettingKey[]; + +const PREVIEW_ACCOUNT_EMAIL = "demo@example.com"; +const PREVIEW_LAST_USED = "today"; +const PREVIEW_STATUS = "active"; +const PREVIEW_LIMITS = "5h ██████▒▒▒▒ 62% | 7d █████▒▒▒▒▒ 49%"; +const PREVIEW_LIMIT_COOLDOWNS = "5h reset 1h 20m | 7d reset 2d 04h"; + +export interface InkSettingsEnvironment extends InkAuthShellEnvironment { + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + stderr?: NodeJS.WriteStream; + debug?: boolean; + patchConsole?: boolean; + exitOnCtrlC?: boolean; +} + +interface InkSettingsEntry { + id: string; + label: string; + detail?: string; + tone: InkShellTone; +} + +function makeEntry(id: string, label: string, tone: InkShellTone, detail?: string): InkSettingsEntry { + return detail ? { id, label, detail, tone } : { id, label, tone }; +} + +interface InkSettingsView { + panelTitle: string; + entries: InkSettingsEntry[]; + footer: string; + previewTitle?: string; + previewLines?: string[]; + status?: string; + statusTone?: InkShellTone; +} + +type InkSettingsTransition = + | { type: "update"; state: TState; cursor?: number } + | { type: "resolve"; value: TResult }; + +interface InkSettingsAppProps extends InkSettingsEnvironment { + title: string; + subtitle?: string; + initialState: TState; + initialCursor?: number; + renderView: (state: TState, cursor: number) => InkSettingsView; + onInput: (params: { + state: TState; + cursor: number; + entry: InkSettingsEntry | undefined; + input: string; + key: Key; + }) => InkSettingsTransition | null | undefined; + onResolve: (value: TResult) => void; +} + +function clampCursor(cursor: number, count: number): number { + if (count <= 0) return 0; + return Math.max(0, Math.min(cursor, count - 1)); +} + +function normalizeStatuslineFields( + fields: DashboardStatuslineField[] | undefined, +): DashboardStatuslineField[] { + const source = fields ?? DEFAULT_STATUSLINE_FIELDS; + const seen = new Set(); + const normalized: DashboardStatuslineField[] = []; + for (const field of source) { + if (seen.has(field)) continue; + seen.add(field); + normalized.push(field); + } + if (normalized.length === 0) { + return [...DEFAULT_STATUSLINE_FIELDS]; + } + return normalized; +} + +function copyDashboardSettingValue( + target: DashboardDisplaySettings, + source: DashboardDisplaySettings, + key: DashboardSettingKey, +): void { + const value = source[key]; + (target as unknown as Record)[key] = Array.isArray(value) ? [...value] : value; +} + +function cloneDashboardSettings(settings: DashboardDisplaySettings): DashboardDisplaySettings { + const layoutMode = resolveMenuLayoutMode(settings); + return { + showPerAccountRows: settings.showPerAccountRows, + showQuotaDetails: settings.showQuotaDetails, + showForecastReasons: settings.showForecastReasons, + showRecommendations: settings.showRecommendations, + showLiveProbeNotes: settings.showLiveProbeNotes, + actionAutoReturnMs: settings.actionAutoReturnMs ?? 2_000, + actionPauseOnKey: settings.actionPauseOnKey ?? true, + menuAutoFetchLimits: settings.menuAutoFetchLimits ?? true, + menuSortEnabled: + settings.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true), + menuSortMode: + settings.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"), + menuSortPinCurrent: + settings.menuSortPinCurrent ?? + (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false), + menuSortQuickSwitchVisibleRow: settings.menuSortQuickSwitchVisibleRow ?? true, + uiThemePreset: settings.uiThemePreset ?? "green", + uiAccentColor: settings.uiAccentColor ?? "green", + menuShowStatusBadge: settings.menuShowStatusBadge ?? true, + menuShowCurrentBadge: settings.menuShowCurrentBadge ?? true, + menuShowLastUsed: settings.menuShowLastUsed ?? true, + menuShowQuotaSummary: settings.menuShowQuotaSummary ?? true, + menuShowQuotaCooldown: settings.menuShowQuotaCooldown ?? true, + menuShowFetchStatus: settings.menuShowFetchStatus ?? true, + menuShowDetailsForUnselectedRows: layoutMode === "expanded-rows", + menuLayoutMode: layoutMode, + menuQuotaTtlMs: settings.menuQuotaTtlMs ?? 5 * 60_000, + menuFocusStyle: settings.menuFocusStyle ?? "row-invert", + menuHighlightCurrentRow: settings.menuHighlightCurrentRow ?? true, + menuStatuslineFields: [...normalizeStatuslineFields(settings.menuStatuslineFields)], + }; +} + +function dashboardSettingsEqual( + left: DashboardDisplaySettings, + right: DashboardDisplaySettings, +): boolean { + return ( + left.showPerAccountRows === right.showPerAccountRows && + left.showQuotaDetails === right.showQuotaDetails && + left.showForecastReasons === right.showForecastReasons && + left.showRecommendations === right.showRecommendations && + left.showLiveProbeNotes === right.showLiveProbeNotes && + (left.actionAutoReturnMs ?? 2_000) === (right.actionAutoReturnMs ?? 2_000) && + (left.actionPauseOnKey ?? true) === (right.actionPauseOnKey ?? true) && + (left.menuAutoFetchLimits ?? true) === (right.menuAutoFetchLimits ?? true) && + (left.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true)) === + (right.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true)) && + (left.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first")) === + (right.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first")) && + (left.menuSortPinCurrent ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false)) === + (right.menuSortPinCurrent ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false)) && + (left.menuSortQuickSwitchVisibleRow ?? true) === + (right.menuSortQuickSwitchVisibleRow ?? true) && + (left.uiThemePreset ?? "green") === (right.uiThemePreset ?? "green") && + (left.uiAccentColor ?? "green") === (right.uiAccentColor ?? "green") && + (left.menuShowStatusBadge ?? true) === (right.menuShowStatusBadge ?? true) && + (left.menuShowCurrentBadge ?? true) === (right.menuShowCurrentBadge ?? true) && + (left.menuShowLastUsed ?? true) === (right.menuShowLastUsed ?? true) && + (left.menuShowQuotaSummary ?? true) === (right.menuShowQuotaSummary ?? true) && + (left.menuShowQuotaCooldown ?? true) === (right.menuShowQuotaCooldown ?? true) && + (left.menuShowFetchStatus ?? true) === (right.menuShowFetchStatus ?? true) && + resolveMenuLayoutMode(left) === resolveMenuLayoutMode(right) && + (left.menuQuotaTtlMs ?? 5 * 60_000) === (right.menuQuotaTtlMs ?? 5 * 60_000) && + (left.menuFocusStyle ?? "row-invert") === (right.menuFocusStyle ?? "row-invert") && + (left.menuHighlightCurrentRow ?? true) === (right.menuHighlightCurrentRow ?? true) && + JSON.stringify(normalizeStatuslineFields(left.menuStatuslineFields)) === + JSON.stringify(normalizeStatuslineFields(right.menuStatuslineFields)) + ); +} + +function applyDashboardDefaultsForKeys( + draft: DashboardDisplaySettings, + keys: readonly DashboardSettingKey[], +): DashboardDisplaySettings { + const next = cloneDashboardSettings(draft); + const defaults = cloneDashboardSettings(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); + for (const key of keys) { + copyDashboardSettingValue(next, defaults, key); + } + return next; +} + +function mergeDashboardSettingsForKeys( + base: DashboardDisplaySettings, + selected: DashboardDisplaySettings, + keys: readonly DashboardSettingKey[], +): DashboardDisplaySettings { + const next = cloneDashboardSettings(base); + for (const key of keys) { + copyDashboardSettingValue(next, selected, key); + } + return cloneDashboardSettings(next); +} + +function cloneBackendPluginConfig(config: PluginConfig): PluginConfig { + const fallbackChain = config.unsupportedCodexFallbackChain; + return { + ...BACKEND_DEFAULTS, + ...config, + unsupportedCodexFallbackChain: + fallbackChain && typeof fallbackChain === "object" + ? { ...fallbackChain } + : {}, + }; +} + +function backendSettingsSnapshot(config: PluginConfig): Record { + const snapshot: Record = {}; + for (const option of BACKEND_TOGGLE_OPTIONS) { + snapshot[option.key] = config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false; + } + for (const option of BACKEND_NUMBER_OPTIONS) { + snapshot[option.key] = config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? option.min; + } + return snapshot; +} + +function backendSettingsEqual(left: PluginConfig, right: PluginConfig): boolean { + return JSON.stringify(backendSettingsSnapshot(left)) === JSON.stringify(backendSettingsSnapshot(right)); +} + +function formatBackendNumberValue(option: BackendNumberSettingOption, value: number): string { + if (option.unit === "percent") return `${Math.round(value)}%`; + if (option.unit === "count") return `${Math.round(value)}`; + if (value >= 60_000 && value % 60_000 === 0) { + return `${Math.round(value / 60_000)}m`; + } + if (value >= 1_000 && value % 1_000 === 0) { + return `${Math.round(value / 1_000)}s`; + } + return `${Math.round(value)}ms`; +} + +function clampBackendNumber(option: BackendNumberSettingOption, value: number): number { + return Math.max(option.min, Math.min(option.max, Math.round(value))); +} + +function buildBackendConfigPatch(config: PluginConfig): Partial { + const patch: Partial = {}; + for (const option of BACKEND_TOGGLE_OPTIONS) { + const value = config[option.key]; + if (typeof value === "boolean") { + patch[option.key] = value; + } + } + for (const option of BACKEND_NUMBER_OPTIONS) { + const value = config[option.key]; + if (typeof value === "number" && Number.isFinite(value)) { + patch[option.key] = clampBackendNumber(option, value); + } + } + return patch; +} + +function reorderField( + fields: DashboardStatuslineField[], + key: DashboardStatuslineField, + direction: -1 | 1, +): DashboardStatuslineField[] { + const index = fields.indexOf(key); + if (index < 0) return fields; + const target = index + direction; + if (target < 0 || target >= fields.length) return fields; + const next = [...fields]; + const current = next[index]; + const swap = next[target]; + if (!current || !swap) return fields; + next[index] = swap; + next[target] = current; + return next; +} + +function formatDashboardSettingState(value: boolean): string { + return value ? "[x]" : "[ ]"; +} + +function formatMenuSortMode(mode: DashboardAccountSortMode): string { + return mode === "ready-first" ? "Ready-First" : "Manual"; +} + +function formatMenuLayoutMode(mode: "compact-details" | "expanded-rows"): string { + return mode === "expanded-rows" ? "Expanded Rows" : "Compact + Details Pane"; +} + +function formatMenuQuotaTtl(ttlMs: number): string { + if (ttlMs >= 60_000 && ttlMs % 60_000 === 0) { + return `${Math.round(ttlMs / 60_000)}m`; + } + if (ttlMs >= 1_000 && ttlMs % 1_000 === 0) { + return `${Math.round(ttlMs / 1_000)}s`; + } + return `${ttlMs}ms`; +} + +function buildSummaryPreviewText(settings: DashboardDisplaySettings): string { + const partsByField = new Map(); + if (settings.menuShowLastUsed !== false) { + partsByField.set("last-used", `last used: ${PREVIEW_LAST_USED}`); + } + if (settings.menuShowQuotaSummary !== false) { + const limitsText = + settings.menuShowQuotaCooldown === false + ? PREVIEW_LIMITS + : `${PREVIEW_LIMITS} | ${PREVIEW_LIMIT_COOLDOWNS}`; + partsByField.set("limits", `limits: ${limitsText}`); + } + if (settings.menuShowStatusBadge === false) { + partsByField.set("status", `status: ${PREVIEW_STATUS}`); + } + const orderedParts = normalizeStatuslineFields(settings.menuStatuslineFields) + .map((field) => partsByField.get(field)) + .filter((part): part is string => typeof part === "string" && part.length > 0); + if (orderedParts.length > 0) { + return orderedParts.join(" | "); + } + const showsStatusField = normalizeStatuslineFields(settings.menuStatuslineFields).includes("status"); + if (showsStatusField && settings.menuShowStatusBadge !== false) { + return "status text appears only when status badges are hidden"; + } + return "no summary text is visible with current account-list settings"; +} + +function buildAccountListPreview(settings: DashboardDisplaySettings): { label: string; hint: string } { + const badges: string[] = []; + if (settings.menuShowCurrentBadge !== false) { + badges.push("[current]"); + } + if (settings.menuShowStatusBadge !== false) { + badges.push("[active]"); + } + const badgeSuffix = badges.length > 0 ? ` ${badges.join(" ")}` : ""; + const rowDetailMode = + resolveMenuLayoutMode(settings) === "expanded-rows" + ? "details shown on all rows" + : "details shown on selected row only"; + return { + label: `1. ${PREVIEW_ACCOUNT_EMAIL}${badgeSuffix}`, + hint: `${buildSummaryPreviewText(settings)}\n${rowDetailMode}`, + }; +} + +function buildBackendSettingsPreview(config: PluginConfig): { label: string; hint: string } { + const liveSync = config.liveAccountSync ?? BACKEND_DEFAULTS.liveAccountSync ?? true; + const affinity = config.sessionAffinity ?? BACKEND_DEFAULTS.sessionAffinity ?? true; + const preemptive = config.preemptiveQuotaEnabled ?? BACKEND_DEFAULTS.preemptiveQuotaEnabled ?? true; + const threshold5h = + config.preemptiveQuotaRemainingPercent5h ?? + BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent5h ?? + 5; + const threshold7d = + config.preemptiveQuotaRemainingPercent7d ?? + BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent7d ?? + 5; + const fetchTimeout = config.fetchTimeoutMs ?? BACKEND_DEFAULTS.fetchTimeoutMs ?? 60_000; + const stallTimeout = config.streamStallTimeoutMs ?? BACKEND_DEFAULTS.streamStallTimeoutMs ?? 45_000; + const fetchTimeoutOption = BACKEND_NUMBER_OPTION_BY_KEY.get("fetchTimeoutMs"); + const stallTimeoutOption = BACKEND_NUMBER_OPTION_BY_KEY.get("streamStallTimeoutMs"); + const fetchTimeoutLabel = fetchTimeoutOption + ? formatBackendNumberValue(fetchTimeoutOption, fetchTimeout) + : `${fetchTimeout}ms`; + const stallTimeoutLabel = stallTimeoutOption + ? formatBackendNumberValue(stallTimeoutOption, stallTimeout) + : `${stallTimeout}ms`; + return { + label: [ + `live sync ${liveSync ? "on" : "off"}`, + `affinity ${affinity ? "on" : "off"}`, + `preemptive ${preemptive ? "on" : "off"}`, + ].join(" | "), + hint: [ + `thresholds 5h<=${threshold5h}%`, + `7d<=${threshold7d}%`, + `timeouts ${fetchTimeoutLabel}/${stallTimeoutLabel}`, + ].join(" | "), + }; +} + +function entryTone(enabled: boolean): InkShellTone { + return enabled ? "success" : "warning"; +} + +function InkSettingsApp(props: InkSettingsAppProps) { + const { exit } = useApp(); + const theme = createInkShellTheme(); + const [state, setState] = useState(props.initialState); + const [cursor, setCursor] = useState(props.initialCursor ?? 0); + const view = useMemo(() => props.renderView(state, cursor), [props, state, cursor]); + const normalizedCursor = clampCursor(cursor, view.entries.length); + + useInput((input, key) => { + if (view.entries.length > 0 && key.upArrow) { + setCursor((current) => clampCursor(current - 1, view.entries.length)); + return; + } + if (view.entries.length > 0 && key.downArrow) { + setCursor((current) => clampCursor(current + 1, view.entries.length)); + return; + } + + const transition = props.onInput({ + state, + cursor: normalizedCursor, + entry: view.entries[normalizedCursor], + input, + key, + }); + if (!transition) return; + if (transition.type === "resolve") { + props.onResolve(transition.value); + exit(); + return; + } + setState(transition.state); + if (typeof transition.cursor === "number") { + const nextView = props.renderView(transition.state, transition.cursor); + setCursor(clampCursor(transition.cursor, nextView.entries.length)); + } + }); + + return createElement( + InkShellFrame, + { + title: props.title, + subtitle: props.subtitle, + status: view.status, + statusTone: view.statusTone, + footer: view.footer, + theme, + }, + createElement( + Box, + { flexDirection: "column", rowGap: 1 }, + view.previewLines && view.previewLines.length > 0 + ? createElement( + InkShellPanel, + { title: view.previewTitle ?? UI_COPY.settings.previewHeading, theme }, + createElement( + Box, + { flexDirection: "column" }, + ...view.previewLines.map((line, index) => + createElement(Text, { key: `${index}:${line}`, color: theme.textColor }, line), + ), + ), + ) + : null, + createElement( + InkShellPanel, + { title: view.panelTitle, theme }, + view.entries.length > 0 + ? createElement( + Box, + { flexDirection: "column" }, + ...view.entries.map((entry, index) => + createElement(InkShellRow, { + key: entry.id, + label: entry.label, + detail: entry.detail, + active: index === normalizedCursor, + tone: entry.tone, + theme, + }), + ), + ) + : createElement(Text, { color: theme.mutedColor }, "No items"), + ), + ), + ); +} + +async function promptInkSettingsScreen( + options: Omit, "onResolve">, +): Promise { + const support = resolveInkAuthShellBootstrap(options); + if (!support.supported) return null; + + return await new Promise((resolve) => { + const App = (props: InkSettingsAppProps) => InkSettingsApp(props); + const renderOptions: RenderOptions = { + stdin: options.stdin ?? process.stdin, + stdout: options.stdout ?? process.stdout, + stderr: options.stderr ?? process.stderr, + debug: options.debug ?? false, + patchConsole: options.patchConsole ?? false, + exitOnCtrlC: options.exitOnCtrlC ?? false, + }; + render(createElement(App, { ...options, onResolve: (value: TResult) => resolve(value) }), renderOptions); + }); +} + +export async function promptInkSettingsHub( + options: InkSettingsEnvironment & { initialFocus?: SettingsHubActionType } = {}, +): Promise<{ type: SettingsHubActionType } | null> { + const focusOrder: SettingsHubActionType[] = [ + "account-list", + "summary-fields", + "behavior", + "theme", + "backend", + "back", + ]; + const initialCursor = Math.max(0, focusOrder.indexOf(options.initialFocus ?? "account-list")); + return await promptInkSettingsScreen({ + ...options, + title: UI_COPY.settings.title, + subtitle: UI_COPY.settings.subtitle, + initialState: 0, + initialCursor, + renderView: (): InkSettingsView => ({ + panelTitle: UI_COPY.settings.sectionTitle, + footer: UI_COPY.settings.help, + entries: [ + makeEntry("account-list", UI_COPY.settings.accountList, "success"), + makeEntry("summary-fields", UI_COPY.settings.summaryFields, "success"), + makeEntry("behavior", UI_COPY.settings.behavior, "success"), + makeEntry("theme", UI_COPY.settings.theme, "success"), + makeEntry("backend", UI_COPY.settings.backend, "warning"), + makeEntry("back", UI_COPY.settings.back, "danger"), + ], + }), + onInput: ({ cursor, entry, input, key }) => { + const lower = input.toLowerCase(); + if (lower === "q") return { type: "resolve", value: { type: "back" } }; + if (lower >= "1" && lower <= "5") { + const index = Number.parseInt(lower, 10) - 1; + const target = focusOrder[index]; + if (target) return { type: "resolve", value: { type: target } }; + } + if (!key.return) return undefined; + const target = focusOrder[cursor] ?? (entry?.id as SettingsHubActionType | undefined); + return { type: "resolve", value: { type: target ?? "back" } }; + }, + }); +} + +export async function promptInkAccountListSettings( + initial: DashboardDisplaySettings, + options: InkSettingsEnvironment = {}, +): Promise { + return await promptInkSettingsScreen({ + ...options, + title: UI_COPY.settings.accountListTitle, + subtitle: UI_COPY.settings.accountListSubtitle, + initialState: cloneDashboardSettings(initial), + renderView: (draft): InkSettingsView => { + const preview = buildAccountListPreview(draft); + const optionEntries: InkSettingsEntry[] = DASHBOARD_DISPLAY_OPTIONS.map((option, index) => { + const enabled = draft[option.key] ?? true; + return makeEntry( + option.key, + `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}`, + entryTone(enabled), + option.description, + ); + }); + const sortMode = draft.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); + const layoutMode = resolveMenuLayoutMode(draft); + return { + panelTitle: UI_COPY.settings.displayHeading, + footer: UI_COPY.settings.accountListHelp, + previewTitle: UI_COPY.settings.previewHeading, + previewLines: [preview.label, preview.hint], + entries: [ + ...optionEntries, + makeEntry( + "sort-mode", + `Sort mode: ${formatMenuSortMode(sortMode)}`, + sortMode === "ready-first" ? "success" : "warning", + "Applies when smart sort is enabled.", + ), + makeEntry( + "layout-mode", + `Layout: ${formatMenuLayoutMode(layoutMode)}`, + layoutMode === "compact-details" ? "success" : "warning", + "Compact shows one-line rows with a selected details pane.", + ), + makeEntry("reset", UI_COPY.settings.resetDefault, "warning"), + makeEntry("save", UI_COPY.settings.saveAndBack, "success"), + makeEntry("cancel", UI_COPY.settings.backNoSave, "danger"), + ], + }; + }, + onInput: ({ state: draft, cursor, entry, input, key }) => { + const lower = input.toLowerCase(); + const toggleOption = (option: DashboardDisplaySettingOption) => ({ + type: "update" as const, + state: { + ...draft, + [option.key]: !(draft[option.key] ?? true), + }, + cursor, + }); + const cycleSortMode = () => { + const currentMode = draft.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); + const nextMode: DashboardAccountSortMode = currentMode === "ready-first" ? "manual" : "ready-first"; + return { + type: "update" as const, + state: { + ...draft, + menuSortMode: nextMode, + menuSortEnabled: + nextMode === "ready-first" + ? true + : (draft.menuSortEnabled ?? + (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true)), + }, + cursor: DASHBOARD_DISPLAY_OPTIONS.length, + }; + }; + const cycleLayout = () => { + const currentLayout = resolveMenuLayoutMode(draft); + const nextLayout: DashboardLayoutMode = currentLayout === "compact-details" + ? "expanded-rows" + : "compact-details"; + return { + type: "update" as const, + state: { + ...draft, + menuLayoutMode: nextLayout, + menuShowDetailsForUnselectedRows: nextLayout === "expanded-rows", + }, + cursor: DASHBOARD_DISPLAY_OPTIONS.length + 1, + }; + }; + + if (lower === "q") return { type: "resolve", value: null }; + if (lower === "s") return { type: "resolve", value: draft }; + if (lower === "r") { + return { + type: "update", + state: applyDashboardDefaultsForKeys(draft, ACCOUNT_LIST_PANEL_KEYS), + cursor: 0, + }; + } + if (lower === "m") return cycleSortMode(); + if (lower === "l") return cycleLayout(); + const parsed = Number.parseInt(lower, 10); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= DASHBOARD_DISPLAY_OPTIONS.length) { + const option = DASHBOARD_DISPLAY_OPTIONS[parsed - 1]; + if (option) return toggleOption(option); + } + if (!key.return || !entry) return undefined; + if (entry.id === "save") return { type: "resolve", value: draft }; + if (entry.id === "cancel") return { type: "resolve", value: null }; + if (entry.id === "reset") { + return { + type: "update", + state: applyDashboardDefaultsForKeys(draft, ACCOUNT_LIST_PANEL_KEYS), + cursor: 0, + }; + } + if (entry.id === "sort-mode") return cycleSortMode(); + if (entry.id === "layout-mode") return cycleLayout(); + const option = DASHBOARD_DISPLAY_OPTIONS.find((candidate) => candidate.key === entry.id); + if (option) return toggleOption(option); + return undefined; + }, + }); +} + +export async function promptInkStatuslineSettings( + initial: DashboardDisplaySettings, + options: InkSettingsEnvironment = {}, +): Promise { + return await promptInkSettingsScreen({ + ...options, + title: UI_COPY.settings.summaryTitle, + subtitle: UI_COPY.settings.summarySubtitle, + initialState: cloneDashboardSettings(initial), + renderView: (draft): InkSettingsView => { + const preview = buildAccountListPreview(draft); + const selectedSet = new Set(normalizeStatuslineFields(draft.menuStatuslineFields)); + const ordered = normalizeStatuslineFields(draft.menuStatuslineFields); + const orderMap = new Map(); + for (let index = 0; index < ordered.length; index += 1) { + const key = ordered[index]; + if (key) orderMap.set(key, index + 1); + } + return { + panelTitle: UI_COPY.settings.displayHeading, + footer: UI_COPY.settings.summaryHelp, + previewTitle: UI_COPY.settings.previewHeading, + previewLines: [preview.label, preview.hint], + entries: [ + ...STATUSLINE_FIELD_OPTIONS.map((option, index) => { + const enabled = selectedSet.has(option.key); + const rank = orderMap.get(option.key); + return makeEntry( + option.key, + `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}${rank ? ` (order ${rank})` : ""}`, + entryTone(enabled), + option.description, + ); + }), + makeEntry("reset", UI_COPY.settings.resetDefault, "warning"), + makeEntry("save", UI_COPY.settings.saveAndBack, "success"), + makeEntry("cancel", UI_COPY.settings.backNoSave, "danger"), + ], + }; + }, + onInput: ({ state: draft, cursor, entry, input, key }) => { + const lower = input.toLowerCase(); + const selectedEntry = entry?.id as DashboardStatuslineField | undefined; + const toggleField = (field: DashboardStatuslineField) => { + const fields = normalizeStatuslineFields(draft.menuStatuslineFields); + const isEnabled = fields.includes(field); + const nextFields = isEnabled + ? fields.filter((candidate) => candidate !== field) + : [...fields, field]; + return { + type: "update" as const, + state: { + ...draft, + menuStatuslineFields: nextFields.length > 0 ? nextFields : [field], + }, + cursor, + }; + }; + const moveField = (direction: -1 | 1) => { + if (!selectedEntry || !STATUSLINE_FIELD_OPTIONS.some((option) => option.key === selectedEntry)) { + return undefined; + } + return { + type: "update" as const, + state: { + ...draft, + menuStatuslineFields: reorderField( + normalizeStatuslineFields(draft.menuStatuslineFields), + selectedEntry, + direction, + ), + }, + cursor, + }; + }; + + if (lower === "q") return { type: "resolve", value: null }; + if (lower === "s") return { type: "resolve", value: draft }; + if (lower === "r") { + return { + type: "update", + state: applyDashboardDefaultsForKeys(draft, STATUSLINE_PANEL_KEYS), + cursor: 0, + }; + } + if (lower === "[") return moveField(-1); + if (lower === "]") return moveField(1); + const parsed = Number.parseInt(lower, 10); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= STATUSLINE_FIELD_OPTIONS.length) { + const target = STATUSLINE_FIELD_OPTIONS[parsed - 1]; + if (target) return toggleField(target.key); + } + if (!key.return || !entry) return undefined; + if (entry.id === "save") return { type: "resolve", value: draft }; + if (entry.id === "cancel") return { type: "resolve", value: null }; + if (entry.id === "reset") { + return { + type: "update", + state: applyDashboardDefaultsForKeys(draft, STATUSLINE_PANEL_KEYS), + cursor: 0, + }; + } + const target = STATUSLINE_FIELD_OPTIONS.find((option) => option.key === entry.id); + if (target) return toggleField(target.key); + return undefined; + }, + }); +} + +export async function promptInkBehaviorSettings( + initial: DashboardDisplaySettings, + options: InkSettingsEnvironment = {}, +): Promise { + return await promptInkSettingsScreen({ + ...options, + title: UI_COPY.settings.behaviorTitle, + subtitle: UI_COPY.settings.behaviorSubtitle, + initialState: cloneDashboardSettings(initial), + renderView: (draft): InkSettingsView => { + const currentDelay = draft.actionAutoReturnMs ?? 2_000; + const pauseOnKey = draft.actionPauseOnKey ?? true; + const autoFetchLimits = draft.menuAutoFetchLimits ?? true; + const fetchStatusVisible = draft.menuShowFetchStatus ?? true; + const menuQuotaTtlMs = draft.menuQuotaTtlMs ?? 5 * 60_000; + return { + panelTitle: UI_COPY.settings.actionTiming, + footer: UI_COPY.settings.behaviorHelp, + status: `Current delay ${formatMenuQuotaTtl(currentDelay)} | TTL ${formatMenuQuotaTtl(menuQuotaTtlMs)}`, + statusTone: "accent", + entries: [ + ...AUTO_RETURN_OPTIONS_MS.map((delayMs, index) => + makeEntry( + `delay:${delayMs}`, + `${currentDelay === delayMs ? "[x]" : "[ ]"} ${index + 1}. ${delayMs <= 0 ? "Instant return" : `${Math.round(delayMs / 1_000)}s auto-return`}`, + currentDelay === delayMs ? "success" : "warning", + delayMs === 1_000 + ? "Fastest loop for frequent actions." + : delayMs === 2_000 + ? "Balanced default for most users." + : "More time to read action output.", + ), + ), + makeEntry( + "pause", + `${pauseOnKey ? "[x]" : "[ ]"} Pause on key press`, + entryTone(pauseOnKey), + "Press any key to stop auto-return.", + ), + makeEntry( + "auto-fetch", + `${autoFetchLimits ? "[x]" : "[ ]"} Auto-fetch limits on menu open (${formatMenuQuotaTtl(menuQuotaTtlMs)} cache)`, + entryTone(autoFetchLimits), + "Refreshes account limits automatically when opening the menu.", + ), + makeEntry( + "fetch-status", + `${fetchStatusVisible ? "[x]" : "[ ]"} Show limit refresh status`, + entryTone(fetchStatusVisible), + "Shows background fetch progress like [2/7] in menu subtitle.", + ), + makeEntry( + "ttl", + `Limit cache TTL: ${formatMenuQuotaTtl(menuQuotaTtlMs)}`, + "warning", + "How fresh cached quota data must be before refresh runs.", + ), + makeEntry("reset", UI_COPY.settings.resetDefault, "warning"), + makeEntry("save", UI_COPY.settings.saveAndBack, "success"), + makeEntry("cancel", UI_COPY.settings.backNoSave, "danger"), + ], + }; + }, + onInput: ({ state: draft, cursor, entry, input, key }) => { + const lower = input.toLowerCase(); + const setDelay = (delayMs: number) => ({ + type: "update" as const, + state: { ...draft, actionAutoReturnMs: delayMs }, + cursor, + }); + const togglePause = () => ({ + type: "update" as const, + state: { ...draft, actionPauseOnKey: !(draft.actionPauseOnKey ?? true) }, + cursor, + }); + const toggleAutoFetch = () => ({ + type: "update" as const, + state: { ...draft, menuAutoFetchLimits: !(draft.menuAutoFetchLimits ?? true) }, + cursor, + }); + const toggleFetchStatus = () => ({ + type: "update" as const, + state: { ...draft, menuShowFetchStatus: !(draft.menuShowFetchStatus ?? true) }, + cursor, + }); + const cycleTtl = () => { + const currentTtl = draft.menuQuotaTtlMs ?? 5 * 60_000; + const currentIndex = MENU_QUOTA_TTL_OPTIONS_MS.findIndex((value) => value === currentTtl); + const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % MENU_QUOTA_TTL_OPTIONS_MS.length; + const nextTtl = MENU_QUOTA_TTL_OPTIONS_MS[nextIndex] ?? MENU_QUOTA_TTL_OPTIONS_MS[0] ?? currentTtl; + return { + type: "update" as const, + state: { ...draft, menuQuotaTtlMs: nextTtl }, + cursor, + }; + }; + + if (lower === "q") return { type: "resolve", value: null }; + if (lower === "s") return { type: "resolve", value: draft }; + if (lower === "r") { + return { + type: "update", + state: applyDashboardDefaultsForKeys(draft, BEHAVIOR_PANEL_KEYS), + cursor: 0, + }; + } + if (lower === "p") return togglePause(); + if (lower === "l") return toggleAutoFetch(); + if (lower === "f") return toggleFetchStatus(); + if (lower === "t") return cycleTtl(); + const parsed = Number.parseInt(lower, 10); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= AUTO_RETURN_OPTIONS_MS.length) { + const delayMs = AUTO_RETURN_OPTIONS_MS[parsed - 1]; + if (typeof delayMs === "number") return setDelay(delayMs); + } + if (!key.return || !entry) return undefined; + if (entry.id === "save") return { type: "resolve", value: draft }; + if (entry.id === "cancel") return { type: "resolve", value: null }; + if (entry.id === "reset") { + return { + type: "update", + state: applyDashboardDefaultsForKeys(draft, BEHAVIOR_PANEL_KEYS), + cursor: 0, + }; + } + if (entry.id === "pause") return togglePause(); + if (entry.id === "auto-fetch") return toggleAutoFetch(); + if (entry.id === "fetch-status") return toggleFetchStatus(); + if (entry.id === "ttl") return cycleTtl(); + if (entry.id.startsWith("delay:")) { + const delayMs = Number.parseInt(entry.id.slice("delay:".length), 10); + if (Number.isFinite(delayMs)) return setDelay(delayMs); + } + return undefined; + }, + }); +} + +export async function promptInkThemeSettings( + initial: DashboardDisplaySettings, + options: InkSettingsEnvironment = {}, +): Promise { + const baseline = cloneDashboardSettings(initial); + return await promptInkSettingsScreen({ + ...options, + title: UI_COPY.settings.themeTitle, + subtitle: UI_COPY.settings.themeSubtitle, + initialState: cloneDashboardSettings(initial), + renderView: (draft): InkSettingsView => ({ + panelTitle: UI_COPY.settings.baseTheme, + footer: UI_COPY.settings.themeHelp, + status: `Base ${draft.uiThemePreset ?? "green"} | Accent ${draft.uiAccentColor ?? "green"}`, + statusTone: "accent", + entries: [ + ...THEME_PRESET_OPTIONS.map((candidate, index) => + makeEntry( + `palette:${candidate}`, + `${draft.uiThemePreset === candidate ? "[x]" : "[ ]"} ${index + 1}. ${candidate === "green" ? "Green base" : "Blue base"}`, + draft.uiThemePreset === candidate ? "success" : "warning", + candidate === "green" ? "High-contrast default." : "Codex-style blue look.", + ), + ), + ...ACCENT_COLOR_OPTIONS.map((candidate) => + makeEntry( + `accent:${candidate}`, + `${draft.uiAccentColor === candidate ? "[x]" : "[ ]"} ${candidate}`, + draft.uiAccentColor === candidate ? "success" : "warning", + ), + ), + makeEntry("reset", UI_COPY.settings.resetDefault, "warning"), + makeEntry("save", UI_COPY.settings.saveAndBack, "success"), + makeEntry("cancel", UI_COPY.settings.backNoSave, "danger"), + ], + previewTitle: UI_COPY.settings.previewHeading, + previewLines: (() => { + const preview = buildAccountListPreview(draft); + return [preview.label, preview.hint]; + })(), + }), + onInput: ({ state: draft, cursor, entry, input, key }) => { + const lower = input.toLowerCase(); + const setPalette = (palette: DashboardThemePreset) => { + const next = { ...draft, uiThemePreset: palette }; + applyUiThemeFromDashboardSettings(next); + return { type: "update" as const, state: next, cursor }; + }; + const setAccent = (accent: DashboardAccentColor) => { + const next = { ...draft, uiAccentColor: accent }; + applyUiThemeFromDashboardSettings(next); + return { type: "update" as const, state: next, cursor }; + }; + if (lower === "q") { + applyUiThemeFromDashboardSettings(baseline); + return { type: "resolve", value: null }; + } + if (lower === "s") return { type: "resolve", value: draft }; + if (lower === "r") { + const next = applyDashboardDefaultsForKeys(draft, THEME_PANEL_KEYS); + applyUiThemeFromDashboardSettings(next); + return { type: "update", state: next, cursor: 0 }; + } + if (lower === "1") return setPalette("green"); + if (lower === "2") return setPalette("blue"); + if (!key.return || !entry) return undefined; + if (entry.id === "save") return { type: "resolve", value: draft }; + if (entry.id === "cancel") { + applyUiThemeFromDashboardSettings(baseline); + return { type: "resolve", value: null }; + } + if (entry.id === "reset") { + const next = applyDashboardDefaultsForKeys(draft, THEME_PANEL_KEYS); + applyUiThemeFromDashboardSettings(next); + return { type: "update", state: next, cursor: 0 }; + } + if (entry.id.startsWith("palette:")) { + const palette = entry.id.slice("palette:".length) as DashboardThemePreset; + return setPalette(palette); + } + if (entry.id.startsWith("accent:")) { + const accent = entry.id.slice("accent:".length) as DashboardAccentColor; + return setAccent(accent); + } + return undefined; + }, + }); +} + +function getBackendCategory(key: BackendCategoryKey): BackendCategoryOption | null { + return BACKEND_CATEGORY_OPTIONS.find((category) => category.key === key) ?? null; +} + +function applyBackendCategoryDefaults( + draft: PluginConfig, + category: BackendCategoryOption, +): PluginConfig { + const next = { ...draft }; + for (const key of category.toggleKeys) { + next[key] = BACKEND_DEFAULTS[key] ?? false; + } + for (const key of category.numberKeys) { + const option = BACKEND_NUMBER_OPTION_BY_KEY.get(key); + const fallback = option?.min ?? 0; + next[key] = BACKEND_DEFAULTS[key] ?? fallback; + } + return next; +} + +async function promptInkBackendCategorySettings( + initial: PluginConfig, + category: BackendCategoryOption, + options: InkSettingsEnvironment = {}, +): Promise { + return await promptInkSettingsScreen({ + ...options, + title: `${UI_COPY.settings.backendCategoryTitle}: ${category.label}`, + subtitle: category.description, + initialState: cloneBackendPluginConfig(initial), + renderView: (draft): InkSettingsView => { + const preview = buildBackendSettingsPreview(draft); + const toggleEntries: InkSettingsEntry[] = category.toggleKeys.flatMap((key, index) => { + const option = BACKEND_TOGGLE_OPTION_BY_KEY.get(key); + if (!option) return []; + const enabled = draft[key] ?? BACKEND_DEFAULTS[key] ?? false; + return [ + makeEntry( + `toggle:${key}`, + `${formatDashboardSettingState(enabled)} ${index + 1}. ${option.label}`, + entryTone(enabled), + option.description, + ), + ]; + }); + const numberEntries: InkSettingsEntry[] = category.numberKeys.flatMap((key) => { + const option = BACKEND_NUMBER_OPTION_BY_KEY.get(key); + if (!option) return []; + const rawValue = draft[key] ?? BACKEND_DEFAULTS[key] ?? option.min; + const numericValue = typeof rawValue === "number" && Number.isFinite(rawValue) + ? rawValue + : option.min; + const clampedValue = clampBackendNumber(option, numericValue); + return [ + makeEntry( + `number:${key}`, + `${option.label}: ${formatBackendNumberValue(option, clampedValue)}`, + "warning", + `${option.description} Step ${formatBackendNumberValue(option, option.step)}.`, + ), + ]; + }); + return { + panelTitle: category.label, + footer: UI_COPY.settings.backendCategoryHelp, + previewTitle: UI_COPY.settings.previewHeading, + previewLines: [preview.label, preview.hint], + entries: [ + ...toggleEntries, + ...numberEntries, + makeEntry("reset", UI_COPY.settings.backendResetCategory, "warning"), + makeEntry("back", UI_COPY.settings.backendBackToCategories, "danger"), + ], + }; + }, + onInput: ({ state: draft, cursor, entry, input, key }) => { + const lower = input.toLowerCase(); + const adjustCurrentNumber = (direction: -1 | 1) => { + if (!entry || !entry.id.startsWith("number:")) return undefined; + const numberKey = entry.id.slice("number:".length) as BackendNumberSettingKey; + const option = BACKEND_NUMBER_OPTION_BY_KEY.get(numberKey); + if (!option) return undefined; + const currentValue = draft[numberKey] ?? BACKEND_DEFAULTS[numberKey] ?? option.min; + const numericCurrent = typeof currentValue === "number" && Number.isFinite(currentValue) + ? currentValue + : option.min; + return { + type: "update" as const, + state: { + ...draft, + [numberKey]: clampBackendNumber(option, numericCurrent + option.step * direction), + }, + cursor, + }; + }; + + if (lower === "q") return { type: "resolve", value: draft }; + if (lower === "r") { + return { + type: "update", + state: applyBackendCategoryDefaults(draft, category), + cursor: 0, + }; + } + if (lower === "+" || lower === "=" || lower === "]" || lower === "d") { + return adjustCurrentNumber(1); + } + if (lower === "-" || lower === "[" || lower === "a") { + return adjustCurrentNumber(-1); + } + const parsed = Number.parseInt(lower, 10); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= category.toggleKeys.length) { + const toggleKey = category.toggleKeys[parsed - 1]; + if (toggleKey) { + const currentValue = draft[toggleKey] ?? BACKEND_DEFAULTS[toggleKey] ?? false; + return { + type: "update", + state: { ...draft, [toggleKey]: !currentValue }, + cursor: parsed - 1, + }; + } + } + if (!key.return || !entry) return undefined; + if (entry.id === "back") return { type: "resolve", value: draft }; + if (entry.id === "reset") { + return { + type: "update", + state: applyBackendCategoryDefaults(draft, category), + cursor: 0, + }; + } + if (entry.id.startsWith("toggle:")) { + const toggleKey = entry.id.slice("toggle:".length) as BackendToggleSettingKey; + const currentValue = draft[toggleKey] ?? BACKEND_DEFAULTS[toggleKey] ?? false; + return { + type: "update", + state: { ...draft, [toggleKey]: !currentValue }, + cursor, + }; + } + if (entry.id.startsWith("number:")) { + return adjustCurrentNumber(1); + } + return undefined; + }, + }); +} + +export async function promptInkBackendSettings( + initial: PluginConfig, + options: InkSettingsEnvironment = {}, +): Promise { + let draft = cloneBackendPluginConfig(initial); + let activeCategory = BACKEND_CATEGORY_OPTIONS[0]?.key ?? "session-sync"; + while (true) { + const action = await promptInkSettingsScreen({ + ...options, + title: UI_COPY.settings.backendTitle, + subtitle: UI_COPY.settings.backendSubtitle, + initialState: draft, + initialCursor: Math.max(0, BACKEND_CATEGORY_OPTIONS.findIndex((category) => category.key === activeCategory)), + renderView: (currentDraft): InkSettingsView => { + const preview = buildBackendSettingsPreview(currentDraft); + return { + panelTitle: UI_COPY.settings.backendCategoriesHeading, + footer: UI_COPY.settings.backendHelp, + previewTitle: UI_COPY.settings.previewHeading, + previewLines: [preview.label, preview.hint], + entries: [ + ...BACKEND_CATEGORY_OPTIONS.map((category, index) => + makeEntry( + `category:${category.key}`, + `${index + 1}. ${category.label}`, + "success", + category.description, + ), + ), + makeEntry("reset", UI_COPY.settings.resetDefault, "warning"), + makeEntry("save", UI_COPY.settings.saveAndBack, "success"), + makeEntry("cancel", UI_COPY.settings.backNoSave, "danger"), + ], + }; + }, + onInput: ({ cursor, entry, input, key }) => { + const lower = input.toLowerCase(); + if (lower === "q") return { type: "resolve", value: { type: "cancel" } }; + if (lower === "s") return { type: "resolve", value: { type: "save" } }; + if (lower === "r") return { type: "resolve", value: { type: "reset" } }; + const parsed = Number.parseInt(lower, 10); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= BACKEND_CATEGORY_OPTIONS.length) { + const category = BACKEND_CATEGORY_OPTIONS[parsed - 1]; + if (category) return { type: "resolve", value: { type: "open", key: category.key } }; + } + if (!key.return || !entry) return undefined; + if (entry.id === "save") return { type: "resolve", value: { type: "save" } }; + if (entry.id === "reset") return { type: "resolve", value: { type: "reset" } }; + if (entry.id === "cancel") return { type: "resolve", value: { type: "cancel" } }; + if (entry.id.startsWith("category:")) { + const keyValue = entry.id.slice("category:".length) as BackendCategoryKey; + return { type: "resolve", value: { type: "open", key: keyValue } }; + } + const category = BACKEND_CATEGORY_OPTIONS[cursor]; + if (category) return { type: "resolve", value: { type: "open", key: category.key } }; + return undefined; + }, + }); + + if (!action) return null; + if (action.type === "cancel") return null; + if (action.type === "save") return draft; + if (action.type === "reset") { + draft = cloneBackendPluginConfig(BACKEND_DEFAULTS); + activeCategory = BACKEND_CATEGORY_OPTIONS[0]?.key ?? activeCategory; + continue; + } + const category = getBackendCategory(action.key); + if (!category) continue; + activeCategory = category.key; + const nextDraft = await promptInkBackendCategorySettings(draft, category, options); + if (nextDraft) { + draft = cloneBackendPluginConfig(nextDraft); + } + } +} + +export async function configureInkUnifiedSettings( + initialSettings?: DashboardDisplaySettings, + options: InkSettingsEnvironment = {}, +): Promise { + let current = cloneDashboardSettings(initialSettings ?? await loadDashboardDisplaySettings()); + let backendConfig = cloneBackendPluginConfig(loadPluginConfig()); + applyUiThemeFromDashboardSettings(current); + let hubFocus: SettingsHubActionType = "account-list"; + const firstAction = await promptInkSettingsHub({ ...options, initialFocus: hubFocus }); + if (!firstAction) return false; + let action: { type: SettingsHubActionType } | null = firstAction; + while (action) { + if (action.type === "back") return true; + hubFocus = action.type; + if (action.type === "account-list") { + const selected = await promptInkAccountListSettings(current, options); + if (selected && !dashboardSettingsEqual(current, selected)) { + current = await persistDashboardSettingsSelection(selected, ACCOUNT_LIST_PANEL_KEYS, "account-list", { + cloneSettings: cloneDashboardSettings, + mergeSettingsForKeys: mergeDashboardSettingsForKeys, + }); + applyUiThemeFromDashboardSettings(current); + } + } else if (action.type === "summary-fields") { + const selected = await promptInkStatuslineSettings(current, options); + if (selected && !dashboardSettingsEqual(current, selected)) { + current = await persistDashboardSettingsSelection(selected, STATUSLINE_PANEL_KEYS, "summary-fields", { + cloneSettings: cloneDashboardSettings, + mergeSettingsForKeys: mergeDashboardSettingsForKeys, + }); + applyUiThemeFromDashboardSettings(current); + } + } else if (action.type === "behavior") { + const selected = await promptInkBehaviorSettings(current, options); + if (selected && !dashboardSettingsEqual(current, selected)) { + current = await persistDashboardSettingsSelection(selected, BEHAVIOR_PANEL_KEYS, "behavior", { + cloneSettings: cloneDashboardSettings, + mergeSettingsForKeys: mergeDashboardSettingsForKeys, + }); + applyUiThemeFromDashboardSettings(current); + } + } else if (action.type === "theme") { + const selected = await promptInkThemeSettings(current, options); + if (selected && !dashboardSettingsEqual(current, selected)) { + current = await persistDashboardSettingsSelection(selected, THEME_PANEL_KEYS, "theme", { + cloneSettings: cloneDashboardSettings, + mergeSettingsForKeys: mergeDashboardSettingsForKeys, + }); + applyUiThemeFromDashboardSettings(current); + } + } else if (action.type === "backend") { + const selected = await promptInkBackendSettings(backendConfig, options); + if (selected && !backendSettingsEqual(backendConfig, selected)) { + backendConfig = await persistBackendConfigSelection(selected, "backend", { + cloneConfig: cloneBackendPluginConfig, + buildPatch: buildBackendConfigPatch, + }); + } + } + action = await promptInkSettingsHub({ ...options, initialFocus: hubFocus }); + if (!action) return true; + } + return true; +} diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index b7c7708f..3e9412ad 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -1,11 +1,11 @@ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { ANSI, isTTY } from "./ansi.js"; -import { confirm } from "./confirm.js"; import { getUiRuntimeOptions } from "./runtime.js"; import { select, type MenuItem } from "./select.js"; import { paintUiText, formatUiBadge, quotaToneFromLeftPercent } from "./format.js"; import { UI_COPY, formatCheckFlaggedLabel } from "./copy.js"; +import { buildAuthAccountDetailViewModel } from "../codex-manager/auth-ui-controller.js"; export type AccountStatus = | "active" @@ -84,9 +84,11 @@ function mainMenuTitleWithVersion(): string { function sanitizeTerminalText(value: string | undefined): string | undefined { if (!value) return undefined; + const ansiPattern = new RegExp("\\u001B\\[[0-?]*[ -/]*[@-~]", "g"); + const controlPattern = new RegExp("[\\u0000-\\u001F\\u007F]", "g"); return value - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(ansiPattern, "") + .replace(controlPattern, "") .trim(); } @@ -100,11 +102,6 @@ function formatRelativeTime(timestamp: number | undefined): string { return new Date(timestamp).toLocaleDateString(); } -function formatDate(timestamp: number | undefined): string { - if (!timestamp) return "unknown"; - return new Date(timestamp).toLocaleDateString(); -} - function statusBadge(status: AccountStatus | undefined): string { const ui = getUiRuntimeOptions(); const withTone = ( @@ -601,18 +598,6 @@ export async function showAuthMenu( focusKey = "action:search"; continue; } - if (result.type === "delete-all") { - const confirmed = await confirm("Delete all accounts?"); - if (!confirmed) continue; - } - if (result.type === "delete-account") { - const confirmed = await confirm(`Delete ${accountTitle(result.account)}?`); - if (!confirmed) continue; - } - if (result.type === "refresh-account") { - const confirmed = await confirm(`Re-authenticate ${accountTitle(result.account)}?`); - if (!confirmed) continue; - } focusKey = authMenuFocusKey(result); return result; } @@ -620,33 +605,27 @@ export async function showAuthMenu( export async function showAccountDetails(account: AccountInfo): Promise { const ui = getUiRuntimeOptions(); - const header = - `${accountTitle(account)} ${statusBadge(account.status)}` + - (account.enabled === false - ? (ui.v2Enabled - ? ` ${formatUiBadge(ui, "disabled", "danger")}` - : ` ${ANSI.red}[disabled]${ANSI.reset}`) - : ""); - const statusLabel = account.status ?? "unknown"; - const subtitle = `Added: ${formatDate(account.addedAt)} | Used: ${formatRelativeTime(account.lastUsed)} | Status: ${statusLabel}`; + const detail = buildAuthAccountDetailViewModel(account); + const header = detail.title + .replace(" [active]", ` ${statusBadge("active")}`) + .replace(" [ok]", ` ${statusBadge("ok")}`) + .replace(" [rate-limited]", ` ${statusBadge("rate-limited")}`) + .replace(" [cooldown]", ` ${statusBadge("cooldown")}`) + .replace(" [disabled]", ui.v2Enabled + ? ` ${formatUiBadge(ui, "disabled", "danger")}` + : ` ${ANSI.red}[disabled]${ANSI.reset}`) + .replace(" [error]", ` ${statusBadge("error")}`) + .replace(" [flagged]", ` ${statusBadge("flagged")}`) + .replace(" [unknown]", ` ${statusBadge("unknown")}`); + const subtitle = detail.subtitle; let focusAction: AccountAction = "back"; while (true) { - const items: MenuItem[] = [ - { label: UI_COPY.accountDetails.back, value: "back" }, - { - label: account.enabled === false ? UI_COPY.accountDetails.enable : UI_COPY.accountDetails.disable, - value: "toggle", - color: account.enabled === false ? "green" : "yellow", - }, - { - label: UI_COPY.accountDetails.setCurrent, - value: "set-current", - color: "green", - }, - { label: UI_COPY.accountDetails.refresh, value: "refresh", color: "green" }, - { label: UI_COPY.accountDetails.remove, value: "delete", color: "red" }, - ]; + const items: MenuItem[] = detail.actions.map((action) => ({ + label: action.label, + value: action.id, + color: action.tone, + })); const initialCursor = items.findIndex((item) => item.value === focusAction); const action = await select(items, { message: header, @@ -675,14 +654,6 @@ export async function showAccountDetails(account: AccountInfo): Promise=14.13.1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -1229,6 +1257,24 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -1710,7 +1756,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", - "dev": true, "license": "MIT", "dependencies": { "environment": "^1.0.0" @@ -1726,7 +1771,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1735,6 +1779,18 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/arctic": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/arctic/-/arctic-2.3.4.tgz", @@ -1769,6 +1825,18 @@ "js-tokens": "^9.0.1" } }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/aws4fetch": { "version": "1.0.20", "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", @@ -1821,6 +1889,30 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1854,6 +1946,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -1871,6 +1975,15 @@ "node": ">=20" } }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1886,6 +1999,13 @@ "node": ">= 8" } }, + "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==", + "devOptional": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1915,14 +2035,12 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, "license": "MIT" }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1938,6 +2056,16 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2342,7 +2470,6 @@ "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==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2426,6 +2553,179 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink/node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/ink/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/ink/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2440,7 +2740,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "^1.3.1" @@ -2465,6 +2764,21 @@ "node": ">=0.10.0" } }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2661,6 +2975,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2713,6 +3045,15 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -2875,6 +3216,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3001,6 +3351,34 @@ ], "license": "MIT" }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -3070,6 +3448,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3145,7 +3532,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -3158,19 +3544,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3181,6 +3554,27 @@ "node": ">=0.10.0" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3226,7 +3620,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -3375,6 +3768,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3649,6 +4054,38 @@ "node": ">=8" } }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3663,7 +4100,6 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -3677,24 +4113,10 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -3708,6 +4130,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", @@ -3737,6 +4180,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 6f848975..92fc1b2b 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ }, "scripts": { "build": "tsc && node scripts/copy-oauth-success.js", + "bun:build:opentui": "node scripts/run-bun-tests.js run runtime/opentui/build.ts", + "bun:dev:opentui": "bun run runtime/opentui/index.ts", "typecheck": "tsc --noEmit", "lint": "npm run lint:ts && npm run lint:scripts", "lint:ts": "eslint . --ext .ts", @@ -46,7 +48,9 @@ "lint:fix": "npm run lint:ts:fix && npm run lint:scripts:fix", "lint:ts:fix": "eslint . --ext .ts --fix", "lint:scripts:fix": "eslint scripts --ext .js --fix", - "test": "vitest run", + "test": "npm run test:opentui && npm run test:vitest", + "test:opentui": "node scripts/run-bun-tests.js", + "test:vitest": "node --max-old-space-size=12288 ./node_modules/vitest/vitest.mjs run --maxWorkers=1 --pool=forks --execArgv=--max-old-space-size=12288", "test:watch": "vitest", "test:ui": "vitest --ui", "test:model-matrix": "node scripts/test-model-matrix.js", @@ -100,13 +104,15 @@ "typescript": "^5" }, "devDependencies": { - "@fast-check/vitest": "^0.2.4", "@codex-ai/sdk": "file:vendor/codex-ai-sdk", + "@fast-check/vitest": "^0.2.4", "@types/node": "^25.3.0", + "@types/react": "18.3.28", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.18", + "bun-types": "^1.3.10", "eslint": "^10.0.0", "fast-check": "^4.5.3", "husky": "^9.1.7", @@ -116,9 +122,14 @@ "vitest": "^4.0.18" }, "dependencies": { - "@openauthjs/openauth": "^0.4.3", "@codex-ai/plugin": "file:vendor/codex-ai-plugin", + "@openauthjs/openauth": "^0.4.3", + "@opentui/core": "^0.1.87", + "@opentui/solid": "^0.1.87", "hono": "4.12.3", + "ink": "5.2.1", + "react": "18.3.1", + "solid-js": "^1.9.11", "zod": "^4.3.6" }, "overrides": { diff --git a/runtime/opentui/account-workspace.ts b/runtime/opentui/account-workspace.ts new file mode 100644 index 00000000..efc08241 --- /dev/null +++ b/runtime/opentui/account-workspace.ts @@ -0,0 +1,404 @@ +import type { SelectOption } from "@opentui/core"; +import { + buildAuthAccountDetailViewModel, + buildAuthDashboardViewModel, + type AuthAccountViewModel, + type AuthDashboardViewModel, +} from "../../lib/codex-manager/auth-ui-controller.js"; +import { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } from "../../lib/dashboard-settings.js"; + +const DEFAULT_STATUSLINE_FIELDS = ["last-used", "limits", "status"] as const; + +const ACCOUNT_ACTION_HOTKEYS = { + back: "Q", + toggle: "E", + "set-current": "S", + refresh: "R", + delete: "D", +} as const; + +export interface OpenTuiAccountDetailPanel { + eyebrow: string; + title: string; + subtitle: string; + metaLines: string[]; + actionLines: string[]; +} + +function sanitizeTerminalText(value: string | undefined): string | undefined { + if (!value) return undefined; + const ansiPattern = new RegExp("\\u001B\\[[0-?]*[ -/]*[@-~]", "g"); + const controlPattern = new RegExp("[\\u0000-\\u001F\\u007F]", "g"); + return value + .replace(ansiPattern, "") + .replace(controlPattern, "") + .trim(); +} + +function formatRelativeTime(timestamp: number | undefined): string { + if (!timestamp) return "never"; + const days = Math.floor((Date.now() - timestamp) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d`; + if (days < 30) return `${Math.floor(days / 7)}w`; + return new Date(timestamp).toLocaleDateString(); +} + +function formatDate(timestamp: number | undefined): string { + if (!timestamp) return "unknown"; + return new Date(timestamp).toLocaleDateString(); +} + +function formatResetTime(timestamp: number | undefined): string { + if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) return "unknown"; + const date = new Date(timestamp); + if (!Number.isFinite(date.getTime())) return "unknown"; + const time = date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + const now = new Date(); + const sameDay = + now.getFullYear() === date.getFullYear() && + now.getMonth() === date.getMonth() && + now.getDate() === date.getDate(); + return sameDay ? time : `${time} ${date.toLocaleDateString()}`; +} + +function formatLeftPercent(value: number | undefined): string | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return `${Math.max(0, Math.min(100, Math.round(value)))}%`; +} + +function formatCompactQuotaSummary(summary: string): string { + const parts = Array.from(summary.matchAll(/(\d+[hdm])\s+(\d{1,3})%/gi)).map((match) => + `${match[1]?.toLowerCase() ?? "quota"}${match[2] ?? ""}` + ); + if (/rate-limited/i.test(summary)) { + parts.push("limit"); + } + if (parts.length > 0) { + return Array.from(new Set(parts)).join(" "); + } + const sanitized = sanitizeTerminalText(summary); + if (!sanitized) return ""; + if (/\berror\b|failed|invalid|expired/i.test(sanitized)) return "error"; + return sanitized + .replace(/[(),:]/g, " ") + .replace(/\s+/g, " ") + .trim() + .split(" ") + .slice(0, 3) + .join(" "); +} + +function resolveAccountIdentity(account: AuthAccountViewModel): string { + const accountNumber = account.quickSwitchNumber ?? (account.index + 1); + return ( + sanitizeTerminalText(account.email) || + sanitizeTerminalText(account.accountLabel) || + sanitizeTerminalText(account.accountId) || + `Account ${accountNumber}` + ); +} + +function resolveStatusBadge(account: AuthAccountViewModel): string | null { + if (account.showStatusBadge === false || !account.status) return null; + switch (account.status) { + case "active": + return "[act]"; + case "rate-limited": + return "[limit]"; + case "cooldown": + return "[cool]"; + case "disabled": + return "[off]"; + case "error": + return "[err]"; + case "flagged": + return "[flag]"; + default: + return `[${account.status}]`; + } +} + +function resolveStatuslineFields(account: AuthAccountViewModel): readonly string[] { + return account.statuslineFields && account.statuslineFields.length > 0 + ? account.statuslineFields + : DEFAULT_STATUSLINE_FIELDS; +} + +function resolveAccountStateLine(account: AuthAccountViewModel): string { + const stateParts: string[] = [account.status ?? "unknown"]; + if (account.isCurrentAccount) stateParts.push("current"); + if (account.enabled === false) { + stateParts.push("paused"); + } + if (account.quotaRateLimited) stateParts.push("limited"); + return `State: ${stateParts.join(" | ")}`; +} + +function resolveQuotaDetailLines(account: AuthAccountViewModel): string[] { + const lines: string[] = []; + const quota5h = formatLeftPercent(account.quota5hLeftPercent); + const quota7d = formatLeftPercent(account.quota7dLeftPercent); + if (quota5h) { + lines.push(`5h left ${quota5h} @ ${formatResetTime(account.quota5hResetAtMs)}`); + } + if (quota7d) { + lines.push(`7d left ${quota7d} @ ${formatResetTime(account.quota7dResetAtMs)}`); + } + if (lines.length === 0 && account.quotaSummary) { + lines.push(`Limits: ${sanitizeTerminalText(account.quotaSummary) ?? account.quotaSummary}`); + } + return lines; +} + +function resolveAccountAlertLine(account: AuthAccountViewModel): string { + if (account.quotaRateLimited) return "Alert: rate-limited"; + switch (account.status) { + case "cooldown": + return "Alert: cooldown active"; + case "disabled": + return "Alert: excluded from rotation"; + case "error": + return "Alert: re-login recommended"; + case "flagged": + return "Alert: verify before reuse"; + case "active": + return "Alert: current selection"; + default: + return "Alert: ready"; + } +} + +function resolveAccountActionHint( + actionId: keyof typeof ACCOUNT_ACTION_HOTKEYS, +): string { + switch (actionId) { + case "back": + return `${ACCOUNT_ACTION_HOTKEYS.back} Back`; + case "toggle": + return `${ACCOUNT_ACTION_HOTKEYS.toggle} Toggle rotation`; + case "set-current": + return `${ACCOUNT_ACTION_HOTKEYS["set-current"]} Set current`; + case "refresh": + return `${ACCOUNT_ACTION_HOTKEYS.refresh} Re-login OAuth`; + case "delete": + return `${ACCOUNT_ACTION_HOTKEYS.delete} Delete (typed)`; + } +} + +function buildSummaryPartMap(account: AuthAccountViewModel): Map { + const parts = new Map(); + if (account.showLastUsed !== false) { + parts.set("last-used", formatRelativeTime(account.lastUsed)); + } + if (account.quotaSummary) { + parts.set("limits", formatCompactQuotaSummary(account.quotaSummary)); + } else if (account.showQuotaCooldown !== false && account.quotaRateLimited) { + parts.set("limits", "limit"); + } + if (account.showStatusBadge === false && account.status) { + parts.set("status", `status ${account.status}`); + } + return parts; +} + +function buildAccountSummary(account: AuthAccountViewModel): string { + const partMap = buildSummaryPartMap(account); + const orderedParts = resolveStatuslineFields(account) + .map((field) => partMap.get(field)) + .filter((part): part is string => typeof part === "string" && part.length > 0); + return orderedParts.join(" "); +} + +export function resolveOpenTuiAccountSourceIndex(account: AuthAccountViewModel): number { + if (typeof account.sourceIndex === "number" && Number.isFinite(account.sourceIndex)) { + return Math.max(0, Math.floor(account.sourceIndex)); + } + if (typeof account.index === "number" && Number.isFinite(account.index)) { + return Math.max(0, Math.floor(account.index)); + } + return -1; +} + +export function formatOpenTuiAccountRow(account: AuthAccountViewModel): string { + const accountNumber = account.quickSwitchNumber ?? (account.index + 1); + const head = [`${accountNumber}.`]; + if (account.showCurrentBadge !== false && account.isCurrentAccount) { + head.push("*"); + } + head.push(resolveAccountIdentity(account)); + const statusBadge = resolveStatusBadge(account); + if (statusBadge) { + head.push(statusBadge); + } + const summary = buildAccountSummary(account); + return summary.length > 0 ? `${head.join(" ")} ${summary}` : head.join(" "); +} + +export function filterOpenTuiDashboardAccounts( + dashboard: AuthDashboardViewModel, + searchQuery: string, +): AuthAccountViewModel[] { + const normalized = searchQuery.trim().toLowerCase(); + if (normalized.length === 0) return dashboard.accounts; + return dashboard.accounts.filter((account) => { + const candidates = [ + account.email, + account.accountLabel, + account.accountId, + String(account.quickSwitchNumber ?? (account.index + 1)), + ]; + return candidates.some((candidate) => + typeof candidate === "string" && candidate.toLowerCase().includes(normalized) + ); + }); +} + +function resolveVisibleQuickSwitchMap(accounts: AuthAccountViewModel[]): { + byNumber: Map; + duplicates: Set; +} { + const byNumber = new Map(); + const duplicates = new Set(); + for (const account of accounts) { + const quickSwitchNumber = account.quickSwitchNumber ?? (account.index + 1); + if (byNumber.has(quickSwitchNumber)) { + duplicates.add(quickSwitchNumber); + continue; + } + byNumber.set(quickSwitchNumber, account); + } + return { byNumber, duplicates }; +} + +export function resolveOpenTuiQuickSwitchAccount( + dashboard: AuthDashboardViewModel, + searchQuery: string, + raw: string, +): AuthAccountViewModel | null { + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 9) return null; + const visibleAccounts = filterOpenTuiDashboardAccounts(dashboard, searchQuery); + const { byNumber, duplicates } = resolveVisibleQuickSwitchMap(visibleAccounts); + if (duplicates.has(parsed)) return null; + return byNumber.get(parsed) ?? null; +} + +export function buildOpenTuiAccountOptions(accounts: AuthAccountViewModel[]): SelectOption[] { + return accounts.map((account) => ({ + name: formatOpenTuiAccountRow(account), + description: "", + })); +} + +export function buildOpenTuiAccountDetailPanel(account: AuthAccountViewModel): OpenTuiAccountDetailPanel { + const detail = buildAuthAccountDetailViewModel(account); + const metaLines = [ + resolveAccountStateLine(account), + `Added: ${formatDate(account.addedAt)}`, + `Used: ${formatRelativeTime(account.lastUsed)}`, + ...resolveQuotaDetailLines(account), + resolveAccountAlertLine(account), + ].filter((line) => line.trim().length > 0); + return { + eyebrow: "focus / account", + title: detail.title, + subtitle: sanitizeTerminalText(account.accountId) + ? `ID ${sanitizeTerminalText(account.accountId)}` + : resolveAccountIdentity(account), + metaLines, + actionLines: detail.actions.map((action) => resolveAccountActionHint(action.id)), + }; +} + +export function resolveOpenTuiDashboardStatus(dashboard: AuthDashboardViewModel): string | undefined { + const raw = dashboard.menuOptions.statusMessage; + if (typeof raw === "function") { + const resolved = raw(); + return typeof resolved === "string" && resolved.trim().length > 0 ? resolved.trim() : undefined; + } + return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined; +} + +export function createDefaultOpenTuiDashboard(): AuthDashboardViewModel { + const now = Date.now(); + return buildAuthDashboardViewModel({ + storage: { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "acc_alpha", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + addedAt: now - 400_000, + lastUsed: now - 86_400_000, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "acc_beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 200_000, + lastUsed: now - 1_000, + enabled: true, + }, + { + email: "gamma@example.com", + accountId: "acc_gamma", + refreshToken: "refresh-gamma", + accessToken: "access-gamma", + expiresAt: now + 3_600_000, + addedAt: now - 100_000, + lastUsed: now - 172_800_000, + enabled: true, + }, + ], + }, + quotaCache: { + byAccountId: {}, + byEmail: { + "alpha@example.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 80, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 70, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + "beta@example.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 0, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 0, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + "gamma@example.com": { + updatedAt: now, + status: 429, + model: "gpt-5-codex", + primary: { usedPercent: 50, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 20, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + }, + }, + displaySettings: { + ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + flaggedCount: 1, + statusMessage: "Loading live limits...", + }); +} diff --git a/runtime/opentui/app.ts b/runtime/opentui/app.ts new file mode 100644 index 00000000..8f2ed1a4 --- /dev/null +++ b/runtime/opentui/app.ts @@ -0,0 +1,2101 @@ +import { + BoxRenderable, + SelectRenderable, + SelectRenderableEvents, + TextRenderable, + type CliRenderer, + type KeyEvent, + type SelectOption, + type Selection, +} from "@opentui/core"; +import { useRenderer } from "@opentui/solid"; +import { createEffect, createSignal, onCleanup } from "solid-js"; +import type { AuthDashboardViewModel } from "../../lib/codex-manager/auth-ui-controller.js"; +import { + ACCENT_COLOR_OPTIONS, + AUTO_RETURN_OPTIONS_MS, + BACKEND_CATEGORY_OPTIONS, + BACKEND_NUMBER_OPTIONS, + BACKEND_TOGGLE_OPTIONS, + DASHBOARD_DISPLAY_OPTIONS, + MENU_QUOTA_TTL_OPTIONS_MS, + STATUSLINE_FIELD_OPTIONS, + THEME_PRESET_OPTIONS, + buildSettingsHubViewModel, + resolveSettingsHubCommand, + type BackendCategoryKey, + type BackendNumberSettingKey, + type BackendToggleSettingKey, + type SettingsHubAction, +} from "../../lib/codex-manager/settings-ui-controller.js"; +import { + applyDashboardDefaultsForKeys, + applyUiThemeFromDashboardSettings, + clampBackendNumber, + cloneBackendPluginConfig, + cloneDashboardSettings, + formatBackendNumberValue, + formatDashboardSettingState, + formatMenuLayoutMode, + formatMenuQuotaTtl, + formatMenuSortMode, + normalizeStatuslineFields, + resolveMenuLayoutMode, +} from "../../lib/codex-manager/settings-hub.js"; +import { + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + type DashboardAccentColor, + type DashboardAccountSortMode, + type DashboardDisplaySettings, + type DashboardLayoutMode, + type DashboardStatuslineField, + type DashboardThemePreset, +} from "../../lib/dashboard-settings.js"; +import { getDefaultPluginConfig } from "../../lib/config.js"; +import type { PluginConfig } from "../../lib/types.js"; +import { UI_COPY } from "../../lib/ui/copy.js"; +import { + buildOpenTuiAccountDetailPanel, + buildOpenTuiAccountOptions, + createDefaultOpenTuiDashboard, + filterOpenTuiDashboardAccounts, + resolveOpenTuiAccountSourceIndex, + resolveOpenTuiDashboardStatus, + resolveOpenTuiQuickSwitchAccount, +} from "./account-workspace.js"; + +const SHELL_TOKENS = { + background: "#08111b", + panelBackground: "#0d1824", + border: "#223244", + text: "#d8e4f0", + muted: "#75859a", + accent: "#7dd3fc", + focusBackground: "#14314a", + focusText: "#f8fbff", + selectedBackground: "#102537", + selectedText: "#a8dafb", + statusBackground: "#09131e", + statusText: "#9db2c7", + success: "#8ee59b", + warning: "#f5cb5c", + danger: "#f38ba8", +} as const; + +const DEFAULT_NAV_OPTIONS: SelectOption[] = [ + { name: "Accounts", description: "Saved accounts workspace" }, + { name: "Add", description: "Launch OAuth sign-in" }, + { name: "Check", description: "Run live account check" }, + { name: "Settings", description: "Open settings flow" }, + { name: "Forecast", description: "Preview best next account" }, + { name: "Fix", description: "Run safe repair flow" }, + { name: "Verify", description: "Review flagged accounts" }, + { name: "Deep Check", description: "Force refresh validation" }, + { name: "Help", description: "Keyboard reference" }, +]; + +type OpenTuiWorkspacePanel = { + eyebrow: string; + title: string; + subtitle: string; + hint: string; + statusLabel: string; + options: SelectOption[]; +}; + +type OpenTuiDetailContent = { + eyebrow: string; + title: string; + subtitle: string; + metaLines: string[]; + actionLines: string[]; + tone: "text" | "success" | "warning" | "danger"; +}; + +type OpenTuiShellLayoutDensity = "default" | "compact"; + +type OpenTuiShellLayout = { + density: OpenTuiShellLayoutDensity; + navWidth: number; + workspacePaddingLeft: number; + workspaceGapWidth: number; + detailWidth: number; + detailPaddingLeft: number; + hideEyebrows: boolean; + detailMetaLimit: number; + detailActionLimit: number; +}; + +const COMPACT_SHELL_MAX_WIDTH = 72; + +function resolveShellLayout(width: number): OpenTuiShellLayout { + if (width <= COMPACT_SHELL_MAX_WIDTH) { + return { + density: "compact", + navWidth: 16, + workspacePaddingLeft: 1, + workspaceGapWidth: 1, + detailWidth: 24, + detailPaddingLeft: 1, + hideEyebrows: true, + detailMetaLimit: 3, + detailActionLimit: 4, + }; + } + + return { + density: "default", + navWidth: 18, + workspacePaddingLeft: 2, + workspaceGapWidth: 2, + detailWidth: 27, + detailPaddingLeft: 2, + hideEyebrows: false, + detailMetaLimit: 7, + detailActionLimit: 5, + }; +} + +const OVERLAY_HOST_OPTIONS: Record = { + Add: { + eyebrow: "workflow / add", + title: "Add account", + subtitle: "Launch a new OAuth sign-in without leaving the auth shell.", + hint: "Press Enter to continue or Left to choose a different route.", + statusLabel: "action-ready", + options: [ + { name: "Open the browser-first login flow", description: "Adds another saved account" }, + ], + }, + Check: { + eyebrow: "workflow / check", + title: "Quick health check", + subtitle: "Run the live session check for the current saved pool.", + hint: "Press Enter to run the check command.", + statusLabel: "action-ready", + options: [ + { name: "Live probe + refresh fallback", description: "Same result contract as the CLI command" }, + ], + }, + Forecast: { + eyebrow: "workflow / forecast", + title: "Forecast next account", + subtitle: "Preview readiness and recommendation signals before switching.", + hint: "Press Enter to run the forecast panel.", + statusLabel: "action-ready", + options: [ + { name: "Best-account preview", description: "Uses the shared forecast controller" }, + ], + }, + Fix: { + eyebrow: "workflow / fix", + title: "Repair account pool", + subtitle: "Run the safe fix flow without leaving the shell entrypoint.", + hint: "Press Enter to apply the existing fix command path.", + statusLabel: "action-ready", + options: [ + { name: "Safe repair flow", description: "Disables hard failures and keeps deterministic output" }, + ], + }, + Verify: { + eyebrow: "workflow / verify", + title: "Verify flagged accounts", + subtitle: "Review recoverable flagged accounts before restoring them.", + hint: "Press Enter to run the flagged-account verification flow.", + statusLabel: "action-ready", + options: [ + { name: "Flagged account verification", description: "Shared restore-safe flow" }, + ], + }, + "Deep Check": { + eyebrow: "workflow / deep-check", + title: "Deep refresh validation", + subtitle: "Force refresh testing for every account in the current pool.", + hint: "Press Enter to run the deep-check command path.", + statusLabel: "action-ready", + options: [ + { name: "Full refresh test", description: "Confirms refresh-token health across the pool" }, + ], + }, + Help: { + eyebrow: "overlay / help", + title: "Keyboard reference", + subtitle: "The auth shell routes commands and account actions without leaving the workspace.", + hint: "Use the rail for add/check/forecast/fix/settings and the account pane for quick actions.", + statusLabel: "overlay-ready", + options: [ + { name: "Tab or Left/Right switches panes", description: "Focus remains deterministic" }, + { name: "/ searches visible rows and 1-9 quick switches", description: "Filtered rows keep source-index mapping" }, + { name: "S/R/E/D act on the selected account", description: "Current, re-login, toggle, delete" }, + { name: "Q or Esc exits cleanly", description: "Renderer teardown stays owned by the shell" }, + ], + }, + Settings: { + eyebrow: "workflow / settings", + title: "Settings entry", + subtitle: "Route into the shared interactive settings flow from the OpenTUI shell.", + hint: "Press Enter to continue or Right if you want to inspect the drawer host.", + statusLabel: "action-ready", + options: [ + { name: "Open shared settings flow", description: "Preserves save, reset, and cancel contracts" }, + { name: "Drawer host stays mounted", description: "The shell still owns settings layout state" }, + ], + }, +}; + +export type OpenTuiShellTimer = unknown; + +export type OpenTuiShellClock = { + setInterval: (callback: () => void, intervalMs: number) => OpenTuiShellTimer; + clearInterval: (timer: OpenTuiShellTimer) => void; +}; + +export type OpenTuiShellExitReason = "escape" | "quit"; + +export type OpenTuiShellFocusTarget = "nav" | "workspace"; + +export type OpenTuiShellSelection = { + navIndex: number; + navLabel: string; + accountIndex: number; + accountLabel: string; + focusTarget: OpenTuiShellFocusTarget; +}; + +export type OpenTuiShellReadyContext = { + renderer: CliRenderer; + rootRef: BoxRenderable; + navRef: SelectRenderable; + accountListRef: SelectRenderable; + statusLineRef: TextRenderable; + modalHostRef: BoxRenderable; + focusedRenderable: SelectRenderable | null; + focusTarget: OpenTuiShellFocusTarget; +}; + +export type OpenTuiWorkspaceAction = + | { type: "quick-switch"; sourceIndex: number } + | { type: "search"; active: boolean; query: string }; + +export type OpenTuiBootstrapAppProps = { + clock?: OpenTuiShellClock; + dashboard?: AuthDashboardViewModel; + navOptions?: SelectOption[]; + onExit?: (reason: OpenTuiShellExitReason, renderer: CliRenderer) => void; + onKeyPress?: (keyEvent: KeyEvent) => void; + onReady?: (context: OpenTuiShellReadyContext) => void; + onRendererSelection?: (selection: Selection) => void; + onSelectionChange?: (selection: OpenTuiShellSelection) => void; + onSettingsSave?: (event: OpenTuiSettingsSaveEvent) => void; + onWorkspaceAction?: (action: OpenTuiWorkspaceAction) => void; +}; + +const defaultClock: OpenTuiShellClock = { + setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs), + clearInterval: (timer) => { + globalThis.clearInterval(timer as ReturnType); + }, +}; + +function createWorkspacePanel(navLabel: string): OpenTuiWorkspacePanel { + if (navLabel === "Accounts") { + return { + eyebrow: "workspace / accounts", + title: "Account workspace", + subtitle: "Compact one-line rows with search and visible-row quick switch.", + hint: "/ search | 1-9 quick switch | Enter reserves the detail seam", + statusLabel: "live", + options: [], + }; + } + + return OVERLAY_HOST_OPTIONS[navLabel] ?? { + eyebrow: "workspace", + title: navLabel, + subtitle: "Placeholder workspace", + hint: "Reserved shell route.", + statusLabel: "idle", + options: [], + }; +} + +function createSelectionSnapshot( + navOptions: SelectOption[], + workspaceOptions: SelectOption[], + navIndex: number, + accountIndex: number, + focusTarget: OpenTuiShellFocusTarget, +): OpenTuiShellSelection { + return { + navIndex, + navLabel: navOptions[navIndex]?.name ?? "", + accountIndex, + accountLabel: workspaceOptions[accountIndex]?.name ?? "", + focusTarget, + }; +} + +function clampSelection(index: number, optionCount: number): number { + if (optionCount <= 0) { + return 0; + } + + return Math.max(0, Math.min(index, optionCount - 1)); +} + +function createStatusLine(selection: { + navLabel: string; + focusTarget: OpenTuiShellFocusTarget; + uptimeSeconds: number; + statusLabel: string; + accountLabel?: string; + searchMode?: boolean; + searchQuery?: string; + visibleAccountCount?: number; + totalAccountCount?: number; +}): string { + const focusedPaneLabel = selection.focusTarget === "workspace" ? "workspace" : "rail"; + if (selection.navLabel === "Accounts") { + const searchState = selection.searchMode + ? `search ${selection.searchQuery && selection.searchQuery.length > 0 ? selection.searchQuery : "_"}` + : selection.searchQuery && selection.searchQuery.length > 0 + ? `filter ${selection.searchQuery}` + : "/ search"; + return `accounts | focus ${focusedPaneLabel} | rows ${selection.visibleAccountCount ?? 0}/${selection.totalAccountCount ?? 0} | ${searchState} | 1-9 switch | s/r/e/d selected | tab routes | q quit`; + } + const activeRow = selection.accountLabel && selection.accountLabel.length > 0 ? selection.accountLabel : "no row"; + return `${selection.navLabel.toLowerCase()} | focus ${focusedPaneLabel} | ${selection.statusLabel} ${selection.uptimeSeconds}s | ${activeRow} | tab switch pane | q quit`; +} + +function resolveDetailTone(statusLabel: string): OpenTuiDetailContent["tone"] { + if (statusLabel.includes("danger") || statusLabel.includes("flag") || statusLabel.includes("error")) { + return "danger"; + } + if (statusLabel.includes("warning") || statusLabel.includes("limit") || statusLabel.includes("cool")) { + return "warning"; + } + if (statusLabel.includes("active") || statusLabel.includes("ready") || statusLabel.includes("live")) { + return "success"; + } + return "text"; +} + +function resolveDetailToneColor(tone: OpenTuiDetailContent["tone"]): string { + switch (tone) { + case "success": + return SHELL_TOKENS.success; + case "warning": + return SHELL_TOKENS.warning; + case "danger": + return SHELL_TOKENS.danger; + default: + return SHELL_TOKENS.text; + } +} + +function createPanelDetailContent(panel: OpenTuiWorkspacePanel): OpenTuiDetailContent { + return { + eyebrow: panel.eyebrow, + title: panel.title, + subtitle: panel.statusLabel, + metaLines: [panel.subtitle, panel.hint], + actionLines: panel.options.map((option) => option.name), + tone: panel.statusLabel.includes("ready") ? "warning" : "text", + }; +} + +type OpenTuiDashboardPanelId = "account-list" | "summary-fields" | "behavior" | "theme"; + +export type OpenTuiSettingsSaveEvent = + | { + kind: "dashboard"; + panel: OpenTuiDashboardPanelId; + selected: DashboardDisplaySettings; + } + | { + kind: "backend"; + selected: PluginConfig; + }; + +type OpenTuiDrawerEntry = { + id: string; + label: string; +}; + +type OpenTuiDrawerState = + | { type: "closed" } + | { type: "hub"; cursor: number } + | { + type: "dashboard-panel"; + panel: OpenTuiDashboardPanelId; + cursor: number; + draft: DashboardDisplaySettings; + hubCursor: number; + themeBaseline: DashboardDisplaySettings; + } + | { + type: "backend-hub"; + cursor: number; + draft: PluginConfig; + baseline: PluginConfig; + hubCursor: number; + } + | { + type: "backend-category"; + categoryKey: BackendCategoryKey; + cursor: number; + draft: PluginConfig; + baseline: PluginConfig; + hubCursor: number; + backendCursor: number; + }; + +type OpenTuiDrawerView = { + title: string; + subtitle: string; + lines: string[]; + footer: string; +}; + +const BACKEND_DEFAULTS = getDefaultPluginConfig(); +const BACKEND_CATEGORY_OPTION_BY_KEY = new Map( + BACKEND_CATEGORY_OPTIONS.map((category) => [category.key, category]), +); +const BACKEND_NUMBER_OPTION_BY_KEY = new Map( + BACKEND_NUMBER_OPTIONS.map((option) => [option.key, option]), +); +const SETTINGS_HUB_VIEW_MODEL = buildSettingsHubViewModel(); +const SETTINGS_HUB_ACTIONS = SETTINGS_HUB_VIEW_MODEL.sections.flatMap((section) => section.actions); + +function wrapCursor(current: number, direction: -1 | 1, count: number): number { + if (count <= 0) return 0; + return (current + direction + count) % count; +} + +function isEnterKey(name: string | undefined): boolean { + return name === "enter" || name === "return"; +} + +function formatDrawerEntry(entry: OpenTuiDrawerEntry, index: number, cursor: number): string { + const prefix = index === cursor ? ">" : " "; + return `${prefix} ${entry.label}`; +} + +function buildSettingsHubLines(cursor: number): string[] { + return [ + "Customize menu, behavior, and", + "backend", + ...SETTINGS_HUB_ACTIONS.map((action, entryIndex) => { + const labelIndex = entryIndex < 5 ? `${entryIndex + 1}. ` : ""; + return `${entryIndex === cursor ? ">" : " "} ${labelIndex}${action.label}`; + }), + ]; +} + +function buildAccountListDrawerEntries(draft: DashboardDisplaySettings): OpenTuiDrawerEntry[] { + const sortMode = draft.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); + const layoutMode = resolveMenuLayoutMode(draft); + return [ + ...DASHBOARD_DISPLAY_OPTIONS.map((option, index) => ({ + id: option.key, + label: `${formatDashboardSettingState(draft[option.key] ?? true)} ${index + 1}. ${option.label}`, + })), + { id: "sort-mode", label: `Sort mode: ${formatMenuSortMode(sortMode)}` }, + { id: "layout-mode", label: `Layout: ${formatMenuLayoutMode(layoutMode)}` }, + { id: "reset", label: UI_COPY.settings.resetDefault }, + { id: "save", label: UI_COPY.settings.saveAndBack }, + { id: "cancel", label: UI_COPY.settings.backNoSave }, + ]; +} + +function buildSummaryDrawerEntries(draft: DashboardDisplaySettings): OpenTuiDrawerEntry[] { + const selectedSet = new Set(normalizeStatuslineFields(draft.menuStatuslineFields)); + const ordered = normalizeStatuslineFields(draft.menuStatuslineFields); + const orderMap = new Map(); + for (let index = 0; index < ordered.length; index += 1) { + const key = ordered[index]; + if (key) orderMap.set(key, index + 1); + } + return [ + ...STATUSLINE_FIELD_OPTIONS.map((option, index) => ({ + id: option.key, + label: `${formatDashboardSettingState(selectedSet.has(option.key))} ${index + 1}. ${option.label}${orderMap.get(option.key) ? ` (order ${orderMap.get(option.key)})` : ""}`, + })), + { id: "reset", label: UI_COPY.settings.resetDefault }, + { id: "save", label: UI_COPY.settings.saveAndBack }, + { id: "cancel", label: UI_COPY.settings.backNoSave }, + ]; +} + +function buildBehaviorDrawerEntries(draft: DashboardDisplaySettings): OpenTuiDrawerEntry[] { + const currentDelay = draft.actionAutoReturnMs ?? 2_000; + const pauseOnKey = draft.actionPauseOnKey ?? true; + const autoFetchLimits = draft.menuAutoFetchLimits ?? true; + const fetchStatusVisible = draft.menuShowFetchStatus ?? true; + const menuQuotaTtlMs = draft.menuQuotaTtlMs ?? 5 * 60_000; + return [ + ...AUTO_RETURN_OPTIONS_MS.map((delayMs, index) => ({ + id: `delay:${delayMs}`, + label: `${currentDelay === delayMs ? "[x]" : "[ ]"} ${index + 1}. ${delayMs <= 0 ? "Instant return" : `${Math.round(delayMs / 1_000)}s auto-return`}`, + })), + { id: "pause", label: `${pauseOnKey ? "[x]" : "[ ]"} Pause on key press` }, + { + id: "auto-fetch", + label: `${autoFetchLimits ? "[x]" : "[ ]"} Auto-fetch limits on menu open (${formatMenuQuotaTtl(menuQuotaTtlMs)} cache)`, + }, + { id: "fetch-status", label: `${fetchStatusVisible ? "[x]" : "[ ]"} Show limit refresh status` }, + { id: "ttl", label: `Limit cache TTL: ${formatMenuQuotaTtl(menuQuotaTtlMs)}` }, + { id: "reset", label: UI_COPY.settings.resetDefault }, + { id: "save", label: UI_COPY.settings.saveAndBack }, + { id: "cancel", label: UI_COPY.settings.backNoSave }, + ]; +} + +function buildThemeDrawerEntries(draft: DashboardDisplaySettings): OpenTuiDrawerEntry[] { + return [ + ...THEME_PRESET_OPTIONS.map((candidate, index) => ({ + id: `palette:${candidate}`, + label: `${draft.uiThemePreset === candidate ? "[x]" : "[ ]"} ${index + 1}. ${candidate === "green" ? "Green base" : "Blue base"}`, + })), + ...ACCENT_COLOR_OPTIONS.map((candidate) => ({ + id: `accent:${candidate}`, + label: `${draft.uiAccentColor === candidate ? "[x]" : "[ ]"} ${candidate}`, + })), + { id: "reset", label: UI_COPY.settings.resetDefault }, + { id: "save", label: UI_COPY.settings.saveAndBack }, + { id: "cancel", label: UI_COPY.settings.backNoSave }, + ]; +} + +function buildBackendHubEntries(): OpenTuiDrawerEntry[] { + return [ + ...BACKEND_CATEGORY_OPTIONS.map((category, index) => ({ + id: `category:${category.key}`, + label: `${index + 1}. ${category.label}`, + })), + { id: "reset", label: UI_COPY.settings.resetDefault }, + { id: "save", label: UI_COPY.settings.saveAndBack }, + { id: "cancel", label: UI_COPY.settings.backNoSave }, + ]; +} + +function buildBackendCategoryEntries(categoryKey: BackendCategoryKey, draft: PluginConfig): OpenTuiDrawerEntry[] { + const category = BACKEND_CATEGORY_OPTION_BY_KEY.get(categoryKey); + if (!category) return []; + const toggleEntries = category.toggleKeys.map((key, index) => { + const option = BACKEND_TOGGLE_OPTIONS.find((candidate) => candidate.key === key); + const enabled = draft[key] ?? BACKEND_DEFAULTS[key] ?? false; + return { + id: `toggle:${key}`, + label: `${formatDashboardSettingState(enabled)} ${index + 1}. ${option?.label ?? key}`, + }; + }); + const numberEntries = category.numberKeys.flatMap((key) => { + const option = BACKEND_NUMBER_OPTION_BY_KEY.get(key); + if (!option) return []; + const rawValue = draft[key] ?? BACKEND_DEFAULTS[key] ?? option.min; + const numericValue = typeof rawValue === "number" && Number.isFinite(rawValue) ? rawValue : option.min; + return [{ + id: `number:${key}`, + label: `${option.label}: ${formatBackendNumberValue(option, clampBackendNumber(option, numericValue))}`, + }]; + }); + return [ + ...toggleEntries, + ...numberEntries, + { id: "reset", label: UI_COPY.settings.backendResetCategory }, + { id: "back", label: UI_COPY.settings.backendBackToCategories }, + ]; +} + +function cycleSortMode(draft: DashboardDisplaySettings): DashboardDisplaySettings { + const currentMode = draft.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); + const nextMode: DashboardAccountSortMode = currentMode === "ready-first" ? "manual" : "ready-first"; + return { + ...draft, + menuSortMode: nextMode, + menuSortEnabled: nextMode === "ready-first" + ? true + : (draft.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true)), + }; +} + +function cycleLayoutMode(draft: DashboardDisplaySettings): DashboardDisplaySettings { + const currentLayout = resolveMenuLayoutMode(draft); + const nextLayout: DashboardLayoutMode = currentLayout === "compact-details" + ? "expanded-rows" + : "compact-details"; + return { + ...draft, + menuLayoutMode: nextLayout, + menuShowDetailsForUnselectedRows: nextLayout === "expanded-rows", + }; +} + +function reorderStatuslineField( + draft: DashboardDisplaySettings, + field: DashboardStatuslineField, + direction: -1 | 1, +): DashboardDisplaySettings { + const fields = [...normalizeStatuslineFields(draft.menuStatuslineFields)]; + const index = fields.indexOf(field); + const target = index + direction; + if (index < 0 || target < 0 || target >= fields.length) return draft; + const current = fields[index]; + const swap = fields[target]; + if (!current || !swap) return draft; + fields[index] = swap; + fields[target] = current; + return { + ...draft, + menuStatuslineFields: fields, + }; +} + +function applyBackendCategoryDefaults(draft: PluginConfig, categoryKey: BackendCategoryKey): PluginConfig { + const category = BACKEND_CATEGORY_OPTION_BY_KEY.get(categoryKey); + if (!category) return cloneBackendPluginConfig(draft); + const next = cloneBackendPluginConfig(draft); + for (const key of category.toggleKeys) { + next[key] = BACKEND_DEFAULTS[key] ?? false; + } + for (const key of category.numberKeys) { + const option = BACKEND_NUMBER_OPTION_BY_KEY.get(key); + next[key] = BACKEND_DEFAULTS[key] ?? option?.min ?? 0; + } + return next; +} + +function buildDrawerView(state: OpenTuiDrawerState): OpenTuiDrawerView { + if (state.type === "closed") { + return { + title: "Overlay host", + subtitle: "Hidden until search/help/settings attach.", + lines: [], + footer: "", + }; + } + if (state.type === "hub") { + return { + title: "Settings host", + subtitle: "", + lines: buildSettingsHubLines(state.cursor), + footer: UI_COPY.settings.help, + }; + } + if (state.type === "dashboard-panel") { + const entries = state.panel === "account-list" + ? buildAccountListDrawerEntries(state.draft) + : state.panel === "summary-fields" + ? buildSummaryDrawerEntries(state.draft) + : state.panel === "behavior" + ? buildBehaviorDrawerEntries(state.draft) + : buildThemeDrawerEntries(state.draft); + return { + title: state.panel === "account-list" + ? UI_COPY.settings.accountListTitle + : state.panel === "summary-fields" + ? UI_COPY.settings.summaryTitle + : state.panel === "behavior" + ? UI_COPY.settings.behaviorTitle + : UI_COPY.settings.themeTitle, + subtitle: state.panel === "account-list" + ? UI_COPY.settings.accountListSubtitle + : state.panel === "summary-fields" + ? UI_COPY.settings.summarySubtitle + : state.panel === "behavior" + ? UI_COPY.settings.behaviorSubtitle + : UI_COPY.settings.themeSubtitle, + lines: entries.map((entry, index) => formatDrawerEntry(entry, index, state.cursor)), + footer: state.panel === "account-list" + ? UI_COPY.settings.accountListHelp + : state.panel === "summary-fields" + ? UI_COPY.settings.summaryHelp + : state.panel === "behavior" + ? UI_COPY.settings.behaviorHelp + : UI_COPY.settings.themeHelp, + }; + } + if (state.type === "backend-hub") { + const entries = buildBackendHubEntries(); + return { + title: UI_COPY.settings.backendTitle, + subtitle: UI_COPY.settings.backendSubtitle, + lines: entries.map((entry, index) => formatDrawerEntry(entry, index, state.cursor)), + footer: UI_COPY.settings.backendHelp, + }; + } + const category = BACKEND_CATEGORY_OPTION_BY_KEY.get(state.categoryKey); + const entries = buildBackendCategoryEntries(state.categoryKey, state.draft); + return { + title: `${UI_COPY.settings.backendCategoryTitle}: ${category?.label ?? state.categoryKey}`, + subtitle: category?.description ?? UI_COPY.settings.backendSubtitle, + lines: entries.map((entry, index) => formatDrawerEntry(entry, index, state.cursor)), + footer: UI_COPY.settings.backendCategoryHelp, + }; +} + +export const OpenTuiBootstrapApp = (props: OpenTuiBootstrapAppProps = {}) => { + const renderer = useRenderer(); + const clock = props.clock ?? defaultClock; + const dashboard = props.dashboard ?? createDefaultOpenTuiDashboard(); + const navOptions = props.navOptions ?? DEFAULT_NAV_OPTIONS; + const initialPanel = createWorkspacePanel(navOptions[0]?.name ?? "Accounts"); + let activeLayout = resolveShellLayout(renderer.width); + + const [uptimeSeconds, setUptimeSeconds] = createSignal(0); + const [navIndex, setNavIndex] = createSignal(0); + const [accountIndex, setAccountIndex] = createSignal(0); + const [focusTarget, setFocusTarget] = createSignal("workspace"); + const [searchQuery, setSearchQuery] = createSignal(""); + const [searchMode, setSearchMode] = createSignal(false); + const [visibleAccountCount, setVisibleAccountCount] = createSignal(dashboard.accounts.length); + const [savedDashboardSettings, setSavedDashboardSettings] = createSignal( + cloneDashboardSettings(DEFAULT_DASHBOARD_DISPLAY_SETTINGS), + ); + const [savedBackendConfig, setSavedBackendConfig] = createSignal( + cloneBackendPluginConfig(getDefaultPluginConfig()), + ); + const [drawerState, setDrawerState] = createSignal({ type: "closed" }); + applyUiThemeFromDashboardSettings(savedDashboardSettings()); + + let activeWorkspaceOptions = initialPanel.options; + let activeVisibleAccounts = filterOpenTuiDashboardAccounts(dashboard, searchQuery()); + let activeStatusLabel = initialPanel.statusLabel; + + const totalAccountCount = dashboard.accounts.length; + + const root = new BoxRenderable(renderer, { + backgroundColor: SHELL_TOKENS.background, + flexDirection: "column", + height: "100%", + width: "100%", + }); + + const shellBody = new BoxRenderable(renderer, { + backgroundColor: SHELL_TOKENS.background, + flexDirection: "row", + flexGrow: 1, + width: "100%", + }); + + const navRail = new BoxRenderable(renderer, { + backgroundColor: SHELL_TOKENS.background, + border: ["right"], + borderColor: SHELL_TOKENS.border, + flexDirection: "column", + paddingBottom: 1, + paddingLeft: 1, + paddingRight: 1, + paddingTop: 1, + width: 18, + }); + + const navBrand = new TextRenderable(renderer, { + content: "codex auth", + fg: SHELL_TOKENS.text, + truncate: true, + }); + const navMeta = new TextRenderable(renderer, { + content: "fast shell", + fg: SHELL_TOKENS.muted, + truncate: true, + }); + const navRef = new SelectRenderable(renderer, { + backgroundColor: SHELL_TOKENS.background, + descriptionColor: SHELL_TOKENS.muted, + focusedBackgroundColor: SHELL_TOKENS.background, + focusedTextColor: SHELL_TOKENS.focusText, + itemSpacing: 0, + options: navOptions, + selectedBackgroundColor: SHELL_TOKENS.selectedBackground, + selectedDescriptionColor: SHELL_TOKENS.muted, + selectedIndex: navIndex(), + selectedTextColor: SHELL_TOKENS.selectedText, + showDescription: false, + textColor: SHELL_TOKENS.muted, + width: "100%", + wrapSelection: true, + }); + + const workspace = new BoxRenderable(renderer, { + backgroundColor: SHELL_TOKENS.panelBackground, + flexDirection: "column", + flexGrow: 1, + paddingBottom: 1, + paddingLeft: 2, + paddingRight: 1, + paddingTop: 1, + }); + + const workspaceEyebrow = new TextRenderable(renderer, { + content: initialPanel.eyebrow, + fg: SHELL_TOKENS.accent, + truncate: true, + }); + const workspaceTitle = new TextRenderable(renderer, { + content: initialPanel.title, + fg: SHELL_TOKENS.text, + truncate: true, + }); + const workspaceSubtitle = new TextRenderable(renderer, { + content: initialPanel.subtitle, + fg: SHELL_TOKENS.muted, + truncate: true, + }); + const workspaceHint = new TextRenderable(renderer, { + content: initialPanel.hint, + fg: SHELL_TOKENS.muted, + truncate: true, + }); + const workspaceBody = new BoxRenderable(renderer, { + flexDirection: "row", + flexGrow: 1, + width: "100%", + }); + const workspaceListPane = new BoxRenderable(renderer, { + flexDirection: "column", + flexGrow: 1, + paddingRight: 1, + }); + const workspacePaneGap = new BoxRenderable(renderer, { + width: 2, + }); + const accountListRef = new SelectRenderable(renderer, { + backgroundColor: SHELL_TOKENS.panelBackground, + descriptionColor: SHELL_TOKENS.muted, + flexGrow: 1, + focusedBackgroundColor: SHELL_TOKENS.panelBackground, + focusedTextColor: SHELL_TOKENS.focusText, + itemSpacing: 0, + options: activeWorkspaceOptions, + selectedBackgroundColor: SHELL_TOKENS.focusBackground, + selectedDescriptionColor: SHELL_TOKENS.muted, + selectedIndex: accountIndex(), + selectedTextColor: SHELL_TOKENS.selectedText, + showDescription: false, + textColor: SHELL_TOKENS.text, + width: "100%", + wrapSelection: true, + }); + const detailPaneRef = new BoxRenderable(renderer, { + backgroundColor: SHELL_TOKENS.panelBackground, + border: ["left"], + borderColor: SHELL_TOKENS.border, + flexDirection: "column", + paddingLeft: 2, + width: 27, + }); + const detailEyebrow = new TextRenderable(renderer, { + content: "focus / account", + fg: SHELL_TOKENS.accent, + truncate: true, + }); + const detailTitle = new TextRenderable(renderer, { + content: "Focused account", + fg: SHELL_TOKENS.text, + truncate: true, + }); + const detailSubtitle = new TextRenderable(renderer, { + content: "context", + fg: SHELL_TOKENS.muted, + truncate: true, + }); + const detailMetaRefs = Array.from({ length: 7 }, () => + new TextRenderable(renderer, { + content: "", + fg: SHELL_TOKENS.muted, + truncate: true, + }) + ); + const detailActionsTitle = new TextRenderable(renderer, { + content: "Actions", + fg: SHELL_TOKENS.accent, + truncate: true, + }); + const detailActionRefs = Array.from({ length: 5 }, () => + new TextRenderable(renderer, { + content: "", + fg: SHELL_TOKENS.text, + truncate: true, + }) + ); + + const statusLineBox = new BoxRenderable(renderer, { + backgroundColor: SHELL_TOKENS.statusBackground, + border: ["top"], + borderColor: SHELL_TOKENS.border, + paddingLeft: 1, + paddingRight: 1, + width: "100%", + }); + const statusLineRef = new TextRenderable(renderer, { + content: createStatusLine({ + focusTarget: focusTarget(), + navLabel: navOptions[navIndex()]?.name ?? "", + searchMode: searchMode(), + searchQuery: searchQuery(), + statusLabel: activeStatusLabel, + uptimeSeconds: uptimeSeconds(), + visibleAccountCount: visibleAccountCount(), + totalAccountCount, + }), + fg: SHELL_TOKENS.statusText, + truncate: true, + }); + + const modalHostRef = new BoxRenderable(renderer, { + backgroundColor: SHELL_TOKENS.panelBackground, + border: true, + borderColor: SHELL_TOKENS.border, + height: 16, + padding: 1, + position: "absolute", + right: 2, + top: 1, + visible: false, + width: 38, + zIndex: 10, + }); + const modalTitle = new TextRenderable(renderer, { + content: "Overlay host", + fg: SHELL_TOKENS.text, + }); + const modalSubtitle = new TextRenderable(renderer, { + content: "Hidden until search/help/settings attach.", + fg: SHELL_TOKENS.muted, + truncate: true, + }); + const modalLineRefs = Array.from({ length: 12 }, () => + new TextRenderable(renderer, { + content: "", + fg: SHELL_TOKENS.text, + truncate: true, + }), + ); + const modalFooter = new TextRenderable(renderer, { + content: "", + fg: SHELL_TOKENS.muted, + truncate: true, + }); + + navRail.add(navBrand); + navRail.add(navMeta); + navRail.add(navRef); + + workspace.add(workspaceEyebrow); + workspace.add(workspaceTitle); + workspace.add(workspaceSubtitle); + workspaceListPane.add(accountListRef); + workspaceBody.add(workspaceListPane); + workspaceBody.add(workspacePaneGap); + workspaceBody.add(detailPaneRef); + workspace.add(workspaceBody); + workspace.add(workspaceHint); + detailPaneRef.add(detailEyebrow); + detailPaneRef.add(detailTitle); + detailPaneRef.add(detailSubtitle); + for (const ref of detailMetaRefs) { + detailPaneRef.add(ref); + } + detailPaneRef.add(detailActionsTitle); + for (const ref of detailActionRefs) { + detailPaneRef.add(ref); + } + + statusLineBox.add(statusLineRef); + modalHostRef.add(modalTitle); + modalHostRef.add(modalSubtitle); + for (const ref of modalLineRefs) { + modalHostRef.add(ref); + } + modalHostRef.add(modalFooter); + + shellBody.add(navRail); + shellBody.add(workspace); + root.add(shellBody); + root.add(statusLineBox); + root.add(modalHostRef); + + const emitSelectionChange = ( + nextNavIndex = navIndex(), + nextAccountIndex = accountIndex(), + nextFocusTarget = focusTarget(), + ) => { + props.onSelectionChange?.( + createSelectionSnapshot(navOptions, activeWorkspaceOptions, nextNavIndex, nextAccountIndex, nextFocusTarget), + ); + }; + + const syncStatusLineContent = ( + nextNavIndex = navIndex(), + nextFocusTarget = focusTarget(), + ) => { + statusLineRef.content = createStatusLine({ + focusTarget: nextFocusTarget, + navLabel: navOptions[nextNavIndex]?.name ?? "", + searchMode: searchMode(), + searchQuery: searchQuery(), + statusLabel: activeStatusLabel, + uptimeSeconds: uptimeSeconds(), + visibleAccountCount: visibleAccountCount(), + totalAccountCount, + }); + }; + + const focusPane = (nextTarget: OpenTuiShellFocusTarget) => { + setFocusTarget(nextTarget); + renderer.focusRenderable(nextTarget === "nav" ? navRef : accountListRef); + syncStatusLineContent(navIndex(), nextTarget); + emitSelectionChange(navIndex(), accountIndex(), nextTarget); + renderer.requestRender(); + }; + + const isAccountsRoute = (nextNavIndex = navIndex()) => navOptions[nextNavIndex]?.name === "Accounts"; + + const applyShellLayout = () => { + navRail.width = activeLayout.navWidth; + workspace.paddingLeft = activeLayout.workspacePaddingLeft; + workspacePaneGap.width = activeLayout.workspaceGapWidth; + detailPaneRef.width = activeLayout.detailWidth; + detailPaneRef.paddingLeft = activeLayout.detailPaddingLeft; + }; + + const applyDetailContent = (detail: OpenTuiDetailContent) => { + const metaLines = detail.metaLines.slice(0, activeLayout.detailMetaLimit); + const actionLines = detail.actionLines.slice(0, activeLayout.detailActionLimit); + const toneColor = resolveDetailToneColor(detail.tone); + detailEyebrow.content = activeLayout.hideEyebrows ? "" : detail.eyebrow; + detailTitle.content = detail.title; + detailTitle.fg = toneColor; + detailSubtitle.content = detail.subtitle; + detailMetaRefs.forEach((ref, index) => { + ref.content = metaLines[index] ?? ""; + }); + detailActionsTitle.content = actionLines.length > 0 ? "Actions" : ""; + detailActionRefs.forEach((ref, index) => { + ref.content = actionLines[index] ?? ""; + }); + }; + + const syncAccountDetailPane = (selectedAccountIndex = accountIndex()) => { + const account = activeVisibleAccounts[selectedAccountIndex]; + if (!account) { + applyDetailContent({ + eyebrow: "focus / account", + title: "Focused account", + subtitle: searchQuery().trim().length > 0 ? "No matching account" : "No saved account", + metaLines: searchQuery().trim().length > 0 + ? [`Filter: ${searchQuery()}`, "Clear search to review full account details."] + : ["Add or restore an account to populate the detail pane."], + actionLines: [], + tone: "warning", + }); + return; + } + const detail = buildOpenTuiAccountDetailPanel(account); + applyDetailContent({ + eyebrow: detail.eyebrow, + title: "Focused account", + subtitle: detail.title, + metaLines: [detail.subtitle, ...detail.metaLines], + actionLines: detail.actionLines, + tone: resolveDetailTone(detail.title.toLowerCase()), + }); + }; + + const resolveCurrentSelectedSourceIndex = (): number | undefined => { + const currentAccount = activeVisibleAccounts[accountIndex()]; + const sourceIndex = currentAccount ? resolveOpenTuiAccountSourceIndex(currentAccount) : -1; + return sourceIndex >= 0 ? sourceIndex : undefined; + }; + + const syncAccountsWorkspace = (preferredSourceIndex?: number) => { + const sourceIndexToPreserve = typeof preferredSourceIndex === "number" + ? preferredSourceIndex + : resolveCurrentSelectedSourceIndex(); + const visibleAccounts = filterOpenTuiDashboardAccounts(dashboard, searchQuery()); + activeVisibleAccounts = visibleAccounts; + setVisibleAccountCount(visibleAccounts.length); + + workspaceEyebrow.content = activeLayout.hideEyebrows + ? "" + : searchMode() || searchQuery().trim().length > 0 + ? "workspace / accounts / search" + : "workspace / accounts"; + workspaceTitle.content = "Account workspace"; + workspaceSubtitle.content = `${visibleAccounts.length} visible | ${totalAccountCount} total`; + const statusMessage = resolveOpenTuiDashboardStatus(dashboard); + workspaceHint.content = searchMode() + ? `Search: ${searchQuery().length > 0 ? searchQuery() : "_"} | Enter | Esc` + : statusMessage ?? "/ search | 1-9 switch | S/R/E/D"; + activeStatusLabel = visibleAccounts.length === totalAccountCount ? "live" : "filtered"; + + activeWorkspaceOptions = visibleAccounts.length > 0 + ? buildOpenTuiAccountOptions(visibleAccounts) + : [{ + name: searchQuery().trim().length > 0 ? `No accounts match \"${searchQuery()}\"` : "No saved accounts yet", + description: "", + }]; + accountListRef.options = activeWorkspaceOptions; + + let nextIndex = 0; + if (visibleAccounts.length > 0) { + if (typeof sourceIndexToPreserve === "number") { + const matchedIndex = visibleAccounts.findIndex((account) => + resolveOpenTuiAccountSourceIndex(account) === sourceIndexToPreserve + ); + nextIndex = matchedIndex >= 0 ? matchedIndex : clampSelection(accountIndex(), visibleAccounts.length); + } else { + nextIndex = clampSelection(accountIndex(), visibleAccounts.length); + } + } + + accountListRef.setSelectedIndex(nextIndex); + setAccountIndex(nextIndex); + syncAccountDetailPane(nextIndex); + }; + + const updateSearch = (nextQuery: string, nextSearchMode: boolean, preferredSourceIndex?: number) => { + setSearchQuery(nextQuery); + setSearchMode(nextSearchMode); + syncAccountsWorkspace(preferredSourceIndex); + props.onWorkspaceAction?.({ type: "search", active: nextSearchMode, query: nextQuery }); + emitSelectionChange(navIndex(), accountIndex(), focusTarget()); + renderer.requestRender(); + }; + + const applyWorkspacePanel = (nextNavIndex: number, nextAccountIndex = 0) => { + const panel = createWorkspacePanel(navOptions[nextNavIndex]?.name ?? "Accounts"); + if (isAccountsRoute(nextNavIndex)) { + if (drawerState().type !== "closed") { + setDrawerState({ type: "closed" }); + modalHostRef.visible = false; + } + syncAccountsWorkspace(resolveCurrentSelectedSourceIndex()); + return; + } + + activeWorkspaceOptions = panel.options; + activeVisibleAccounts = []; + setVisibleAccountCount(0); + activeStatusLabel = panel.statusLabel; + const boundedIndex = clampSelection(nextAccountIndex, activeWorkspaceOptions.length); + + workspaceEyebrow.content = panel.eyebrow; + if (activeLayout.hideEyebrows) { + workspaceEyebrow.content = ""; + } + workspaceTitle.content = panel.title; + workspaceSubtitle.content = panel.subtitle; + workspaceHint.content = activeLayout.density === "compact" + ? panel.hint + .replace("Press Enter to continue or ", "Enter continue | ") + .replace("Press Enter to run the ", "Enter run ") + .replace("Press Enter to apply the existing ", "Enter run ") + .replace("Press Enter to run the shared ", "Enter run ") + .replace("Press Enter to run the flagged-account verification flow.", "Enter run verify flow.") + .replace("Press Enter to run the deep-check command path.", "Enter run deep check.") + .replace("Press Enter to run the forecast panel.", "Enter run forecast.") + .replace("Press Enter to run the check command.", "Enter run check.") + .replace("Press Enter to continue or Left to choose a different route.", "Enter continue | Left route") + .replace("Press Enter to continue or Right if you want to inspect the drawer host.", "Enter continue | Right drawer") + : panel.hint; + accountListRef.options = activeWorkspaceOptions; + accountListRef.setSelectedIndex(boundedIndex); + setAccountIndex(boundedIndex); + applyDetailContent(createPanelDetailContent(panel)); + }; + + const openSettingsDrawer = () => { + const nextState: OpenTuiDrawerState = { type: "hub", cursor: 0 }; + const view = buildDrawerView(nextState); + modalHostRef.visible = true; + modalTitle.content = view.title; + modalSubtitle.content = view.subtitle; + modalLineRefs.forEach((ref, index) => { + ref.content = view.lines[index] ?? ""; + }); + modalFooter.content = view.footer; + setDrawerState(nextState); + renderer.requestRender(); + }; + + const closeSettingsDrawer = () => { + const currentDrawerState = drawerState(); + if (currentDrawerState.type === "dashboard-panel" && currentDrawerState.panel === "theme") { + applyUiThemeFromDashboardSettings(savedDashboardSettings()); + } + modalHostRef.visible = false; + setDrawerState({ type: "closed" }); + renderer.requestRender(); + }; + + const updateDrawerCursor = (direction: -1 | 1) => { + const currentDrawerState = drawerState(); + if (currentDrawerState.type === "closed") return; + if (currentDrawerState.type === "hub") { + setDrawerState({ + ...currentDrawerState, + cursor: wrapCursor(currentDrawerState.cursor, direction, SETTINGS_HUB_ACTIONS.length), + }); + return; + } + if (currentDrawerState.type === "dashboard-panel") { + const entries = currentDrawerState.panel === "account-list" + ? buildAccountListDrawerEntries(currentDrawerState.draft) + : currentDrawerState.panel === "summary-fields" + ? buildSummaryDrawerEntries(currentDrawerState.draft) + : currentDrawerState.panel === "behavior" + ? buildBehaviorDrawerEntries(currentDrawerState.draft) + : buildThemeDrawerEntries(currentDrawerState.draft); + setDrawerState({ + ...currentDrawerState, + cursor: wrapCursor(currentDrawerState.cursor, direction, entries.length), + }); + return; + } + if (currentDrawerState.type === "backend-hub") { + const entries = buildBackendHubEntries(); + setDrawerState({ + ...currentDrawerState, + cursor: wrapCursor(currentDrawerState.cursor, direction, entries.length), + }); + return; + } + const entries = buildBackendCategoryEntries(currentDrawerState.categoryKey, currentDrawerState.draft); + setDrawerState({ + ...currentDrawerState, + cursor: wrapCursor(currentDrawerState.cursor, direction, entries.length), + }); + }; + + const emitDashboardSettingsSave = (panel: OpenTuiDashboardPanelId, selected: DashboardDisplaySettings) => { + const snapshot = cloneDashboardSettings(selected); + setSavedDashboardSettings(snapshot); + props.onSettingsSave?.({ kind: "dashboard", panel, selected: snapshot }); + }; + + const emitBackendSettingsSave = (selected: PluginConfig) => { + const snapshot = cloneBackendPluginConfig(selected); + setSavedBackendConfig(snapshot); + props.onSettingsSave?.({ kind: "backend", selected: snapshot }); + }; + + const handleDrawerHubEnter = (cursor: number) => { + const actionId = SETTINGS_HUB_ACTIONS[cursor]?.id ?? "back"; + const command = resolveSettingsHubCommand({ type: actionId as SettingsHubAction["type"] }); + if (command.type === "back") { + closeSettingsDrawer(); + return; + } + if (command.type === "open-dashboard-panel") { + setDrawerState({ + type: "dashboard-panel", + panel: command.panel, + cursor: 0, + draft: cloneDashboardSettings(savedDashboardSettings()), + hubCursor: cursor, + themeBaseline: cloneDashboardSettings(savedDashboardSettings()), + }); + return; + } + setDrawerState({ + type: "backend-hub", + cursor: 0, + draft: cloneBackendPluginConfig(savedBackendConfig()), + baseline: cloneBackendPluginConfig(savedBackendConfig()), + hubCursor: cursor, + }); + }; + + const handleDrawerKeyPress = (keyEvent: KeyEvent, raw: string): boolean => { + const currentDrawerState = drawerState(); + if (currentDrawerState.type === "closed") { + return false; + } + const lower = raw.toLowerCase(); + if (keyEvent.name === "up") { + updateDrawerCursor(-1); + renderer.requestRender(); + return true; + } + if (keyEvent.name === "down") { + updateDrawerCursor(1); + renderer.requestRender(); + return true; + } + + if (currentDrawerState.type === "hub") { + if (lower === "q") { + closeSettingsDrawer(); + return true; + } + const parsed = Number.parseInt(lower, 10); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= 5) { + handleDrawerHubEnter(parsed - 1); + renderer.requestRender(); + return true; + } + if (isEnterKey(keyEvent.name)) { + handleDrawerHubEnter(currentDrawerState.cursor); + renderer.requestRender(); + return true; + } + return true; + } + + if (currentDrawerState.type === "dashboard-panel") { + const entries = currentDrawerState.panel === "account-list" + ? buildAccountListDrawerEntries(currentDrawerState.draft) + : currentDrawerState.panel === "summary-fields" + ? buildSummaryDrawerEntries(currentDrawerState.draft) + : currentDrawerState.panel === "behavior" + ? buildBehaviorDrawerEntries(currentDrawerState.draft) + : buildThemeDrawerEntries(currentDrawerState.draft); + const entry = entries[currentDrawerState.cursor]; + const commitAndReturn = (nextDraft: DashboardDisplaySettings) => { + emitDashboardSettingsSave(currentDrawerState.panel, nextDraft); + setDrawerState({ type: "hub", cursor: currentDrawerState.hubCursor }); + }; + if (lower === "q") { + if (currentDrawerState.panel === "theme") { + applyUiThemeFromDashboardSettings(currentDrawerState.themeBaseline); + } + setDrawerState({ type: "hub", cursor: currentDrawerState.hubCursor }); + renderer.requestRender(); + return true; + } + if (lower === "s") { + commitAndReturn(currentDrawerState.draft); + renderer.requestRender(); + return true; + } + if (lower === "r") { + const keys = currentDrawerState.panel === "account-list" + ? [ + "menuShowStatusBadge", + "menuShowCurrentBadge", + "menuShowLastUsed", + "menuShowQuotaSummary", + "menuShowQuotaCooldown", + "menuShowFetchStatus", + "menuShowDetailsForUnselectedRows", + "menuHighlightCurrentRow", + "menuSortEnabled", + "menuSortMode", + "menuSortPinCurrent", + "menuSortQuickSwitchVisibleRow", + "menuLayoutMode", + ] as const + : currentDrawerState.panel === "summary-fields" + ? ["menuStatuslineFields"] as const + : currentDrawerState.panel === "behavior" + ? [ + "actionAutoReturnMs", + "actionPauseOnKey", + "menuAutoFetchLimits", + "menuShowFetchStatus", + "menuQuotaTtlMs", + ] as const + : ["uiThemePreset", "uiAccentColor"] as const; + const nextDraft = applyDashboardDefaultsForKeys(currentDrawerState.draft, keys); + if (currentDrawerState.panel === "theme") { + applyUiThemeFromDashboardSettings(nextDraft); + } + setDrawerState({ ...currentDrawerState, draft: nextDraft, cursor: 0 }); + renderer.requestRender(); + return true; + } + + if (currentDrawerState.panel === "account-list") { + const parsed = Number.parseInt(lower, 10); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= DASHBOARD_DISPLAY_OPTIONS.length) { + const option = DASHBOARD_DISPLAY_OPTIONS[parsed - 1]; + if (option) { + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + [option.key]: !(currentDrawerState.draft[option.key] ?? true), + }, + cursor: parsed - 1, + }); + renderer.requestRender(); + return true; + } + } + if (lower === "m") { + setDrawerState({ ...currentDrawerState, draft: cycleSortMode(currentDrawerState.draft), cursor: DASHBOARD_DISPLAY_OPTIONS.length }); + renderer.requestRender(); + return true; + } + if (lower === "l") { + setDrawerState({ + ...currentDrawerState, + draft: cycleLayoutMode(currentDrawerState.draft), + cursor: DASHBOARD_DISPLAY_OPTIONS.length + 1, + }); + renderer.requestRender(); + return true; + } + if (isEnterKey(keyEvent.name) && entry) { + if (entry.id === "save") { + commitAndReturn(currentDrawerState.draft); + } else if (entry.id === "cancel") { + setDrawerState({ type: "hub", cursor: currentDrawerState.hubCursor }); + } else if (entry.id === "reset") { + setDrawerState({ + ...currentDrawerState, + draft: applyDashboardDefaultsForKeys(currentDrawerState.draft, [ + "menuShowStatusBadge", + "menuShowCurrentBadge", + "menuShowLastUsed", + "menuShowQuotaSummary", + "menuShowQuotaCooldown", + "menuShowFetchStatus", + "menuShowDetailsForUnselectedRows", + "menuHighlightCurrentRow", + "menuSortEnabled", + "menuSortMode", + "menuSortPinCurrent", + "menuSortQuickSwitchVisibleRow", + "menuLayoutMode", + ]), + cursor: 0, + }); + } else if (entry.id === "sort-mode") { + setDrawerState({ ...currentDrawerState, draft: cycleSortMode(currentDrawerState.draft) }); + } else if (entry.id === "layout-mode") { + setDrawerState({ ...currentDrawerState, draft: cycleLayoutMode(currentDrawerState.draft) }); + } else { + const option = DASHBOARD_DISPLAY_OPTIONS.find((candidate) => candidate.key === entry.id); + if (option) { + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + [option.key]: !(currentDrawerState.draft[option.key] ?? true), + }, + }); + } + } + renderer.requestRender(); + return true; + } + return true; + } + + if (currentDrawerState.panel === "summary-fields") { + if (lower === "[") { + const currentField = STATUSLINE_FIELD_OPTIONS[currentDrawerState.cursor]?.key; + if (currentField) { + setDrawerState({ + ...currentDrawerState, + draft: reorderStatuslineField(currentDrawerState.draft, currentField, -1), + }); + renderer.requestRender(); + } + return true; + } + if (lower === "]") { + const currentField = STATUSLINE_FIELD_OPTIONS[currentDrawerState.cursor]?.key; + if (currentField) { + setDrawerState({ + ...currentDrawerState, + draft: reorderStatuslineField(currentDrawerState.draft, currentField, 1), + }); + renderer.requestRender(); + } + return true; + } + const parsed = Number.parseInt(lower, 10); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= STATUSLINE_FIELD_OPTIONS.length) { + const target = STATUSLINE_FIELD_OPTIONS[parsed - 1]; + if (target) { + const fields = normalizeStatuslineFields(currentDrawerState.draft.menuStatuslineFields); + const isEnabled = fields.includes(target.key); + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + menuStatuslineFields: isEnabled + ? (fields.filter((field) => field !== target.key).length > 0 + ? fields.filter((field) => field !== target.key) + : [target.key]) + : [...fields, target.key], + }, + cursor: parsed - 1, + }); + renderer.requestRender(); + } + return true; + } + if (isEnterKey(keyEvent.name) && entry) { + if (entry.id === "save") { + commitAndReturn(currentDrawerState.draft); + } else if (entry.id === "cancel") { + setDrawerState({ type: "hub", cursor: currentDrawerState.hubCursor }); + } else if (entry.id === "reset") { + setDrawerState({ + ...currentDrawerState, + draft: applyDashboardDefaultsForKeys(currentDrawerState.draft, ["menuStatuslineFields"]), + cursor: 0, + }); + } else { + const target = STATUSLINE_FIELD_OPTIONS.find((option) => option.key === entry.id); + if (target) { + const fields = normalizeStatuslineFields(currentDrawerState.draft.menuStatuslineFields); + const isEnabled = fields.includes(target.key); + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + menuStatuslineFields: isEnabled + ? (fields.filter((field) => field !== target.key).length > 0 + ? fields.filter((field) => field !== target.key) + : [target.key]) + : [...fields, target.key], + }, + }); + } + } + renderer.requestRender(); + return true; + } + return true; + } + + if (currentDrawerState.panel === "behavior") { + const setDelay = (delayMs: number) => { + setDrawerState({ + ...currentDrawerState, + draft: { ...currentDrawerState.draft, actionAutoReturnMs: delayMs }, + }); + }; + if (lower === "p") { + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + actionPauseOnKey: !(currentDrawerState.draft.actionPauseOnKey ?? true), + }, + }); + renderer.requestRender(); + return true; + } + if (lower === "l") { + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + menuAutoFetchLimits: !(currentDrawerState.draft.menuAutoFetchLimits ?? true), + }, + }); + renderer.requestRender(); + return true; + } + if (lower === "f") { + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + menuShowFetchStatus: !(currentDrawerState.draft.menuShowFetchStatus ?? true), + }, + }); + renderer.requestRender(); + return true; + } + if (lower === "t") { + const currentTtl = currentDrawerState.draft.menuQuotaTtlMs ?? 5 * 60_000; + const currentIndex = MENU_QUOTA_TTL_OPTIONS_MS.findIndex((value) => value === currentTtl); + const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % MENU_QUOTA_TTL_OPTIONS_MS.length; + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + menuQuotaTtlMs: MENU_QUOTA_TTL_OPTIONS_MS[nextIndex] ?? MENU_QUOTA_TTL_OPTIONS_MS[0] ?? currentTtl, + }, + }); + renderer.requestRender(); + return true; + } + const parsed = Number.parseInt(lower, 10); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= AUTO_RETURN_OPTIONS_MS.length) { + const delayMs = AUTO_RETURN_OPTIONS_MS[parsed - 1]; + if (typeof delayMs === "number") { + setDelay(delayMs); + renderer.requestRender(); + } + return true; + } + if (isEnterKey(keyEvent.name) && entry) { + if (entry.id === "save") { + commitAndReturn(currentDrawerState.draft); + } else if (entry.id === "cancel") { + setDrawerState({ type: "hub", cursor: currentDrawerState.hubCursor }); + } else if (entry.id === "reset") { + setDrawerState({ + ...currentDrawerState, + draft: applyDashboardDefaultsForKeys(currentDrawerState.draft, [ + "actionAutoReturnMs", + "actionPauseOnKey", + "menuAutoFetchLimits", + "menuShowFetchStatus", + "menuQuotaTtlMs", + ]), + cursor: 0, + }); + } else if (entry.id === "pause") { + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + actionPauseOnKey: !(currentDrawerState.draft.actionPauseOnKey ?? true), + }, + }); + } else if (entry.id === "auto-fetch") { + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + menuAutoFetchLimits: !(currentDrawerState.draft.menuAutoFetchLimits ?? true), + }, + }); + } else if (entry.id === "fetch-status") { + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + menuShowFetchStatus: !(currentDrawerState.draft.menuShowFetchStatus ?? true), + }, + }); + } else if (entry.id === "ttl") { + const currentTtl = currentDrawerState.draft.menuQuotaTtlMs ?? 5 * 60_000; + const currentIndex = MENU_QUOTA_TTL_OPTIONS_MS.findIndex((value) => value === currentTtl); + const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % MENU_QUOTA_TTL_OPTIONS_MS.length; + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + menuQuotaTtlMs: MENU_QUOTA_TTL_OPTIONS_MS[nextIndex] ?? MENU_QUOTA_TTL_OPTIONS_MS[0] ?? currentTtl, + }, + }); + } else if (entry.id.startsWith("delay:")) { + const delayMs = Number.parseInt(entry.id.slice("delay:".length), 10); + if (Number.isFinite(delayMs)) { + setDelay(delayMs); + } + } + renderer.requestRender(); + return true; + } + return true; + } + + const setPalette = (palette: DashboardThemePreset) => { + const nextDraft = { ...currentDrawerState.draft, uiThemePreset: palette }; + applyUiThemeFromDashboardSettings(nextDraft); + setDrawerState({ ...currentDrawerState, draft: nextDraft }); + }; + const setAccent = (accent: DashboardAccentColor) => { + const nextDraft = { ...currentDrawerState.draft, uiAccentColor: accent }; + applyUiThemeFromDashboardSettings(nextDraft); + setDrawerState({ ...currentDrawerState, draft: nextDraft }); + }; + if (lower === "1") { + setPalette("green"); + renderer.requestRender(); + return true; + } + if (lower === "2") { + setPalette("blue"); + renderer.requestRender(); + return true; + } + if (isEnterKey(keyEvent.name) && entry) { + if (entry.id === "save") { + commitAndReturn(currentDrawerState.draft); + } else if (entry.id === "cancel") { + applyUiThemeFromDashboardSettings(currentDrawerState.themeBaseline); + setDrawerState({ type: "hub", cursor: currentDrawerState.hubCursor }); + } else if (entry.id === "reset") { + const nextDraft = applyDashboardDefaultsForKeys(currentDrawerState.draft, ["uiThemePreset", "uiAccentColor"]); + applyUiThemeFromDashboardSettings(nextDraft); + setDrawerState({ ...currentDrawerState, draft: nextDraft, cursor: 0 }); + } else if (entry.id.startsWith("palette:")) { + setPalette(entry.id.slice("palette:".length) as DashboardThemePreset); + } else if (entry.id.startsWith("accent:")) { + setAccent(entry.id.slice("accent:".length) as DashboardAccentColor); + } + renderer.requestRender(); + return true; + } + return true; + } + + if (currentDrawerState.type === "backend-hub") { + const entries = buildBackendHubEntries(); + const entry = entries[currentDrawerState.cursor]; + if (lower === "q") { + setDrawerState({ type: "hub", cursor: currentDrawerState.hubCursor }); + renderer.requestRender(); + return true; + } + if (lower === "s") { + emitBackendSettingsSave(currentDrawerState.draft); + setDrawerState({ type: "hub", cursor: currentDrawerState.hubCursor }); + renderer.requestRender(); + return true; + } + if (lower === "r") { + setDrawerState({ + ...currentDrawerState, + draft: cloneBackendPluginConfig(BACKEND_DEFAULTS), + cursor: 0, + }); + renderer.requestRender(); + return true; + } + const parsed = Number.parseInt(lower, 10); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= BACKEND_CATEGORY_OPTIONS.length) { + const category = BACKEND_CATEGORY_OPTIONS[parsed - 1]; + if (category) { + setDrawerState({ + type: "backend-category", + categoryKey: category.key, + cursor: 0, + draft: cloneBackendPluginConfig(currentDrawerState.draft), + baseline: cloneBackendPluginConfig(currentDrawerState.baseline), + hubCursor: currentDrawerState.hubCursor, + backendCursor: parsed - 1, + }); + renderer.requestRender(); + } + return true; + } + if (isEnterKey(keyEvent.name) && entry) { + if (entry.id === "save") { + emitBackendSettingsSave(currentDrawerState.draft); + setDrawerState({ type: "hub", cursor: currentDrawerState.hubCursor }); + } else if (entry.id === "cancel") { + setDrawerState({ type: "hub", cursor: currentDrawerState.hubCursor }); + } else if (entry.id === "reset") { + setDrawerState({ + ...currentDrawerState, + draft: cloneBackendPluginConfig(BACKEND_DEFAULTS), + cursor: 0, + }); + } else if (entry.id.startsWith("category:")) { + const categoryKey = entry.id.slice("category:".length) as BackendCategoryKey; + setDrawerState({ + type: "backend-category", + categoryKey, + cursor: 0, + draft: cloneBackendPluginConfig(currentDrawerState.draft), + baseline: cloneBackendPluginConfig(currentDrawerState.baseline), + hubCursor: currentDrawerState.hubCursor, + backendCursor: currentDrawerState.cursor, + }); + } + renderer.requestRender(); + return true; + } + return true; + } + + const entries = buildBackendCategoryEntries(currentDrawerState.categoryKey, currentDrawerState.draft); + const entry = entries[currentDrawerState.cursor]; + const adjustCurrentNumber = (direction: -1 | 1) => { + if (!entry || !entry.id.startsWith("number:")) return; + const numberKey = entry.id.slice("number:".length) as BackendNumberSettingKey; + const option = BACKEND_NUMBER_OPTION_BY_KEY.get(numberKey); + if (!option) return; + const currentValue = currentDrawerState.draft[numberKey] ?? BACKEND_DEFAULTS[numberKey] ?? option.min; + const numericCurrent = typeof currentValue === "number" && Number.isFinite(currentValue) + ? currentValue + : option.min; + setDrawerState({ + ...currentDrawerState, + draft: { + ...currentDrawerState.draft, + [numberKey]: clampBackendNumber(option, numericCurrent + option.step * direction), + }, + }); + }; + if (lower === "q") { + setDrawerState({ + type: "backend-hub", + cursor: currentDrawerState.backendCursor, + draft: cloneBackendPluginConfig(currentDrawerState.draft), + baseline: cloneBackendPluginConfig(currentDrawerState.baseline), + hubCursor: currentDrawerState.hubCursor, + }); + renderer.requestRender(); + return true; + } + if (lower === "r") { + setDrawerState({ + ...currentDrawerState, + draft: applyBackendCategoryDefaults(currentDrawerState.draft, currentDrawerState.categoryKey), + cursor: 0, + }); + renderer.requestRender(); + return true; + } + if (lower === "+" || lower === "=" || lower === "]" || lower === "d") { + adjustCurrentNumber(1); + renderer.requestRender(); + return true; + } + if (lower === "-" || lower === "[" || lower === "a") { + adjustCurrentNumber(-1); + renderer.requestRender(); + return true; + } + const parsed = Number.parseInt(lower, 10); + const category = BACKEND_CATEGORY_OPTION_BY_KEY.get(currentDrawerState.categoryKey); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= (category?.toggleKeys.length ?? 0)) { + const toggleKey = category?.toggleKeys[parsed - 1]; + if (toggleKey) { + const currentValue = currentDrawerState.draft[toggleKey] ?? BACKEND_DEFAULTS[toggleKey] ?? false; + setDrawerState({ + ...currentDrawerState, + draft: { ...currentDrawerState.draft, [toggleKey]: !currentValue }, + cursor: parsed - 1, + }); + renderer.requestRender(); + } + return true; + } + if (isEnterKey(keyEvent.name) && entry) { + if (entry.id === "back") { + setDrawerState({ + type: "backend-hub", + cursor: currentDrawerState.backendCursor, + draft: cloneBackendPluginConfig(currentDrawerState.draft), + baseline: cloneBackendPluginConfig(currentDrawerState.baseline), + hubCursor: currentDrawerState.hubCursor, + }); + } else if (entry.id === "reset") { + setDrawerState({ + ...currentDrawerState, + draft: applyBackendCategoryDefaults(currentDrawerState.draft, currentDrawerState.categoryKey), + cursor: 0, + }); + } else if (entry.id.startsWith("toggle:")) { + const toggleKey = entry.id.slice("toggle:".length) as BackendToggleSettingKey; + const currentValue = currentDrawerState.draft[toggleKey] ?? BACKEND_DEFAULTS[toggleKey] ?? false; + setDrawerState({ + ...currentDrawerState, + draft: { ...currentDrawerState.draft, [toggleKey]: !currentValue }, + }); + } else if (entry.id.startsWith("number:")) { + adjustCurrentNumber(1); + } + renderer.requestRender(); + return true; + } + return true; + }; + + const handleNavSelectionChange = (nextIndex: number) => { + setNavIndex(nextIndex); + applyWorkspacePanel(nextIndex, 0); + syncStatusLineContent(nextIndex, focusTarget()); + emitSelectionChange(nextIndex, 0, focusTarget()); + }; + + const handleAccountSelectionChange = (nextIndex: number) => { + setAccountIndex(nextIndex); + syncAccountDetailPane(nextIndex); + emitSelectionChange(navIndex(), nextIndex, focusTarget()); + }; + + navRef.on(SelectRenderableEvents.SELECTION_CHANGED, handleNavSelectionChange); + accountListRef.on(SelectRenderableEvents.SELECTION_CHANGED, handleAccountSelectionChange); + applyShellLayout(); + applyWorkspacePanel(0, 0); + + const timer = clock.setInterval(() => { + setUptimeSeconds((value) => value + 1); + }, 1000); + + createEffect(() => { + statusLineRef.content = createStatusLine({ + focusTarget: focusTarget(), + navLabel: navOptions[navIndex()]?.name ?? "", + searchMode: searchMode(), + searchQuery: searchQuery(), + statusLabel: activeStatusLabel, + uptimeSeconds: uptimeSeconds(), + visibleAccountCount: visibleAccountCount(), + totalAccountCount, + }); + }); + + createEffect(() => { + const view = buildDrawerView(drawerState()); + modalHostRef.visible = drawerState().type !== "closed"; + modalTitle.content = view.title; + modalSubtitle.content = view.subtitle; + modalLineRefs.forEach((ref, index) => { + ref.content = view.lines[index] ?? ""; + }); + modalFooter.content = view.footer; + renderer.requestRender(); + }); + + const handleRendererSelection = (selection: Selection) => { + props.onRendererSelection?.(selection); + }; + + root.onSizeChange = () => { + const nextLayout = resolveShellLayout(renderer.width); + if (nextLayout.density === activeLayout.density) { + return; + } + activeLayout = nextLayout; + applyShellLayout(); + applyWorkspacePanel(navIndex(), accountIndex()); + syncStatusLineContent(navIndex(), focusTarget()); + renderer.requestRender(); + }; + + const handleKeyPress = (keyEvent: KeyEvent) => { + props.onKeyPress?.(keyEvent); + + if (renderer.isDestroyed) { + return; + } + + const raw = keyEvent.sequence ?? keyEvent.name ?? ""; + const accountsRoute = isAccountsRoute(); + if (handleDrawerKeyPress(keyEvent, raw)) { + return; + } + if (accountsRoute && focusTarget() === "workspace" && searchMode()) { + const preferredSourceIndex = resolveCurrentSelectedSourceIndex(); + if (keyEvent.name === "escape" || raw === "\u001b") { + updateSearch("", false, preferredSourceIndex); + return; + } + if (keyEvent.name === "return" || keyEvent.name === "enter") { + updateSearch(searchQuery(), false, preferredSourceIndex); + return; + } + if (keyEvent.name === "backspace") { + updateSearch(searchQuery().slice(0, -1), true, preferredSourceIndex); + return; + } + if (!keyEvent.ctrl && !keyEvent.meta && typeof raw === "string" && raw.length === 1 && raw >= " ") { + updateSearch(`${searchQuery()}${raw}`, true, preferredSourceIndex); + return; + } + } + + const isEscape = keyEvent.name === "escape" || raw === "\u001b"; + if (isEscape || keyEvent.name === "q") { + const reason: OpenTuiShellExitReason = isEscape ? "escape" : "quit"; + + try { + props.onExit?.(reason, renderer); + } finally { + renderer.destroy(); + } + return; + } + + if (keyEvent.name === "tab") { + const nextTarget = keyEvent.shift ? "nav" : focusTarget() === "nav" ? "workspace" : "nav"; + focusPane(nextTarget); + if (nextTarget === "workspace" && navOptions[navIndex()]?.name === "Settings") { + openSettingsDrawer(); + } + return; + } + + if (keyEvent.name === "left" && focusTarget() === "workspace") { + focusPane("nav"); + return; + } + + if (keyEvent.name === "right" && focusTarget() === "nav") { + focusPane("workspace"); + if (navOptions[navIndex()]?.name === "Settings") { + openSettingsDrawer(); + } + return; + } + + if (focusTarget() === "nav" && isEnterKey(keyEvent.name) && navOptions[navIndex()]?.name === "Settings") { + focusPane("workspace"); + openSettingsDrawer(); + return; + } + + if (accountsRoute && focusTarget() === "workspace") { + if (raw === "/") { + updateSearch(searchQuery(), true, resolveCurrentSelectedSourceIndex()); + return; + } + + const quickSwitchAccount = resolveOpenTuiQuickSwitchAccount(dashboard, searchQuery(), raw); + if (quickSwitchAccount) { + const sourceIndex = resolveOpenTuiAccountSourceIndex(quickSwitchAccount); + if (sourceIndex >= 0) { + const visibleIndex = activeVisibleAccounts.findIndex((account) => + resolveOpenTuiAccountSourceIndex(account) === sourceIndex + ); + if (visibleIndex >= 0) { + accountListRef.setSelectedIndex(visibleIndex); + setAccountIndex(visibleIndex); + emitSelectionChange(navIndex(), visibleIndex, focusTarget()); + props.onWorkspaceAction?.({ type: "quick-switch", sourceIndex }); + renderer.requestRender(); + return; + } + } + } + } + + const activeRenderable = focusTarget() === "nav" ? navRef : accountListRef; + if (activeRenderable.handleKeyPress) { + const handled = activeRenderable.handleKeyPress(keyEvent); + if (handled) { + renderer.requestRender(); + } + } + }; + + renderer.on("selection", handleRendererSelection); + renderer.keyInput.on("keypress", handleKeyPress); + + onCleanup(() => { + clock.clearInterval(timer); + navRef.off(SelectRenderableEvents.SELECTION_CHANGED, handleNavSelectionChange); + accountListRef.off(SelectRenderableEvents.SELECTION_CHANGED, handleAccountSelectionChange); + renderer.off("selection", handleRendererSelection); + renderer.keyInput.off("keypress", handleKeyPress); + }); + + queueMicrotask(() => { + if (renderer.isDestroyed) { + return; + } + + focusPane("workspace"); + props.onReady?.({ + accountListRef, + focusTarget: focusTarget(), + focusedRenderable: renderer.currentFocusedRenderable as SelectRenderable | null, + modalHostRef, + navRef, + renderer, + rootRef: root, + statusLineRef, + }); + }); + + return root; +}; diff --git a/runtime/opentui/bootstrap.ts b/runtime/opentui/bootstrap.ts new file mode 100644 index 00000000..b661d213 --- /dev/null +++ b/runtime/opentui/bootstrap.ts @@ -0,0 +1,79 @@ +import type { CliRenderer, CliRendererConfig } from "@opentui/core"; +import { render } from "@opentui/solid"; +import { createComponent } from "solid-js"; +import { OpenTuiBootstrapApp, type OpenTuiBootstrapAppProps } from "./app.js"; + +export type OpenTuiAuthShellBootstrapReason = + | "stdin-not-tty" + | "stdout-not-tty" + | "host-managed-ui"; + +export interface OpenTuiAuthShellEnvironment { + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + env?: NodeJS.ProcessEnv; +} + +export interface OpenTuiAuthShellBootstrapResult { + supported: boolean; + reason?: OpenTuiAuthShellBootstrapReason; +} + +function isHostManagedUi(env: NodeJS.ProcessEnv): boolean { + if (env.FORCE_INTERACTIVE_MODE === "1") return false; + if (env.CODEX_TUI === "1") return true; + if (env.CODEX_DESKTOP === "1") return true; + if ((env.TERM_PROGRAM ?? "").trim().toLowerCase() === "codex") return true; + if (env.ELECTRON_RUN_AS_NODE === "1") return true; + return false; +} + +export function resolveOpenTuiAuthShellBootstrap( + environment: OpenTuiAuthShellEnvironment = {}, +): OpenTuiAuthShellBootstrapResult { + const stdin = environment.stdin ?? process.stdin; + const stdout = environment.stdout ?? process.stdout; + const env = environment.env ?? process.env; + if (!stdin.isTTY) { + return { supported: false, reason: "stdin-not-tty" }; + } + if (!stdout.isTTY) { + return { supported: false, reason: "stdout-not-tty" }; + } + if (isHostManagedUi(env)) { + return { supported: false, reason: "host-managed-ui" }; + } + return { supported: true }; +} + +export type OpenTuiBootstrapOptions = OpenTuiAuthShellEnvironment & OpenTuiBootstrapAppProps & { + renderer?: CliRenderer | CliRendererConfig; +}; + +export async function startOpenTuiAuthShell(options: OpenTuiBootstrapOptions = {}) { + const support = resolveOpenTuiAuthShellBootstrap(options); + if (!support.supported) { + return null; + } + + const renderResult = await render(() => createComponent(OpenTuiBootstrapApp, { + dashboard: options.dashboard, + navOptions: options.navOptions, + onExit: options.onExit, + onKeyPress: options.onKeyPress, + onReady: options.onReady, + onRendererSelection: options.onRendererSelection, + onSelectionChange: options.onSelectionChange, + onSettingsSave: options.onSettingsSave, + onWorkspaceAction: options.onWorkspaceAction, + }), options.renderer ?? { + exitOnCtrlC: false, + targetFps: 30, + }); + + return renderResult; +} + +export async function startOpenTuiBootstrap(options: OpenTuiBootstrapOptions = {}) { + return startOpenTuiAuthShell(options); +} diff --git a/runtime/opentui/build.ts b/runtime/opentui/build.ts new file mode 100644 index 00000000..9fd0104e --- /dev/null +++ b/runtime/opentui/build.ts @@ -0,0 +1,20 @@ +/// +import { build } from "bun"; +import solidPlugin from "@opentui/solid/bun-plugin"; + +const result = await build({ + entrypoints: ["./runtime/opentui/index.ts"], + outdir: "./dist/opentui", + target: "bun", + sourcemap: "external", + plugins: [solidPlugin], +}); + +if (!result.success) { + for (const log of result.logs) { + console.error(log.message); + } + process.exit(1); +} + +console.log(`Built OpenTUI runtime (${result.outputs.length} files) -> dist/opentui`); diff --git a/runtime/opentui/index.ts b/runtime/opentui/index.ts new file mode 100644 index 00000000..2e82595e --- /dev/null +++ b/runtime/opentui/index.ts @@ -0,0 +1,8 @@ +import { startOpenTuiBootstrap } from "./bootstrap.js"; + +await startOpenTuiBootstrap({ + renderer: { + exitOnCtrlC: true, + targetFps: 30, + }, +}); diff --git a/runtime/opentui/prompt.ts b/runtime/opentui/prompt.ts new file mode 100644 index 00000000..ae5284fb --- /dev/null +++ b/runtime/opentui/prompt.ts @@ -0,0 +1,176 @@ +import type { LoginMenuResult } from "../../lib/cli.js"; +import type { AuthDashboardViewModel } from "../../lib/codex-manager/auth-ui-controller.js"; +import type { KeyEvent } from "@opentui/core"; +import type { + OpenTuiShellSelection, + OpenTuiWorkspaceAction, +} from "./app.js"; +import { + filterOpenTuiDashboardAccounts, + resolveOpenTuiAccountSourceIndex, +} from "./account-workspace.js"; +import { + resolveOpenTuiAuthShellBootstrap, + startOpenTuiAuthShell, + type OpenTuiBootstrapOptions, +} from "./bootstrap.js"; + +type OpenTuiAuthNavActionLabel = + | "Add" + | "Check" + | "Forecast" + | "Fix" + | "Verify" + | "Deep Check"; + +const NAV_ACTION_RESULTS: Record = { + Add: { mode: "add" }, + Check: { mode: "check" }, + Forecast: { mode: "forecast" }, + Fix: { mode: "fix" }, + Verify: { mode: "verify-flagged" }, + "Deep Check": { mode: "deep-check" }, +}; + +const EMPTY_SELECTION: OpenTuiShellSelection = { + navIndex: 0, + navLabel: "Accounts", + accountIndex: 0, + accountLabel: "", + focusTarget: "workspace", +}; + +function isEnterKey(event: KeyEvent): boolean { + return event.name === "enter" || event.name === "return"; +} + +function resolveNavActionResult(label: string): LoginMenuResult | null { + return NAV_ACTION_RESULTS[label as OpenTuiAuthNavActionLabel] ?? null; +} + +function resolveSelectedAccountResult( + dashboard: AuthDashboardViewModel, + selection: OpenTuiShellSelection, + searchQuery: string, + raw: string, +): LoginMenuResult | null { + if (selection.navLabel !== "Accounts" || selection.focusTarget !== "workspace") { + return null; + } + + const visibleAccounts = filterOpenTuiDashboardAccounts(dashboard, searchQuery); + const account = visibleAccounts[selection.accountIndex]; + if (!account) { + return null; + } + + const sourceIndex = resolveOpenTuiAccountSourceIndex(account); + if (sourceIndex < 0) { + return null; + } + + switch (raw) { + case "s": + return { mode: "manage", switchAccountIndex: sourceIndex }; + case "r": + return { mode: "manage", refreshAccountIndex: sourceIndex }; + case "e": + return { mode: "manage", toggleAccountIndex: sourceIndex }; + case "d": + return { mode: "manage", deleteAccountIndex: sourceIndex }; + default: + return null; + } +} + +export interface PromptOpenTuiAuthDashboardOptions extends OpenTuiBootstrapOptions { + dashboard: AuthDashboardViewModel; +} + +export async function promptOpenTuiAuthDashboard( + options: PromptOpenTuiAuthDashboardOptions, +): Promise { + const support = resolveOpenTuiAuthShellBootstrap(options); + if (!support.supported) { + return null; + } + + let settled = false; + let searchQuery = ""; + let selection = EMPTY_SELECTION; + let destroyRenderer: (() => void) | null = null; + + return await new Promise((resolve, reject) => { + const finish = (result: LoginMenuResult | null) => { + if (settled) return; + settled = true; + try { + destroyRenderer?.(); + } catch { + // best effort cleanup + } + resolve(result); + }; + + void startOpenTuiAuthShell({ + ...options, + onReady: (context) => { + destroyRenderer = () => { + if (!context.renderer.isDestroyed) { + context.renderer.destroy(); + } + }; + options.onReady?.(context); + }, + onSelectionChange: (nextSelection) => { + selection = nextSelection; + options.onSelectionChange?.(nextSelection); + }, + onWorkspaceAction: (action: OpenTuiWorkspaceAction) => { + if (action.type === "search") { + searchQuery = action.query; + } + if (action.type === "quick-switch") { + finish({ mode: "manage", switchAccountIndex: action.sourceIndex }); + return; + } + options.onWorkspaceAction?.(action); + }, + onExit: (reason, renderer) => { + options.onExit?.(reason, renderer); + finish({ mode: "cancel" }); + }, + onKeyPress: (event) => { + options.onKeyPress?.(event); + const raw = (event.sequence ?? event.name ?? "").toLowerCase(); + + if (isEnterKey(event) && selection.navLabel !== "Accounts") { + const navAction = resolveNavActionResult(selection.navLabel); + if (navAction) { + finish(navAction); + return; + } + } + + const selectedAccountResult = resolveSelectedAccountResult( + options.dashboard, + selection, + searchQuery, + raw, + ); + if (selectedAccountResult) { + finish(selectedAccountResult); + } + }, + }) + .then((renderResult) => { + if (!settled && renderResult === null) { + finish(null); + } + }) + .catch((error) => { + if (settled) return; + reject(error); + }); + }); +} diff --git a/scripts/run-bun-tests.js b/scripts/run-bun-tests.js new file mode 100644 index 00000000..212e1ce2 --- /dev/null +++ b/scripts/run-bun-tests.js @@ -0,0 +1,38 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +function resolveBunCommand() { + const envPath = (process.env.BUN_BIN ?? "").trim(); + if (envPath.length > 0) { + return envPath; + } + + const home = homedir(); + const candidates = process.platform === "win32" + ? [join(home, ".bun", "bin", "bun.exe")] + : [join(home, ".bun", "bin", "bun")]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + return "bun"; +} + +const bunCommand = resolveBunCommand(); +const bunArgs = process.argv.length > 2 ? process.argv.slice(2) : ["test"]; +const result = spawnSync(bunCommand, bunArgs, { + stdio: "inherit", + shell: process.platform === "win32", + env: process.env, +}); + +if (typeof result.status === "number") { + process.exit(result.status); +} + +process.exit(1); diff --git a/test/auth-menu-hotkeys.test.ts b/test/auth-menu-hotkeys.test.ts index 75ec2269..a77d75dc 100644 --- a/test/auth-menu-hotkeys.test.ts +++ b/test/auth-menu-hotkeys.test.ts @@ -197,4 +197,24 @@ describe("auth-menu hotkeys", () => { const options = selectMock.mock.calls[0]?.[1] as { message?: string }; expect(options?.message).toBe("Accounts Dashboard (v0.1.6)"); }); + + it.each([ + { input: "s", expected: "set-current" }, + { input: "r", expected: "refresh" }, + { input: "t", expected: "toggle" }, + { input: "e", expected: "toggle" }, + { input: "x", expected: "toggle" }, + { input: "d", expected: "delete" }, + { input: "q", expected: "cancel" }, + ])("honors account detail hotkey %s", async ({ input, expected }) => { + selectMock.mockImplementationOnce(async (_items: unknown[], options: { onInput?: (raw: string) => unknown }) => { + const onInput = options.onInput; + return onInput ? onInput(input) : null; + }); + + const { showAccountDetails } = await import("../lib/ui/auth-menu.js"); + const result = await showAccountDetails(createAccounts()[0]); + + expect(result).toBe(expected); + }); }); diff --git a/test/auth-ui-controller.test.ts b/test/auth-ui-controller.test.ts index 2aa27fc6..ca01310b 100644 --- a/test/auth-ui-controller.test.ts +++ b/test/auth-ui-controller.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { + buildAuthAccountDetailViewModel, + buildAuthDashboardScreenState, buildAuthDashboardViewModel, + resolveAuthAccountDetailSelection, resolveAuthDashboardCommand, + resolveAuthDashboardSelection, + settleAuthConfirmation, } from "../lib/codex-manager/auth-ui-controller.js"; describe("auth ui controller seam", () => { @@ -117,6 +122,58 @@ describe("auth ui controller seam", () => { expect(viewModel.accounts[1]?.isCurrentAccount).toBe(true); }); + it("builds a renderer-agnostic screen state with default detail-pane data", () => { + const now = Date.now(); + const state = buildAuthDashboardScreenState({ + storage: { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + accessToken: "access-a", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "b@example.com", + accountId: "acc_b", + refreshToken: "refresh-b", + accessToken: "access-b", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }, + quotaCache: null, + displaySettings: { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + }, + }); + + expect(state.selectedAccountIndex).toBe(0); + expect(state.detailPane?.account.email).toBe("b@example.com"); + expect(state.detailPane?.actions.map((action) => action.id)).toEqual([ + "back", + "toggle", + "set-current", + "refresh", + "delete", + ]); + expect(state.modal).toBeNull(); + }); + it("maps login menu outcomes into renderer-agnostic commands", () => { expect(resolveAuthDashboardCommand({ mode: "settings" })).toEqual({ type: "open-settings", @@ -140,4 +197,64 @@ describe("auth ui controller seam", () => { panel: { title: "Applying Change", stage: "Updating selected account" }, }); }); + + it("resolves auth dashboard selections, detail actions, and modal confirmations outside the renderer", () => { + const account = { + index: 0, + sourceIndex: 3, + quickSwitchNumber: 4, + email: "sorted@example.com", + status: "active" as const, + enabled: true, + }; + + expect(resolveAuthDashboardSelection({ type: "set-current-account", account })).toEqual({ + type: "result", + result: { mode: "manage", switchAccountIndex: 3 }, + }); + + const detailResolution = resolveAuthDashboardSelection({ type: "select-account", account }); + if (detailResolution.type !== "detail") { + throw new Error("expected detail resolution"); + } + expect(detailResolution.detail.title).toContain("sorted@example.com"); + + const modalResolution = resolveAuthAccountDetailSelection(account, "refresh"); + if (modalResolution.type !== "confirm") { + throw new Error("expected confirm resolution"); + } + expect(modalResolution.modal.id).toBe("refresh-account"); + expect(settleAuthConfirmation(modalResolution.modal, false)).toEqual({ type: "continue" }); + expect(settleAuthConfirmation(modalResolution.modal, true)).toEqual({ + type: "result", + result: { mode: "manage", refreshAccountIndex: 3 }, + }); + + expect(resolveAuthDashboardSelection({ type: "delete-all" })).toEqual({ + type: "confirm", + modal: { + id: "delete-all", + message: "Delete all accounts?", + confirmStyle: "typed-delete", + result: { mode: "fresh", deleteAll: true }, + cancelMessage: "\nDelete all cancelled.\n", + }, + }); + }); + + it("builds detail-pane view models without renderer dependencies", () => { + const detail = buildAuthAccountDetailViewModel({ + index: 1, + quickSwitchNumber: 2, + email: "detail@example.com", + status: "disabled", + enabled: false, + addedAt: 1, + lastUsed: 1, + }); + + expect(detail.title).toContain("detail@example.com"); + expect(detail.title).toContain("[disabled]"); + expect(detail.actions[1]).toEqual({ id: "toggle", label: "Enable Account", tone: "green" }); + }); }); diff --git a/test/cli-auth-menu.test.ts b/test/cli-auth-menu.test.ts index 0f06f2c3..a8552deb 100644 --- a/test/cli-auth-menu.test.ts +++ b/test/cli-auth-menu.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const showAuthMenu = vi.fn(); const showAccountDetails = vi.fn(); const isTTY = vi.fn(); +const confirm = vi.fn(); const mockRl = { question: vi.fn(), close: vi.fn(), @@ -18,15 +19,21 @@ vi.mock("../lib/ui/auth-menu.js", () => ({ isTTY, })); +vi.mock("../lib/ui/confirm.js", () => ({ + confirm, +})); + describe("CLI auth menu shortcuts", () => { beforeEach(() => { vi.resetModules(); showAuthMenu.mockReset(); showAccountDetails.mockReset(); isTTY.mockReset(); + confirm.mockReset(); mockRl.question.mockReset(); mockRl.close.mockReset(); isTTY.mockReturnValue(true); + confirm.mockResolvedValue(true); process.env.FORCE_INTERACTIVE_MODE = "1"; }); @@ -268,6 +275,31 @@ describe("CLI auth menu shortcuts", () => { consoleSpy.mockRestore(); }); + it("routes fallback prompt when TTY is unavailable", async () => { + isTTY.mockReturnValue(false); + mockRl.question.mockResolvedValueOnce("forecast"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0, email: "ttyless@example.com" }]); + + expect(result).toEqual({ mode: "forecast" }); + expect(mockRl.question).toHaveBeenCalledTimes(1); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("re-prompts fallback selection until a valid mode is chosen", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + isTTY.mockReturnValue(false); + mockRl.question.mockResolvedValueOnce("invalid").mockResolvedValueOnce("q"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0, email: "retry@example.com" }]); + + expect(result).toEqual({ mode: "cancel" }); + expect(mockRl.question).toHaveBeenCalledTimes(2); + consoleSpy.mockRestore(); + }); + it("returns fresh mode when delete-all is confirmed", async () => { mockRl.question.mockResolvedValueOnce("DELETE"); showAuthMenu.mockResolvedValueOnce({ type: "delete-all" }); diff --git a/test/codex-manager-cli-manage.test.ts b/test/codex-manager-cli-manage.test.ts new file mode 100644 index 00000000..0117b9aa --- /dev/null +++ b/test/codex-manager-cli-manage.test.ts @@ -0,0 +1,607 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const loadAccountsMock = vi.fn(); +const loadFlaggedAccountsMock = vi.fn(); +const getRestoreAssessmentMock = vi.fn(); +const saveAccountsMock = vi.fn(); +const saveFlaggedAccountsMock = vi.fn(); +const clearAccountsMock = vi.fn(); +const createEmptyAccountStorageMock = vi.fn(() => ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, +})); +const withAccountStorageTransactionMock = vi.fn(); +const setStoragePathMock = vi.fn(); +const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +const queuedRefreshMock = vi.fn(); +const setCodexCliActiveSelectionMock = vi.fn(); +const promptAddAnotherAccountMock = vi.fn(); +const promptLoginModeMock = vi.fn(); +const promptOpenTuiAuthDashboardMock = vi.fn(); +const promptInkAuthDashboardMock = vi.fn(); +const configureInkUnifiedSettingsMock = vi.fn(); +const promptInkRestoreForLoginMock = vi.fn(); +const isNonInteractiveModeMock = vi.fn(); +const fetchCodexQuotaSnapshotMock = vi.fn(); +const loadDashboardDisplaySettingsMock = vi.fn(); +const saveDashboardDisplaySettingsMock = vi.fn(); +const loadQuotaCacheMock = vi.fn(); +const saveQuotaCacheMock = vi.fn(); +const loadPluginConfigMock = vi.fn(); +const savePluginConfigMock = vi.fn(); +const selectMock = vi.fn(); + +vi.mock("../lib/logger.js", () => ({ + createLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), + logWarn: vi.fn(), +})); + +vi.mock("../lib/auth/auth.js", () => ({ + createAuthorizationFlow: vi.fn(), + exchangeAuthorizationCode: vi.fn(), + parseAuthorizationInput: vi.fn(), + REDIRECT_URI: "http://localhost:1455/auth/callback", +})); + +vi.mock("../lib/auth/browser.js", () => ({ + openBrowserUrl: vi.fn(), + copyTextToClipboard: vi.fn(() => true), +})); + +vi.mock("../lib/auth/server.js", () => ({ + startLocalOAuthServer: vi.fn(), +})); + +vi.mock("../lib/cli.js", () => ({ + isNonInteractiveMode: isNonInteractiveModeMock, + promptAddAnotherAccount: promptAddAnotherAccountMock, + promptLoginMode: promptLoginModeMock, +})); + +vi.mock("../runtime/opentui/prompt.js", () => ({ + promptOpenTuiAuthDashboard: promptOpenTuiAuthDashboardMock, +})); + +vi.mock("../lib/ui-ink/index.js", () => ({ + configureInkUnifiedSettings: configureInkUnifiedSettingsMock, + promptInkAuthDashboard: promptInkAuthDashboardMock, + promptInkRestoreForLogin: promptInkRestoreForLoginMock, +})); + +vi.mock("../lib/prompts/codex.js", () => ({ + MODEL_FAMILIES: ["codex"] as const, +})); + +vi.mock("../lib/accounts.js", () => ({ + extractAccountEmail: vi.fn(() => undefined), + extractAccountId: vi.fn(() => "acc_test"), + formatAccountLabel: vi.fn((account: { email?: string }, index: number) => + account.email ? `${index + 1}. ${account.email}` : `Account ${index + 1}`, + ), + formatCooldown: vi.fn(() => null), + formatWaitTime: vi.fn((ms: number) => `${Math.max(1, Math.round(ms / 1000))}s`), + getAccountIdCandidates: vi.fn(() => []), + resolveRequestAccountId: vi.fn( + (_override: string | undefined, _source: string | undefined, tokenId: string | undefined) => + tokenId, + ), + sanitizeEmail: vi.fn((email: string | undefined) => + typeof email === "string" ? email.toLowerCase() : undefined, + ), + selectBestAccountCandidate: vi.fn(() => null), +})); + +vi.mock("../lib/storage.js", () => ({ + cloneAccountStorage: vi.fn((storage: unknown) => + storage == null ? storage : structuredClone(storage), + ), + createEmptyAccountStorage: createEmptyAccountStorageMock, + getRestoreAssessment: getRestoreAssessmentMock, + loadAccounts: loadAccountsMock, + loadFlaggedAccounts: loadFlaggedAccountsMock, + saveAccounts: saveAccountsMock, + saveFlaggedAccounts: saveFlaggedAccountsMock, + clearAccounts: clearAccountsMock, + setStoragePath: setStoragePathMock, + getStoragePath: getStoragePathMock, + withAccountStorageTransaction: withAccountStorageTransactionMock, +})); + +vi.mock("../lib/refresh-queue.js", () => ({ + queuedRefresh: queuedRefreshMock, +})); + +vi.mock("../lib/codex-cli/writer.js", () => ({ + setCodexCliActiveSelection: setCodexCliActiveSelectionMock, +})); + +vi.mock("../lib/quota-probe.js", () => ({ + fetchCodexQuotaSnapshot: fetchCodexQuotaSnapshotMock, + formatQuotaSnapshotLine: vi.fn(() => "probe-ok"), +})); + +vi.mock("../lib/dashboard-settings.js", () => ({ + DEFAULT_DASHBOARD_DISPLAY_SETTINGS: { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + getDashboardSettingsPath: vi.fn(() => "/mock/dashboard-settings.json"), + loadDashboardDisplaySettings: loadDashboardDisplaySettingsMock, + saveDashboardDisplaySettings: saveDashboardDisplaySettingsMock, +})); + +vi.mock("../lib/config.js", () => ({ + DEFAULT_PLUGIN_CONFIG: { + codexMode: true, + codexTuiV2: true, + codexTuiColorProfile: "truecolor", + codexTuiGlyphMode: "ascii", + fastSession: false, + fastSessionStrategy: "hybrid", + fastSessionMaxInputItems: 30, + retryAllAccountsRateLimited: true, + retryAllAccountsMaxWaitMs: 0, + retryAllAccountsMaxRetries: Infinity, + unsupportedCodexPolicy: "strict", + fallbackOnUnsupportedCodexModel: false, + fallbackToGpt52OnUnsupportedGpt53: true, + unsupportedCodexFallbackChain: {}, + tokenRefreshSkewMs: 60_000, + rateLimitToastDebounceMs: 60_000, + toastDurationMs: 5_000, + perProjectAccounts: true, + sessionRecovery: true, + autoResume: true, + parallelProbing: false, + parallelProbingMaxConcurrency: 2, + emptyResponseMaxRetries: 2, + emptyResponseRetryDelayMs: 1_000, + pidOffsetEnabled: false, + fetchTimeoutMs: 60_000, + streamStallTimeoutMs: 45_000, + liveAccountSync: true, + liveAccountSyncDebounceMs: 250, + liveAccountSyncPollMs: 2_000, + sessionAffinity: true, + sessionAffinityTtlMs: 1_200_000, + sessionAffinityMaxEntries: 512, + proactiveRefreshGuardian: true, + proactiveRefreshIntervalMs: 60_000, + proactiveRefreshBufferMs: 300_000, + networkErrorCooldownMs: 6_000, + serverErrorCooldownMs: 4_000, + storageBackupEnabled: true, + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 5, + preemptiveQuotaRemainingPercent7d: 5, + preemptiveQuotaMaxDeferralMs: 7_200_000, + }, + getDefaultPluginConfig: vi.fn(() => ({ + codexMode: true, + codexTuiV2: true, + codexTuiColorProfile: "truecolor", + codexTuiGlyphMode: "ascii", + fastSession: false, + fastSessionStrategy: "hybrid", + fastSessionMaxInputItems: 30, + retryAllAccountsRateLimited: true, + retryAllAccountsMaxWaitMs: 0, + retryAllAccountsMaxRetries: Infinity, + unsupportedCodexPolicy: "strict", + fallbackOnUnsupportedCodexModel: false, + fallbackToGpt52OnUnsupportedGpt53: true, + unsupportedCodexFallbackChain: {}, + tokenRefreshSkewMs: 60_000, + rateLimitToastDebounceMs: 60_000, + toastDurationMs: 5_000, + perProjectAccounts: true, + sessionRecovery: true, + autoResume: true, + parallelProbing: false, + parallelProbingMaxConcurrency: 2, + emptyResponseMaxRetries: 2, + emptyResponseRetryDelayMs: 1_000, + pidOffsetEnabled: false, + fetchTimeoutMs: 60_000, + streamStallTimeoutMs: 45_000, + liveAccountSync: true, + liveAccountSyncDebounceMs: 250, + liveAccountSyncPollMs: 2_000, + sessionAffinity: true, + sessionAffinityTtlMs: 1_200_000, + sessionAffinityMaxEntries: 512, + proactiveRefreshGuardian: true, + proactiveRefreshIntervalMs: 60_000, + proactiveRefreshBufferMs: 300_000, + networkErrorCooldownMs: 6_000, + serverErrorCooldownMs: 4_000, + storageBackupEnabled: true, + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 5, + preemptiveQuotaRemainingPercent7d: 5, + preemptiveQuotaMaxDeferralMs: 7_200_000, + })), + loadPluginConfig: loadPluginConfigMock, + savePluginConfig: savePluginConfigMock, +})); + +vi.mock("../lib/quota-cache.js", () => ({ + loadQuotaCache: loadQuotaCacheMock, + saveQuotaCache: saveQuotaCacheMock, +})); + +vi.mock("../lib/ui/select.js", () => ({ + select: selectMock, +})); + +const stdinIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); +const stdoutIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); +let defaultConsoleLogSpy: ReturnType | null = null; + +function setInteractiveTTY(enabled: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { + value: enabled, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: enabled, + configurable: true, + }); +} + +function restoreTTYDescriptors(): void { + if (stdinIsTTYDescriptor) { + Object.defineProperty(process.stdin, "isTTY", stdinIsTTYDescriptor); + } else { + delete (process.stdin as unknown as { isTTY?: boolean }).isTTY; + } + if (stdoutIsTTYDescriptor) { + Object.defineProperty(process.stdout, "isTTY", stdoutIsTTYDescriptor); + } else { + delete (process.stdout as unknown as { isTTY?: boolean }).isTTY; + } +} + +function createDeferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeErrnoError(message: string, code: string): NodeJS.ErrnoException { + const error = new Error(message) as NodeJS.ErrnoException; + error.code = code; + return error; +} + +function createRestoreAssessment(overrides: Partial<{ + storagePath: string; + restoreEligible: boolean; + restoreReason: "empty-storage" | "intentional-reset" | "missing-storage"; + latestSnapshot: { + kind: string; + path: string; + exists: boolean; + valid: boolean; + accountCount?: number; + bytes?: number; + mtimeMs?: number; + version?: number; + }; + backupMetadata: { + accounts: { + storagePath: string; + latestValidPath?: string; + snapshotCount: number; + validSnapshotCount: number; + snapshots: Array>; + }; + flaggedAccounts: { + storagePath: string; + latestValidPath?: string; + snapshotCount: number; + validSnapshotCount: number; + snapshots: Array>; + }; + }; +}> = {}) { + const storagePath = overrides.storagePath ?? "/mock/openai-codex-accounts.json"; + const flaggedPath = "/mock/openai-codex-flagged-accounts.json"; + return { + storagePath, + restoreEligible: overrides.restoreEligible ?? false, + restoreReason: overrides.restoreReason, + latestSnapshot: overrides.latestSnapshot, + backupMetadata: overrides.backupMetadata ?? { + accounts: { + storagePath, + snapshotCount: 0, + validSnapshotCount: 0, + snapshots: [], + }, + flaggedAccounts: { + storagePath: flaggedPath, + snapshotCount: 0, + validSnapshotCount: 0, + snapshots: [], + }, + }, + }; +} + +describe("codex manager cli commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + defaultConsoleLogSpy?.mockRestore(); + defaultConsoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + loadAccountsMock.mockReset(); + loadFlaggedAccountsMock.mockReset(); + getRestoreAssessmentMock.mockReset(); + saveAccountsMock.mockReset(); + saveFlaggedAccountsMock.mockReset(); + clearAccountsMock.mockReset(); + createEmptyAccountStorageMock.mockReset(); + withAccountStorageTransactionMock.mockReset(); + queuedRefreshMock.mockReset(); + setCodexCliActiveSelectionMock.mockReset(); + promptAddAnotherAccountMock.mockReset(); + promptLoginModeMock.mockReset(); + promptOpenTuiAuthDashboardMock.mockReset(); + promptInkAuthDashboardMock.mockReset(); + configureInkUnifiedSettingsMock.mockReset(); + promptInkRestoreForLoginMock.mockReset(); + isNonInteractiveModeMock.mockReset(); + fetchCodexQuotaSnapshotMock.mockReset(); + loadDashboardDisplaySettingsMock.mockReset(); + saveDashboardDisplaySettingsMock.mockReset(); + loadQuotaCacheMock.mockReset(); + saveQuotaCacheMock.mockReset(); + loadPluginConfigMock.mockReset(); + savePluginConfigMock.mockReset(); + selectMock.mockReset(); + fetchCodexQuotaSnapshotMock.mockResolvedValue({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: {}, + }); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [], + }); + getRestoreAssessmentMock.mockResolvedValue(createRestoreAssessment()); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + loadPluginConfigMock.mockReturnValue({}); + savePluginConfigMock.mockResolvedValue(undefined); + isNonInteractiveModeMock.mockImplementation(() => { + if (process.env.FORCE_INTERACTIVE_MODE === "1") return false; + return !process.stdin.isTTY || !process.stdout.isTTY; + }); + selectMock.mockResolvedValue(undefined); + promptOpenTuiAuthDashboardMock.mockResolvedValue(null); + promptInkAuthDashboardMock.mockResolvedValue(null); + configureInkUnifiedSettingsMock.mockResolvedValue(false); + promptInkRestoreForLoginMock.mockResolvedValue(null); + restoreTTYDescriptors(); + setInteractiveTTY(true); + setStoragePathMock.mockReset(); + getStoragePathMock.mockReturnValue("/mock/openai-codex-accounts.json"); + withAccountStorageTransactionMock.mockImplementation(async (handler) => { + const latestLoadResult = loadAccountsMock.mock.results[loadAccountsMock.mock.results.length - 1]; + const current = latestLoadResult ? await latestLoadResult.value : await loadAccountsMock(); + return handler( + current == null ? current : structuredClone(current), + async (storage) => saveAccountsMock(storage), + ); + }); + }); + + afterEach(() => { + defaultConsoleLogSpy?.mockRestore(); + defaultConsoleLogSpy = null; + restoreTTYDescriptors(); + vi.restoreAllMocks(); + }); + + it("deletes an account from manage mode and persists storage", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "second@example.com", + refreshToken: "refresh-second", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptOpenTuiAuthDashboardMock + .mockResolvedValueOnce({ mode: "manage", deleteAccountIndex: 1 }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe("first@example.com"); + }); + + it("toggles account enabled state from manage mode", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "toggle@example.com", + refreshToken: "refresh-toggle", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptOpenTuiAuthDashboardMock + .mockResolvedValueOnce({ mode: "manage", toggleAccountIndex: 0 }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe(false); + }); + + it("refreshes a specific account from manage mode and persists new tokens", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + accessToken: "access-first", + expiresAt: now - 1_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "second@example.com", + refreshToken: "refresh-second", + accessToken: "access-second", + expiresAt: now - 1_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }); + promptOpenTuiAuthDashboardMock + .mockResolvedValueOnce({ mode: "manage", refreshAccountIndex: 1 }) + .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce("browser"); + const authModule = await import("../lib/auth/auth.js"); + const createAuthorizationFlowMock = vi.mocked(authModule.createAuthorizationFlow); + const exchangeAuthorizationCodeMock = vi.mocked(authModule.exchangeAuthorizationCode); + const browserModule = await import("../lib/auth/browser.js"); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); + const serverModule = await import("../lib/auth/server.js"); + const startLocalOAuthServerMock = vi.mocked(serverModule.startLocalOAuthServer); + + createAuthorizationFlowMock.mockResolvedValue({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + exchangeAuthorizationCodeMock.mockResolvedValue({ + type: "success", + access: "access-second-next", + refresh: "refresh-second-next", + expires: now + 3_600_000, + }); + openBrowserUrlMock.mockReturnValue(true); + startLocalOAuthServerMock.mockResolvedValue({ + ready: true, + waitForCode: vi.fn(async () => ({ code: "oauth-code" })), + close: vi.fn(), + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + const saved = saveAccountsMock.mock.calls[0]?.[0]; + expect(saved?.accounts?.length).toBeGreaterThanOrEqual(2); + expect(saved?.accounts?.some((account) => account?.email === "second@example.com")).toBe(true); + }); + + it("resets all accounts through transactional persistence", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "reset@example.com", + refreshToken: "refresh-reset", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptOpenTuiAuthDashboardMock + .mockResolvedValueOnce({ mode: "fresh", deleteAll: true }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(withAccountStorageTransactionMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + }); + + +}); diff --git a/test/codex-manager-cli-noninteractive.test.ts b/test/codex-manager-cli-noninteractive.test.ts new file mode 100644 index 00000000..97f406ad --- /dev/null +++ b/test/codex-manager-cli-noninteractive.test.ts @@ -0,0 +1,503 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const loadAccountsMock = vi.fn(); +const loadFlaggedAccountsMock = vi.fn(); +const getRestoreAssessmentMock = vi.fn(); +const saveAccountsMock = vi.fn(); +const saveFlaggedAccountsMock = vi.fn(); +const clearAccountsMock = vi.fn(); +const createEmptyAccountStorageMock = vi.fn(() => ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, +})); +const withAccountStorageTransactionMock = vi.fn(); +const setStoragePathMock = vi.fn(); +const getStoragePathMock = vi.fn(() => "/mock/openai-codex-accounts.json"); +const queuedRefreshMock = vi.fn(); +const setCodexCliActiveSelectionMock = vi.fn(); +const promptAddAnotherAccountMock = vi.fn(); +const promptLoginModeMock = vi.fn(); +const promptOpenTuiAuthDashboardMock = vi.fn(); +const promptInkAuthDashboardMock = vi.fn(); +const configureInkUnifiedSettingsMock = vi.fn(); +const promptInkRestoreForLoginMock = vi.fn(); +const isNonInteractiveModeMock = vi.fn(); +const fetchCodexQuotaSnapshotMock = vi.fn(); +const loadDashboardDisplaySettingsMock = vi.fn(); +const saveDashboardDisplaySettingsMock = vi.fn(); +const loadQuotaCacheMock = vi.fn(); +const saveQuotaCacheMock = vi.fn(); +const loadPluginConfigMock = vi.fn(); +const savePluginConfigMock = vi.fn(); +const selectMock = vi.fn(); + +vi.mock("../lib/logger.js", () => ({ + createLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), + logWarn: vi.fn(), +})); + +vi.mock("../lib/auth/auth.js", () => ({ + createAuthorizationFlow: vi.fn(), + exchangeAuthorizationCode: vi.fn(), + parseAuthorizationInput: vi.fn(), + REDIRECT_URI: "http://localhost:1455/auth/callback", +})); + +vi.mock("../lib/auth/browser.js", () => ({ + openBrowserUrl: vi.fn(), + copyTextToClipboard: vi.fn(() => true), +})); + +vi.mock("../lib/auth/server.js", () => ({ + startLocalOAuthServer: vi.fn(), +})); + +vi.mock("../lib/cli.js", () => ({ + isNonInteractiveMode: isNonInteractiveModeMock, + promptAddAnotherAccount: promptAddAnotherAccountMock, + promptLoginMode: promptLoginModeMock, +})); + +vi.mock("../runtime/opentui/prompt.js", () => ({ + promptOpenTuiAuthDashboard: promptOpenTuiAuthDashboardMock, +})); + +vi.mock("../lib/ui-ink/index.js", () => ({ + configureInkUnifiedSettings: configureInkUnifiedSettingsMock, + promptInkAuthDashboard: promptInkAuthDashboardMock, + promptInkRestoreForLogin: promptInkRestoreForLoginMock, +})); + +vi.mock("../lib/prompts/codex.js", () => ({ + MODEL_FAMILIES: ["codex"] as const, +})); + +vi.mock("../lib/accounts.js", () => ({ + extractAccountEmail: vi.fn(() => undefined), + extractAccountId: vi.fn(() => "acc_test"), + formatAccountLabel: vi.fn((account: { email?: string }, index: number) => + account.email ? `${index + 1}. ${account.email}` : `Account ${index + 1}`, + ), + formatCooldown: vi.fn(() => null), + formatWaitTime: vi.fn((ms: number) => `${Math.max(1, Math.round(ms / 1000))}s`), + getAccountIdCandidates: vi.fn(() => []), + resolveRequestAccountId: vi.fn( + (_override: string | undefined, _source: string | undefined, tokenId: string | undefined) => + tokenId, + ), + sanitizeEmail: vi.fn((email: string | undefined) => + typeof email === "string" ? email.toLowerCase() : undefined, + ), + selectBestAccountCandidate: vi.fn(() => null), +})); + +vi.mock("../lib/storage.js", () => ({ + cloneAccountStorage: vi.fn((storage: unknown) => + storage == null ? storage : structuredClone(storage), + ), + createEmptyAccountStorage: createEmptyAccountStorageMock, + getRestoreAssessment: getRestoreAssessmentMock, + loadAccounts: loadAccountsMock, + loadFlaggedAccounts: loadFlaggedAccountsMock, + saveAccounts: saveAccountsMock, + saveFlaggedAccounts: saveFlaggedAccountsMock, + clearAccounts: clearAccountsMock, + setStoragePath: setStoragePathMock, + getStoragePath: getStoragePathMock, + withAccountStorageTransaction: withAccountStorageTransactionMock, +})); + +vi.mock("../lib/refresh-queue.js", () => ({ + queuedRefresh: queuedRefreshMock, +})); + +vi.mock("../lib/codex-cli/writer.js", () => ({ + setCodexCliActiveSelection: setCodexCliActiveSelectionMock, +})); + +vi.mock("../lib/quota-probe.js", () => ({ + fetchCodexQuotaSnapshot: fetchCodexQuotaSnapshotMock, + formatQuotaSnapshotLine: vi.fn(() => "probe-ok"), +})); + +vi.mock("../lib/dashboard-settings.js", () => ({ + DEFAULT_DASHBOARD_DISPLAY_SETTINGS: { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + getDashboardSettingsPath: vi.fn(() => "/mock/dashboard-settings.json"), + loadDashboardDisplaySettings: loadDashboardDisplaySettingsMock, + saveDashboardDisplaySettings: saveDashboardDisplaySettingsMock, +})); + +vi.mock("../lib/config.js", () => ({ + DEFAULT_PLUGIN_CONFIG: { + codexMode: true, + codexTuiV2: true, + codexTuiColorProfile: "truecolor", + codexTuiGlyphMode: "ascii", + fastSession: false, + fastSessionStrategy: "hybrid", + fastSessionMaxInputItems: 30, + retryAllAccountsRateLimited: true, + retryAllAccountsMaxWaitMs: 0, + retryAllAccountsMaxRetries: Infinity, + unsupportedCodexPolicy: "strict", + fallbackOnUnsupportedCodexModel: false, + fallbackToGpt52OnUnsupportedGpt53: true, + unsupportedCodexFallbackChain: {}, + tokenRefreshSkewMs: 60_000, + rateLimitToastDebounceMs: 60_000, + toastDurationMs: 5_000, + perProjectAccounts: true, + sessionRecovery: true, + autoResume: true, + parallelProbing: false, + parallelProbingMaxConcurrency: 2, + emptyResponseMaxRetries: 2, + emptyResponseRetryDelayMs: 1_000, + pidOffsetEnabled: false, + fetchTimeoutMs: 60_000, + streamStallTimeoutMs: 45_000, + liveAccountSync: true, + liveAccountSyncDebounceMs: 250, + liveAccountSyncPollMs: 2_000, + sessionAffinity: true, + sessionAffinityTtlMs: 1_200_000, + sessionAffinityMaxEntries: 512, + proactiveRefreshGuardian: true, + proactiveRefreshIntervalMs: 60_000, + proactiveRefreshBufferMs: 300_000, + networkErrorCooldownMs: 6_000, + serverErrorCooldownMs: 4_000, + storageBackupEnabled: true, + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 5, + preemptiveQuotaRemainingPercent7d: 5, + preemptiveQuotaMaxDeferralMs: 7_200_000, + }, + getDefaultPluginConfig: vi.fn(() => ({ + codexMode: true, + codexTuiV2: true, + codexTuiColorProfile: "truecolor", + codexTuiGlyphMode: "ascii", + fastSession: false, + fastSessionStrategy: "hybrid", + fastSessionMaxInputItems: 30, + retryAllAccountsRateLimited: true, + retryAllAccountsMaxWaitMs: 0, + retryAllAccountsMaxRetries: Infinity, + unsupportedCodexPolicy: "strict", + fallbackOnUnsupportedCodexModel: false, + fallbackToGpt52OnUnsupportedGpt53: true, + unsupportedCodexFallbackChain: {}, + tokenRefreshSkewMs: 60_000, + rateLimitToastDebounceMs: 60_000, + toastDurationMs: 5_000, + perProjectAccounts: true, + sessionRecovery: true, + autoResume: true, + parallelProbing: false, + parallelProbingMaxConcurrency: 2, + emptyResponseMaxRetries: 2, + emptyResponseRetryDelayMs: 1_000, + pidOffsetEnabled: false, + fetchTimeoutMs: 60_000, + streamStallTimeoutMs: 45_000, + liveAccountSync: true, + liveAccountSyncDebounceMs: 250, + liveAccountSyncPollMs: 2_000, + sessionAffinity: true, + sessionAffinityTtlMs: 1_200_000, + sessionAffinityMaxEntries: 512, + proactiveRefreshGuardian: true, + proactiveRefreshIntervalMs: 60_000, + proactiveRefreshBufferMs: 300_000, + networkErrorCooldownMs: 6_000, + serverErrorCooldownMs: 4_000, + storageBackupEnabled: true, + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 5, + preemptiveQuotaRemainingPercent7d: 5, + preemptiveQuotaMaxDeferralMs: 7_200_000, + })), + loadPluginConfig: loadPluginConfigMock, + savePluginConfig: savePluginConfigMock, +})); + +vi.mock("../lib/quota-cache.js", () => ({ + loadQuotaCache: loadQuotaCacheMock, + saveQuotaCache: saveQuotaCacheMock, +})); + +vi.mock("../lib/ui/select.js", () => ({ + select: selectMock, +})); + +const stdinIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); +const stdoutIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); +let defaultConsoleLogSpy: ReturnType | null = null; + +function setInteractiveTTY(enabled: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { + value: enabled, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: enabled, + configurable: true, + }); +} + +function restoreTTYDescriptors(): void { + if (stdinIsTTYDescriptor) { + Object.defineProperty(process.stdin, "isTTY", stdinIsTTYDescriptor); + } else { + delete (process.stdin as unknown as { isTTY?: boolean }).isTTY; + } + if (stdoutIsTTYDescriptor) { + Object.defineProperty(process.stdout, "isTTY", stdoutIsTTYDescriptor); + } else { + delete (process.stdout as unknown as { isTTY?: boolean }).isTTY; + } +} + +function createDeferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeErrnoError(message: string, code: string): NodeJS.ErrnoException { + const error = new Error(message) as NodeJS.ErrnoException; + error.code = code; + return error; +} + +function createRestoreAssessment(overrides: Partial<{ + storagePath: string; + restoreEligible: boolean; + restoreReason: "empty-storage" | "intentional-reset" | "missing-storage"; + latestSnapshot: { + kind: string; + path: string; + exists: boolean; + valid: boolean; + accountCount?: number; + bytes?: number; + mtimeMs?: number; + version?: number; + }; + backupMetadata: { + accounts: { + storagePath: string; + latestValidPath?: string; + snapshotCount: number; + validSnapshotCount: number; + snapshots: Array>; + }; + flaggedAccounts: { + storagePath: string; + latestValidPath?: string; + snapshotCount: number; + validSnapshotCount: number; + snapshots: Array>; + }; + }; +}> = {}) { + const storagePath = overrides.storagePath ?? "/mock/openai-codex-accounts.json"; + const flaggedPath = "/mock/openai-codex-flagged-accounts.json"; + return { + storagePath, + restoreEligible: overrides.restoreEligible ?? false, + restoreReason: overrides.restoreReason, + latestSnapshot: overrides.latestSnapshot, + backupMetadata: overrides.backupMetadata ?? { + accounts: { + storagePath, + snapshotCount: 0, + validSnapshotCount: 0, + snapshots: [], + }, + flaggedAccounts: { + storagePath: flaggedPath, + snapshotCount: 0, + validSnapshotCount: 0, + snapshots: [], + }, + }, + }; +} + +describe("codex manager cli commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + defaultConsoleLogSpy?.mockRestore(); + defaultConsoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + loadAccountsMock.mockReset(); + loadFlaggedAccountsMock.mockReset(); + getRestoreAssessmentMock.mockReset(); + saveAccountsMock.mockReset(); + saveFlaggedAccountsMock.mockReset(); + clearAccountsMock.mockReset(); + createEmptyAccountStorageMock.mockReset(); + withAccountStorageTransactionMock.mockReset(); + queuedRefreshMock.mockReset(); + setCodexCliActiveSelectionMock.mockReset(); + promptAddAnotherAccountMock.mockReset(); + promptLoginModeMock.mockReset(); + promptOpenTuiAuthDashboardMock.mockReset(); + promptInkAuthDashboardMock.mockReset(); + configureInkUnifiedSettingsMock.mockReset(); + promptInkRestoreForLoginMock.mockReset(); + isNonInteractiveModeMock.mockReset(); + fetchCodexQuotaSnapshotMock.mockReset(); + loadDashboardDisplaySettingsMock.mockReset(); + saveDashboardDisplaySettingsMock.mockReset(); + loadQuotaCacheMock.mockReset(); + saveQuotaCacheMock.mockReset(); + loadPluginConfigMock.mockReset(); + savePluginConfigMock.mockReset(); + selectMock.mockReset(); + fetchCodexQuotaSnapshotMock.mockResolvedValue({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: {}, + }); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [], + }); + getRestoreAssessmentMock.mockResolvedValue(createRestoreAssessment()); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + loadPluginConfigMock.mockReturnValue({}); + savePluginConfigMock.mockResolvedValue(undefined); + isNonInteractiveModeMock.mockImplementation(() => { + if (process.env.FORCE_INTERACTIVE_MODE === "1") return false; + return !process.stdin.isTTY || !process.stdout.isTTY; + }); + selectMock.mockResolvedValue(undefined); + promptOpenTuiAuthDashboardMock.mockResolvedValue(null); + promptInkAuthDashboardMock.mockResolvedValue(null); + configureInkUnifiedSettingsMock.mockResolvedValue(false); + promptInkRestoreForLoginMock.mockResolvedValue(null); + restoreTTYDescriptors(); + setInteractiveTTY(true); + setStoragePathMock.mockReset(); + getStoragePathMock.mockReturnValue("/mock/openai-codex-accounts.json"); + withAccountStorageTransactionMock.mockImplementation(async (handler) => { + const latestLoadResult = loadAccountsMock.mock.results[loadAccountsMock.mock.results.length - 1]; + const current = latestLoadResult ? await latestLoadResult.value : await loadAccountsMock(); + return handler( + current == null ? current : structuredClone(current), + async (storage) => saveAccountsMock(storage), + ); + }); + }); + + afterEach(() => { + defaultConsoleLogSpy?.mockRestore(); + defaultConsoleLogSpy = null; + restoreTTYDescriptors(); + vi.restoreAllMocks(); + }); + + it("skips interactive dashboard routing in non-interactive login mode", async () => { + const now = Date.now(); + setInteractiveTTY(false); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "non-tty@example.com", + accountId: "acc_non_tty", + refreshToken: "refresh-non-tty", + accessToken: "access-non-tty", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + + const authModule = await import("../lib/auth/auth.js"); + const createAuthorizationFlowMock = vi.mocked(authModule.createAuthorizationFlow); + const exchangeAuthorizationCodeMock = vi.mocked(authModule.exchangeAuthorizationCode); + const browserModule = await import("../lib/auth/browser.js"); + const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); + const serverModule = await import("../lib/auth/server.js"); + const startLocalOAuthServerMock = vi.mocked(serverModule.startLocalOAuthServer); + + createAuthorizationFlowMock.mockResolvedValue({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + exchangeAuthorizationCodeMock.mockResolvedValue({ + type: "success", + access: "access-non-tty-next", + refresh: "refresh-non-tty-next", + expires: now + 3_600_000, + }); + openBrowserUrlMock.mockReturnValue(true); + startLocalOAuthServerMock.mockResolvedValue({ + ready: true, + waitForCode: vi.fn(async () => ({ code: "oauth-code" })), + close: vi.fn(), + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(selectMock).not.toHaveBeenCalled(); + expect(promptOpenTuiAuthDashboardMock).not.toHaveBeenCalled(); + expect(promptInkAuthDashboardMock).not.toHaveBeenCalled(); + expect(saveDashboardDisplaySettingsMock).not.toHaveBeenCalled(); + expect(savePluginConfigMock).not.toHaveBeenCalled(); + expect(createAuthorizationFlowMock).toHaveBeenCalledTimes(1); + }); + +}); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index a26e459c..2c7bf7fb 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -19,6 +19,7 @@ const queuedRefreshMock = vi.fn(); const setCodexCliActiveSelectionMock = vi.fn(); const promptAddAnotherAccountMock = vi.fn(); const promptLoginModeMock = vi.fn(); +const promptOpenTuiAuthDashboardMock = vi.fn(); const promptInkAuthDashboardMock = vi.fn(); const configureInkUnifiedSettingsMock = vi.fn(); const promptInkRestoreForLoginMock = vi.fn(); @@ -64,6 +65,10 @@ vi.mock("../lib/cli.js", () => ({ promptLoginMode: promptLoginModeMock, })); +vi.mock("../runtime/opentui/prompt.js", () => ({ + promptOpenTuiAuthDashboard: promptOpenTuiAuthDashboardMock, +})); + vi.mock("../lib/ui-ink/index.js", () => ({ configureInkUnifiedSettings: configureInkUnifiedSettingsMock, promptInkAuthDashboard: promptInkAuthDashboardMock, @@ -140,14 +145,100 @@ vi.mock("../lib/dashboard-settings.js", () => ({ saveDashboardDisplaySettings: saveDashboardDisplaySettingsMock, })); -vi.mock("../lib/config.js", async () => { - const actual = await vi.importActual("../lib/config.js"); - return { - ...(actual as Record), - loadPluginConfig: loadPluginConfigMock, - savePluginConfig: savePluginConfigMock, - }; -}); +vi.mock("../lib/config.js", () => ({ + DEFAULT_PLUGIN_CONFIG: { + codexMode: true, + codexTuiV2: true, + codexTuiColorProfile: "truecolor", + codexTuiGlyphMode: "ascii", + fastSession: false, + fastSessionStrategy: "hybrid", + fastSessionMaxInputItems: 30, + retryAllAccountsRateLimited: true, + retryAllAccountsMaxWaitMs: 0, + retryAllAccountsMaxRetries: Infinity, + unsupportedCodexPolicy: "strict", + fallbackOnUnsupportedCodexModel: false, + fallbackToGpt52OnUnsupportedGpt53: true, + unsupportedCodexFallbackChain: {}, + tokenRefreshSkewMs: 60_000, + rateLimitToastDebounceMs: 60_000, + toastDurationMs: 5_000, + perProjectAccounts: true, + sessionRecovery: true, + autoResume: true, + parallelProbing: false, + parallelProbingMaxConcurrency: 2, + emptyResponseMaxRetries: 2, + emptyResponseRetryDelayMs: 1_000, + pidOffsetEnabled: false, + fetchTimeoutMs: 60_000, + streamStallTimeoutMs: 45_000, + liveAccountSync: true, + liveAccountSyncDebounceMs: 250, + liveAccountSyncPollMs: 2_000, + sessionAffinity: true, + sessionAffinityTtlMs: 1_200_000, + sessionAffinityMaxEntries: 512, + proactiveRefreshGuardian: true, + proactiveRefreshIntervalMs: 60_000, + proactiveRefreshBufferMs: 300_000, + networkErrorCooldownMs: 6_000, + serverErrorCooldownMs: 4_000, + storageBackupEnabled: true, + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 5, + preemptiveQuotaRemainingPercent7d: 5, + preemptiveQuotaMaxDeferralMs: 7_200_000, + }, + getDefaultPluginConfig: vi.fn(() => ({ + codexMode: true, + codexTuiV2: true, + codexTuiColorProfile: "truecolor", + codexTuiGlyphMode: "ascii", + fastSession: false, + fastSessionStrategy: "hybrid", + fastSessionMaxInputItems: 30, + retryAllAccountsRateLimited: true, + retryAllAccountsMaxWaitMs: 0, + retryAllAccountsMaxRetries: Infinity, + unsupportedCodexPolicy: "strict", + fallbackOnUnsupportedCodexModel: false, + fallbackToGpt52OnUnsupportedGpt53: true, + unsupportedCodexFallbackChain: {}, + tokenRefreshSkewMs: 60_000, + rateLimitToastDebounceMs: 60_000, + toastDurationMs: 5_000, + perProjectAccounts: true, + sessionRecovery: true, + autoResume: true, + parallelProbing: false, + parallelProbingMaxConcurrency: 2, + emptyResponseMaxRetries: 2, + emptyResponseRetryDelayMs: 1_000, + pidOffsetEnabled: false, + fetchTimeoutMs: 60_000, + streamStallTimeoutMs: 45_000, + liveAccountSync: true, + liveAccountSyncDebounceMs: 250, + liveAccountSyncPollMs: 2_000, + sessionAffinity: true, + sessionAffinityTtlMs: 1_200_000, + sessionAffinityMaxEntries: 512, + proactiveRefreshGuardian: true, + proactiveRefreshIntervalMs: 60_000, + proactiveRefreshBufferMs: 300_000, + networkErrorCooldownMs: 6_000, + serverErrorCooldownMs: 4_000, + storageBackupEnabled: true, + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 5, + preemptiveQuotaRemainingPercent7d: 5, + preemptiveQuotaMaxDeferralMs: 7_200_000, + })), + loadPluginConfig: loadPluginConfigMock, + savePluginConfig: savePluginConfigMock, +})); vi.mock("../lib/quota-cache.js", () => ({ loadQuotaCache: loadQuotaCacheMock, @@ -160,6 +251,7 @@ vi.mock("../lib/ui/select.js", () => ({ const stdinIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); const stdoutIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); +let defaultConsoleLogSpy: ReturnType | null = null; function setInteractiveTTY(enabled: boolean): void { Object.defineProperty(process.stdin, "isTTY", { @@ -262,8 +354,9 @@ function createRestoreAssessment(overrides: Partial<{ describe("codex manager cli commands", () => { beforeEach(() => { - vi.resetModules(); vi.clearAllMocks(); + defaultConsoleLogSpy?.mockRestore(); + defaultConsoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); loadAccountsMock.mockReset(); loadFlaggedAccountsMock.mockReset(); getRestoreAssessmentMock.mockReset(); @@ -276,6 +369,7 @@ describe("codex manager cli commands", () => { setCodexCliActiveSelectionMock.mockReset(); promptAddAnotherAccountMock.mockReset(); promptLoginModeMock.mockReset(); + promptOpenTuiAuthDashboardMock.mockReset(); promptInkAuthDashboardMock.mockReset(); configureInkUnifiedSettingsMock.mockReset(); promptInkRestoreForLoginMock.mockReset(); @@ -322,10 +416,12 @@ describe("codex manager cli commands", () => { return !process.stdin.isTTY || !process.stdout.isTTY; }); selectMock.mockResolvedValue(undefined); + promptOpenTuiAuthDashboardMock.mockResolvedValue(null); promptInkAuthDashboardMock.mockResolvedValue(null); configureInkUnifiedSettingsMock.mockResolvedValue(false); promptInkRestoreForLoginMock.mockResolvedValue(null); restoreTTYDescriptors(); + setInteractiveTTY(true); setStoragePathMock.mockReset(); getStoragePathMock.mockReturnValue("/mock/openai-codex-accounts.json"); withAccountStorageTransactionMock.mockImplementation(async (handler) => { @@ -339,6 +435,8 @@ describe("codex manager cli commands", () => { }); afterEach(() => { + defaultConsoleLogSpy?.mockRestore(); + defaultConsoleLogSpy = null; restoreTTYDescriptors(); vi.restoreAllMocks(); }); @@ -883,7 +981,7 @@ describe("codex manager cli commands", () => { }; loadAccountsMock.mockResolvedValue(storage); setCodexCliActiveSelectionMock.mockResolvedValue(true); - promptInkAuthDashboardMock + promptOpenTuiAuthDashboardMock .mockResolvedValueOnce({ mode: "manage", switchAccountIndex: 1 }) .mockResolvedValueOnce({ mode: "cancel" }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -891,8 +989,8 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(promptInkAuthDashboardMock).toHaveBeenCalledTimes(2); - expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(promptOpenTuiAuthDashboardMock).toHaveBeenCalledTimes(2); + expect(promptInkAuthDashboardMock).not.toHaveBeenCalled(); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledWith( expect.stringContaining("Switched to account 2"), @@ -968,7 +1066,7 @@ describe("codex manager cli commands", () => { })); loadAccountsMock.mockResolvedValue(restoredStorage); selectMock.mockResolvedValueOnce(true); - promptInkAuthDashboardMock.mockResolvedValueOnce({ mode: "cancel" }); + promptOpenTuiAuthDashboardMock.mockResolvedValueOnce({ mode: "cancel" }); promptInkRestoreForLoginMock.mockResolvedValueOnce(true); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -984,14 +1082,17 @@ describe("codex manager cli commands", () => { }), ); expect(selectMock).not.toHaveBeenCalled(); - expect(promptInkAuthDashboardMock).toHaveBeenCalledTimes(1); - expect(promptInkAuthDashboardMock).toHaveBeenCalledWith( + expect(promptOpenTuiAuthDashboardMock).toHaveBeenCalledTimes(1); + expect(promptOpenTuiAuthDashboardMock).toHaveBeenCalledWith( expect.objectContaining({ - statusTextOverride: expect.stringContaining("Restored 1 account"), - statusToneOverride: "success", + dashboard: expect.objectContaining({ + menuOptions: expect.objectContaining({ + statusMessage: expect.stringContaining("Restored 1 account"), + }), + }), }), ); - expect(promptLoginModeMock).not.toHaveBeenCalled(); + expect(promptInkAuthDashboardMock).not.toHaveBeenCalled(); }); @@ -1079,6 +1180,7 @@ describe("codex manager cli commands", () => { expect( logSpy.mock.calls.some((call) => String(call[0]).includes("non-interactive mode skips the prompt")), ).toBe(true); + expect(promptOpenTuiAuthDashboardMock).not.toHaveBeenCalled(); }); it("marks newly added login account active so smart sort reflects it immediately", async () => { @@ -1118,9 +1220,10 @@ describe("codex manager cli commands", () => { saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); - promptLoginModeMock + promptOpenTuiAuthDashboardMock .mockResolvedValueOnce({ mode: "add" }) .mockResolvedValueOnce({ mode: "cancel" }); + selectMock.mockResolvedValueOnce("browser"); promptAddAnotherAccountMock.mockResolvedValue(false); const authModule = await import("../lib/auth/auth.js"); @@ -1184,7 +1287,7 @@ describe("codex manager cli commands", () => { ], }; loadAccountsMock.mockResolvedValue(storage); - promptLoginModeMock + promptOpenTuiAuthDashboardMock .mockResolvedValueOnce({ mode: "deep-check" }) .mockResolvedValueOnce({ mode: "cancel" }); queuedRefreshMock.mockResolvedValueOnce({ @@ -1223,7 +1326,7 @@ describe("codex manager cli commands", () => { ], }; loadAccountsMock.mockResolvedValue(storage); - promptLoginModeMock + promptOpenTuiAuthDashboardMock .mockResolvedValueOnce({ mode: "check" }) .mockResolvedValueOnce({ mode: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); @@ -1265,7 +1368,7 @@ describe("codex manager cli commands", () => { menuSortPinCurrent: true, menuSortQuickSwitchVisibleRow: true, }); - promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + promptOpenTuiAuthDashboardMock.mockResolvedValueOnce({ mode: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); @@ -1295,7 +1398,7 @@ describe("codex manager cli commands", () => { ], }; loadAccountsMock.mockResolvedValue(storage); - promptLoginModeMock + promptOpenTuiAuthDashboardMock .mockResolvedValueOnce({ mode: "settings" }) .mockResolvedValueOnce({ mode: "cancel" }); configureInkUnifiedSettingsMock.mockResolvedValueOnce(true); @@ -1305,7 +1408,8 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(configureInkUnifiedSettingsMock).toHaveBeenCalledTimes(1); expect(selectMock).not.toHaveBeenCalled(); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + expect(promptOpenTuiAuthDashboardMock).toHaveBeenCalledTimes(2); + expect(promptInkAuthDashboardMock).not.toHaveBeenCalled(); }); it("passes smart-sorted accounts to auth menu while preserving source index mapping", async () => { @@ -1386,13 +1490,13 @@ describe("codex manager cli commands", () => { }, }, }); - promptInkAuthDashboardMock.mockResolvedValueOnce({ mode: "cancel" }); + promptOpenTuiAuthDashboardMock.mockResolvedValueOnce({ mode: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - const firstCallAccounts = (promptInkAuthDashboardMock.mock.calls[0]?.[0] as { + const firstCallAccounts = (promptOpenTuiAuthDashboardMock.mock.calls[0]?.[0] as { dashboard: { accounts: Array<{ email?: string; index: number; @@ -1473,13 +1577,13 @@ describe("codex manager cli commands", () => { }, }, }); - promptInkAuthDashboardMock.mockResolvedValueOnce({ mode: "cancel" }); + promptOpenTuiAuthDashboardMock.mockResolvedValueOnce({ mode: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - const firstCallAccounts = (promptInkAuthDashboardMock.mock.calls[0]?.[0] as { + const firstCallAccounts = (promptOpenTuiAuthDashboardMock.mock.calls[0]?.[0] as { dashboard: { accounts: Array<{ email?: string; quickSwitchNumber?: number }> }; })?.dashboard.accounts ?? []; expect(firstCallAccounts.map((account) => account.email)).toEqual([ @@ -1489,6 +1593,36 @@ describe("codex manager cli commands", () => { expect(firstCallAccounts.map((account) => account.quickSwitchNumber)).toEqual([2, 1]); }); + it("falls back to Ink routing when OpenTUI reports unsupported", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "fallback@example.com", + accountId: "acc_fallback", + refreshToken: "refresh-fallback", + accessToken: "access-fallback", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptOpenTuiAuthDashboardMock.mockResolvedValueOnce(null); + promptInkAuthDashboardMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(promptOpenTuiAuthDashboardMock).toHaveBeenCalledTimes(1); + expect(promptInkAuthDashboardMock).toHaveBeenCalledTimes(1); + }); + it("runs doctor command in json mode", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ @@ -1622,7 +1756,7 @@ describe("codex manager cli commands", () => { ], }; loadAccountsMock.mockImplementation(async () => structuredClone(storage)); - promptLoginModeMock + promptOpenTuiAuthDashboardMock .mockResolvedValueOnce({ mode: "settings" }) .mockResolvedValueOnce({ mode: "cancel" }); @@ -1840,7 +1974,7 @@ describe("codex manager cli commands", () => { }, ], }); - promptLoginModeMock + promptOpenTuiAuthDashboardMock .mockResolvedValueOnce({ mode: "settings" }) .mockResolvedValueOnce({ mode: "cancel" }); @@ -1882,7 +2016,7 @@ describe("codex manager cli commands", () => { }, ], }); - promptLoginModeMock + promptOpenTuiAuthDashboardMock .mockResolvedValueOnce({ mode: "settings" }) .mockResolvedValueOnce({ mode: "cancel" }); @@ -1929,7 +2063,7 @@ describe("codex manager cli commands", () => { }, ], }); - promptLoginModeMock + promptOpenTuiAuthDashboardMock .mockResolvedValueOnce({ mode: "settings" }) .mockResolvedValueOnce({ mode: "cancel" }); @@ -1974,7 +2108,7 @@ describe("codex manager cli commands", () => { }, ], }); - promptLoginModeMock + promptOpenTuiAuthDashboardMock .mockResolvedValueOnce({ mode: "settings" }) .mockResolvedValueOnce({ mode: "cancel" }); const initialSettings = { @@ -2047,7 +2181,7 @@ describe("codex manager cli commands", () => { }, ], }); - promptLoginModeMock + promptOpenTuiAuthDashboardMock .mockResolvedValueOnce({ mode: "settings" }) .mockResolvedValueOnce({ mode: "cancel" }); @@ -2198,130 +2332,5 @@ describe("codex manager cli commands", () => { ).toBe(true); }); - it("deletes an account from manage mode and persists storage", async () => { - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "first@example.com", - refreshToken: "refresh-first", - addedAt: now - 2_000, - lastUsed: now - 2_000, - enabled: true, - }, - { - email: "second@example.com", - refreshToken: "refresh-second", - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "manage", deleteAccountIndex: 1 }) - .mockResolvedValueOnce({ mode: "cancel" }); - - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe("first@example.com"); - }); - it("toggles account enabled state from manage mode", async () => { - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "toggle@example.com", - refreshToken: "refresh-toggle", - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "manage", toggleAccountIndex: 0 }) - .mockResolvedValueOnce({ mode: "cancel" }); - - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(withAccountStorageTransactionMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe(false); - }); - - it("resets all accounts through transactional persistence", async () => { - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "reset@example.com", - refreshToken: "refresh-reset", - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "fresh", deleteAll: true }) - .mockResolvedValueOnce({ mode: "cancel" }); - - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(clearAccountsMock).toHaveBeenCalledTimes(1); - expect(withAccountStorageTransactionMock).not.toHaveBeenCalled(); - expect(saveAccountsMock).not.toHaveBeenCalled(); - }); - - it("keeps settings unchanged in non-interactive mode and returns to menu", async () => { - const now = Date.now(); - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "non-tty@example.com", - accountId: "acc_non_tty", - refreshToken: "refresh-non-tty", - accessToken: "access-non-tty", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "settings" }) - .mockResolvedValueOnce({ mode: "cancel" }); - - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(selectMock).not.toHaveBeenCalled(); - expect(saveDashboardDisplaySettingsMock).not.toHaveBeenCalled(); - expect(savePluginConfigMock).not.toHaveBeenCalled(); - }); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index db70c317..8c169e1a 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { promises as fs, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; +import { ACCOUNT_LIMITS } from "../lib/constants.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; import { deduplicateAccounts, @@ -215,7 +216,7 @@ describe("storage", () => { // @ts-ignore const { importAccounts } = await import("../lib/storage.js"); - const manyAccounts = Array.from({ length: 21 }, (_, i) => ({ + const manyAccounts = Array.from({ length: ACCOUNT_LIMITS.MAX_ACCOUNTS + 1 }, (_, i) => ({ accountId: `acct${i}`, refreshToken: `ref${i}`, addedAt: Date.now(), diff --git a/test/tui/account-details.test.tsx b/test/tui/account-details.test.tsx new file mode 100644 index 00000000..3c2d27ea --- /dev/null +++ b/test/tui/account-details.test.tsx @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; +import { buildAuthDashboardViewModel } from "../../lib/codex-manager/auth-ui-controller.js"; +import { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } from "../../lib/dashboard-settings.js"; +import { mountOpenTuiShellHarness } from "./harness.js"; + +function createDetailDashboard() { + const now = Date.now(); + return buildAuthDashboardViewModel({ + storage: { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + accounts: [ + { + email: "alpha@ex.com", + accountId: "acc_alpha", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + addedAt: now - 30_000, + lastUsed: now - 86_400_000, + enabled: true, + }, + { + email: "beta@ex.com", + accountId: "acc_beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 20_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "gamma@ex.com", + accountId: "acc_gamma", + refreshToken: "refresh-gamma", + accessToken: "access-gamma", + expiresAt: now + 3_600_000, + addedAt: now - 10_000, + lastUsed: now - 172_800_000, + enabled: true, + }, + ], + }, + quotaCache: { + byAccountId: {}, + byEmail: { + "alpha@ex.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 80, windowMinutes: 300, resetAtMs: now + 3_300_000 }, + secondary: { usedPercent: 70, windowMinutes: 10080, resetAtMs: now + 86_400_000 }, + }, + "beta@ex.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 0, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 0, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + "gamma@ex.com": { + updatedAt: now, + status: 429, + model: "gpt-5-codex", + primary: { usedPercent: 50, windowMinutes: 300, resetAtMs: now + 4_500_000 }, + secondary: { usedPercent: 20, windowMinutes: 10080, resetAtMs: now + 172_800_000 }, + }, + }, + }, + displaySettings: { + ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + flaggedCount: 1, + statusMessage: "Loading live limits...", + }); +} + +function findLine(frame: string, snippet: string): string { + return frame.split("\n").find((line) => line.includes(snippet)) ?? ""; +} + +describe("OpenTUI account detail pane", () => { + test("shows richer focused detail beside compact account rows", async () => { + const harness = await mountOpenTuiShellHarness({ + shell: { + dashboard: createDetailDashboard(), + }, + renderer: { + height: 28, + width: 132, + }, + }); + + try { + let frame = harness.captureCharFrame(); + expect(frame).toContain("Focused account"); + expect(frame).toContain("1. beta@ex.com"); + expect(frame).toContain("S Set current"); + expect(frame).toContain("R Re-login OAuth"); + expect(frame).toContain("D Delete (typed)"); + + harness.mockInput.pressArrow("down"); + await harness.renderOnce(); + + frame = harness.captureCharFrame(); + const compactRow = findLine(frame, "gamma@ex.com [ok]"); + expect(compactRow).toContain("5h50 7d80 limit"); + expect(compactRow).not.toContain("resets"); + expect(frame).toContain("gamma@ex.com [ok]"); + expect(frame).toContain("5h left 50% @"); + expect(frame).toContain("7d left 80% @"); + expect(frame).toContain("Alert: rate-limited"); + } finally { + await harness.destroy(); + } + }); +}); diff --git a/test/tui/account-workspace.test.tsx b/test/tui/account-workspace.test.tsx new file mode 100644 index 00000000..55875d9f --- /dev/null +++ b/test/tui/account-workspace.test.tsx @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import { buildAuthDashboardViewModel } from "../../lib/codex-manager/auth-ui-controller.js"; +import { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } from "../../lib/dashboard-settings.js"; +import { formatOpenTuiAccountRow } from "../../runtime/opentui/account-workspace.js"; +import { mountOpenTuiShellHarness } from "./harness.js"; + +function createWorkspaceDashboard() { + const now = Date.now(); + return buildAuthDashboardViewModel({ + storage: { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + accounts: [ + { + email: "alpha@ex.com", + accountId: "acc_alpha", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + addedAt: now - 30_000, + lastUsed: now - 86_400_000, + enabled: true, + }, + { + email: "beta@ex.com", + accountId: "acc_beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 20_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "gamma@ex.com", + accountId: "acc_gamma", + refreshToken: "refresh-gamma", + accessToken: "access-gamma", + expiresAt: now + 3_600_000, + addedAt: now - 10_000, + lastUsed: now - 172_800_000, + enabled: true, + }, + ], + }, + quotaCache: { + byAccountId: {}, + byEmail: { + "alpha@ex.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 80, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 70, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + "beta@ex.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 0, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 0, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + "gamma@ex.com": { + updatedAt: now, + status: 429, + model: "gpt-5-codex", + primary: { usedPercent: 50, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 20, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + }, + }, + displaySettings: { + ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + flaggedCount: 1, + statusMessage: "Loading live limits...", + }); +} + +describe("OpenTUI account workspace", () => { + test("renders compact single-line account rows with current, status, limits, and last-used data", async () => { + const harness = await mountOpenTuiShellHarness({ + shell: { + dashboard: createWorkspaceDashboard(), + }, + renderer: { + height: 28, + width: 132, + }, + }); + + try { + const frame = harness.captureCharFrame(); + expect(frame).toContain("1. * beta@ex.com [act] today 5h100 7d100"); + expect(frame).toContain("2. gamma@ex.com [ok] 2d 5h50 7d80 limit"); + expect(frame).toContain("3. alpha@ex.com [ok] yesterday 5h20 7d30"); + expect(frame).not.toContain("details shown on all rows"); + } finally { + await harness.destroy(); + } + }); + + test("filters visible rows with inline search and quick-switches using visible-row numbers only", async () => { + const harness = await mountOpenTuiShellHarness({ + shell: { + dashboard: createWorkspaceDashboard(), + }, + renderer: { + height: 28, + width: 132, + }, + }); + + try { + harness.mockInput.pressKey("/"); + await harness.renderOnce(); + await harness.mockInput.typeText("gamma"); + await harness.renderOnce(); + + const searchFrame = harness.captureCharFrame(); + expect(searchFrame).toContain("Search: gamma"); + expect(searchFrame).toContain("gamma@ex.com"); + expect(searchFrame).not.toContain("beta@ex.com"); + + harness.mockInput.pressEnter(); + await harness.renderOnce(); + harness.mockInput.pressKey("2"); + await harness.renderOnce(); + + expect(harness.workspaceActions.at(-1)).toEqual({ + type: "quick-switch", + sourceIndex: 2, + }); + expect(harness.selectionChanges.at(-1)?.accountLabel).toContain("gamma@ex.com"); + } finally { + await harness.destroy(); + } + }); + + test("collapses verbose quota and warning text into a compact row summary", () => { + const row = formatOpenTuiAccountRow({ + index: 0, + quickSwitchNumber: 1, + email: "verbose@ex.com", + status: "ok", + quotaRateLimited: true, + quotaSummary: "5h 40% left (resets 14:05), 7d 75% left (resets 16:40 on Mar 11), rate-limited", + enabled: true, + showLastUsed: false, + }); + + expect(row).toContain("1. verbose@ex.com [ok] 5h40 7d75 limit"); + expect(row).not.toContain("resets"); + expect(row).not.toContain("left ("); + }); +}); diff --git a/test/tui/app-shell.test.tsx b/test/tui/app-shell.test.tsx new file mode 100644 index 00000000..52d94939 --- /dev/null +++ b/test/tui/app-shell.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { mountOpenTuiShellHarness } from "./harness.js"; + +describe("OpenTUI shell harness", () => { + test("mounts, focuses the account list, and exits through keyboard plumbing", async () => { + const destroyCalls: string[] = []; + const harness = await mountOpenTuiShellHarness({ + renderer: { + onDestroy: () => { + destroyCalls.push("destroy"); + }, + }, + }); + + try { + const frame = harness.captureCharFrame(); + expect(frame).toContain("Account workspace"); + expect(frame).toContain("Focused account"); + expect(frame).not.toContain("Accountcworkspacents"); + expect(frame).not.toContain("QcBacks"); + expect(harness.readyContexts).toHaveLength(1); + expect(harness.readyContexts[0]?.accountListRef).toBeDefined(); + expect(harness.readyContexts[0]?.focusTarget).toBe("workspace"); + expect(harness.readyContexts[0]?.statusLineRef.plainText).toContain("rows 3/3"); + expect(harness.readyContexts[0]?.focusedRenderable).toBe(harness.readyContexts[0]?.accountListRef ?? null); + expect(harness.selectionChanges[0]).toEqual({ + accountIndex: 0, + accountLabel: "1. * beta@example.com [act] today 5h100 7d100", + focusTarget: "workspace", + navIndex: 0, + navLabel: "Accounts", + }); + + harness.mockInput.pressArrow("down"); + await harness.renderOnce(); + + expect(harness.keyNames).toContain("down"); + expect(harness.selectionChanges.at(-1)?.accountLabel).toContain("gamma@example.com"); + + harness.mockInput.pressKey("q"); + await Promise.resolve(); + + expect(harness.keyNames).toContain("q"); + expect(harness.exitReasons).toEqual(["quit"]); + expect(destroyCalls).toEqual(["destroy"]); + expect(harness.renderer.isDestroyed).toBe(true); + } finally { + await harness.destroy(); + } + }); +}); diff --git a/test/tui/bootstrap-boundaries.test.ts b/test/tui/bootstrap-boundaries.test.ts new file mode 100644 index 00000000..dd4bbe70 --- /dev/null +++ b/test/tui/bootstrap-boundaries.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { resolveOpenTuiAuthShellBootstrap } from "../../runtime/opentui/bootstrap.js"; + +function createMockStream(isTTY: boolean): NodeJS.ReadStream & NodeJS.WriteStream { + return { isTTY } as NodeJS.ReadStream & NodeJS.WriteStream; +} + +describe("OpenTUI bootstrap boundaries", () => { + test("rejects non-tty input or output streams", () => { + expect(resolveOpenTuiAuthShellBootstrap({ + stdin: createMockStream(false), + stdout: createMockStream(true), + env: {}, + })).toEqual({ supported: false, reason: "stdin-not-tty" }); + + expect(resolveOpenTuiAuthShellBootstrap({ + stdin: createMockStream(true), + stdout: createMockStream(false), + env: {}, + })).toEqual({ supported: false, reason: "stdout-not-tty" }); + }); + + test("preserves host-managed ui fallback boundaries", () => { + expect(resolveOpenTuiAuthShellBootstrap({ + stdin: createMockStream(true), + stdout: createMockStream(true), + env: { CODEX_TUI: "1" }, + })).toEqual({ supported: false, reason: "host-managed-ui" }); + + expect(resolveOpenTuiAuthShellBootstrap({ + stdin: createMockStream(true), + stdout: createMockStream(true), + env: { CODEX_TUI: "1", FORCE_INTERACTIVE_MODE: "1" }, + })).toEqual({ supported: true }); + }); +}); diff --git a/test/tui/cleanup.test.tsx b/test/tui/cleanup.test.tsx new file mode 100644 index 00000000..360e7ce5 --- /dev/null +++ b/test/tui/cleanup.test.tsx @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { getShellListenerCounts, mountOpenTuiShellHarness } from "./harness.js"; + +describe("OpenTUI shell cleanup", () => { + test("cleans timers and listeners on repeated mount and destroy", async () => { + for (let cycle = 0; cycle < 2; cycle += 1) { + const destroyCalls: string[] = []; + const harness = await mountOpenTuiShellHarness({ + renderer: { + onDestroy: () => { + destroyCalls.push(`destroy-${cycle}`); + }, + }, + }); + + try { + const mountedListeners = getShellListenerCounts(harness.renderer); + + expect(harness.clock.getActiveTimerCount()).toBe(1); + expect(mountedListeners.selection).toBe(harness.baselineListenerCounts.selection + 1); + expect(mountedListeners.keypress).toBeGreaterThan(harness.baselineListenerCounts.keypress); + + await harness.destroy(); + + const cleanedListeners = getShellListenerCounts(harness.renderer); + + expect(harness.clock.getActiveTimerCount()).toBe(0); + expect(cleanedListeners.selection).toBe(harness.baselineListenerCounts.selection); + expect(cleanedListeners.keypress).toBe(harness.baselineListenerCounts.keypress); + expect(destroyCalls).toEqual([`destroy-${cycle}`]); + expect(harness.renderer.isDestroyed).toBe(true); + } finally { + await harness.destroy(); + } + } + }); + + test("cleans shell resources on escape exit", async () => { + const destroyCalls: string[] = []; + const harness = await mountOpenTuiShellHarness({ + renderer: { + onDestroy: () => { + destroyCalls.push("destroy"); + }, + }, + }); + + try { + expect(harness.clock.getActiveTimerCount()).toBe(1); + expect(getShellListenerCounts(harness.renderer).selection).toBe(harness.baselineListenerCounts.selection + 1); + + harness.mockInput.pressEscape(); + await Promise.resolve(); + + const cleanedListeners = getShellListenerCounts(harness.renderer); + + expect(harness.exitReasons).toEqual(["escape"]); + expect(harness.clock.getActiveTimerCount()).toBe(0); + expect(cleanedListeners.selection).toBe(harness.baselineListenerCounts.selection); + expect(cleanedListeners.keypress).toBe(harness.baselineListenerCounts.keypress); + expect(destroyCalls).toEqual(["destroy"]); + expect(harness.renderer.isDestroyed).toBe(true); + } finally { + await harness.destroy(); + } + }); +}); diff --git a/test/tui/harness.tsx b/test/tui/harness.tsx new file mode 100644 index 00000000..bae973a6 --- /dev/null +++ b/test/tui/harness.tsx @@ -0,0 +1,129 @@ +import { render } from "@opentui/solid"; +import { createTestRenderer, type TestRendererOptions } from "@opentui/core/testing"; +import { createComponent } from "solid-js"; +import type { + OpenTuiBootstrapAppProps, + OpenTuiShellExitReason, + OpenTuiShellFocusTarget, + OpenTuiShellReadyContext, + OpenTuiShellSelection, + OpenTuiWorkspaceAction, + OpenTuiShellTimer, +} from "../../runtime/opentui/app.js"; +import { OpenTuiBootstrapApp } from "../../runtime/opentui/app.js"; + +export type TrackedShellClock = { + activeTimers: Set; + getActiveTimerCount: () => number; + setInterval: (_callback: () => void, _intervalMs: number) => OpenTuiShellTimer; + clearInterval: (timer: OpenTuiShellTimer) => void; +}; + +export function createTrackedShellClock(): TrackedShellClock { + const activeTimers = new Set(); + let nextTimerId = 0; + + return { + activeTimers, + getActiveTimerCount: () => activeTimers.size, + setInterval: () => { + nextTimerId += 1; + activeTimers.add(nextTimerId); + return nextTimerId; + }, + clearInterval: (timer) => { + if (typeof timer === "number") { + activeTimers.delete(timer); + } + }, + }; +} + +export function getShellListenerCounts(renderer: { + listenerCount: (eventName: string) => number; + keyInput: { + listenerCount: (eventName: string) => number; + }; +}) { + return { + selection: renderer.listenerCount("selection"), + keypress: renderer.keyInput.listenerCount("keypress"), + }; +} + +export async function mountOpenTuiShellHarness(options: { + shell?: OpenTuiBootstrapAppProps; + renderer?: Partial; +} = {}) { + const readyContexts: OpenTuiShellReadyContext[] = []; + const selectionChanges: OpenTuiShellSelection[] = []; + const exitReasons: OpenTuiShellExitReason[] = []; + const focusTargets: OpenTuiShellFocusTarget[] = []; + const keyNames: string[] = []; + const workspaceActions: OpenTuiWorkspaceAction[] = []; + const trackedClock = options.shell?.clock ?? createTrackedShellClock(); + + const setup = await createTestRenderer( + { + autoFocus: true, + exitOnCtrlC: false, + height: 20, + kittyKeyboard: true, + targetFps: 30, + width: 72, + ...(options.renderer ?? {}), + }, + ); + + const baselineListenerCounts = getShellListenerCounts(setup.renderer); + + await render( + () => createComponent(OpenTuiBootstrapApp, { + ...(options.shell ?? {}), + clock: trackedClock, + onExit: (reason, renderer) => { + exitReasons.push(reason); + options.shell?.onExit?.(reason, renderer); + }, + onKeyPress: (keyEvent) => { + keyNames.push(keyEvent.name); + options.shell?.onKeyPress?.(keyEvent); + }, + onReady: (context) => { + readyContexts.push(context); + options.shell?.onReady?.(context); + }, + onSelectionChange: (selection) => { + selectionChanges.push(selection); + focusTargets.push(selection.focusTarget); + options.shell?.onSelectionChange?.(selection); + }, + onWorkspaceAction: (action) => { + workspaceActions.push(action); + options.shell?.onWorkspaceAction?.(action); + }, + }), + setup.renderer, + ); + + await setup.renderOnce(); + await Promise.resolve(); + + return { + ...setup, + baselineListenerCounts, + clock: trackedClock, + exitReasons, + focusTargets, + keyNames, + readyContexts, + selectionChanges, + workspaceActions, + destroy: async () => { + if (!setup.renderer.isDestroyed) { + setup.renderer.destroy(); + await Promise.resolve(); + } + }, + }; +} diff --git a/test/tui/navigation.test.tsx b/test/tui/navigation.test.tsx new file mode 100644 index 00000000..af47940c --- /dev/null +++ b/test/tui/navigation.test.tsx @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { mountOpenTuiShellHarness } from "./harness.js"; + +describe("OpenTUI shell navigation", () => { + test("renders a two-pane shell with a compact status line", async () => { + const harness = await mountOpenTuiShellHarness(); + + try { + const frame = harness.captureCharFrame(); + const frameLines = frame.split("\n"); + + expect(frame).toContain("codex auth"); + expect(frame).toContain("Account workspace"); + expect(frame).toContain("1. * beta@example"); + expect(frame).toContain("2. gamma@example"); + expect(frame).toContain("Focused account"); + expect(frame).toContain("focus workspace"); + expect(frame).not.toContain("Accountcworkspacents"); + expect(frame).not.toContain("QcBacks"); + expect(frame).not.toContain("OpenTUI shell harness"); + expect(frame).not.toContain("Focused: account list"); + expect(frame).not.toContain("Renderer heartbeat"); + expect(frame).toContain("rows 3/3"); + expect(frameLines.some((line) => line.includes("│") || line.includes("|"))).toBe(true); + expect(harness.readyContexts[0]?.modalHostRef.visible).toBe(false); + } finally { + await harness.destroy(); + } + }); + + test("switches focus between the nav rail and workspace without losing selection state", async () => { + const harness = await mountOpenTuiShellHarness(); + + try { + expect(harness.selectionChanges.at(-1)?.focusTarget).toBe("workspace"); + expect(harness.renderer.currentFocusedRenderable).toBe(harness.readyContexts[0]?.accountListRef ?? null); + + harness.mockInput.pressArrow("left"); + await harness.renderOnce(); + + expect(harness.selectionChanges.at(-1)?.focusTarget).toBe("nav"); + expect(harness.renderer.currentFocusedRenderable).toBe(harness.readyContexts[0]?.navRef ?? null); + + harness.mockInput.pressArrow("down"); + await harness.renderOnce(); + + expect(harness.selectionChanges.at(-1)?.navLabel).toBe("Add"); + expect(harness.selectionChanges.at(-1)?.accountLabel).toBe("Open the browser-first login flow"); + expect(harness.captureCharFrame()).toContain("Add account"); + + harness.mockInput.pressTab(); + await harness.renderOnce(); + + expect(harness.selectionChanges.at(-1)?.focusTarget).toBe("workspace"); + expect(harness.renderer.currentFocusedRenderable).toBe(harness.readyContexts[0]?.accountListRef ?? null); + + harness.mockInput.pressArrow("down"); + await harness.renderOnce(); + + expect(harness.selectionChanges.at(-1)?.accountLabel).toBe("Open the browser-first login flow"); + + harness.mockInput.pressTab({ shift: true }); + await harness.renderOnce(); + + expect(harness.selectionChanges.at(-1)?.focusTarget).toBe("nav"); + } finally { + await harness.destroy(); + } + }); +}); diff --git a/test/tui/opentui-auth-login-smoke.test.ts b/test/tui/opentui-auth-login-smoke.test.ts new file mode 100644 index 00000000..1b2a46ed --- /dev/null +++ b/test/tui/opentui-auth-login-smoke.test.ts @@ -0,0 +1,106 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { buildAuthDashboardViewModel } from "../../lib/codex-manager/auth-ui-controller.js"; +import { startOpenTuiAuthShell } from "../../runtime/opentui/bootstrap.js"; + +const stdinIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); +const stdoutIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); +const stdoutColumnsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns"); +const stdoutRowsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "rows"); + +function setInteractiveTTY(): void { + Object.defineProperty(process.stdin, "isTTY", { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, "columns", { + value: 120, + configurable: true, + }); + Object.defineProperty(process.stdout, "rows", { + value: 40, + configurable: true, + }); +} + +function restoreTTYDescriptors(): void { + if (stdinIsTTYDescriptor) { + Object.defineProperty(process.stdin, "isTTY", stdinIsTTYDescriptor); + } + if (stdoutIsTTYDescriptor) { + Object.defineProperty(process.stdout, "isTTY", stdoutIsTTYDescriptor); + } + if (stdoutColumnsDescriptor) { + Object.defineProperty(process.stdout, "columns", stdoutColumnsDescriptor); + } + if (stdoutRowsDescriptor) { + Object.defineProperty(process.stdout, "rows", stdoutRowsDescriptor); + } +} + +function loadFixtureDashboard() { + const storage = JSON.parse( + readFileSync(resolve(process.cwd(), "test/fixtures/v3-storage.json"), "utf-8"), + ) as { + version: number; + activeIndex: number; + accounts: Array>; + }; + + return buildAuthDashboardViewModel({ + storage: { + ...storage, + activeIndexByFamily: { codex: storage.activeIndex }, + }, + quotaCache: { + byAccountId: {}, + byEmail: {}, + }, + displaySettings: { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + flaggedCount: 0, + statusMessage: "Fixture-backed OpenTUI smoke", + }); +} + +afterEach(() => { + restoreTTYDescriptors(); +}); + +describe("OpenTUI auth login smoke", () => { + test("renders the auth shell in supported interactive conditions", async () => { + setInteractiveTTY(); + const dashboard = loadFixtureDashboard(); + let ready = false; + let statusLine = ""; + + const renderResult = await startOpenTuiAuthShell({ + dashboard, + onReady: ({ renderer, statusLineRef }) => { + ready = true; + statusLine = statusLineRef.plainText; + renderer.destroy(); + }, + }); + + expect(renderResult).not.toBeNull(); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); + expect(ready).toBe(true); + expect(statusLine).toContain("accounts"); + }); +}); diff --git a/test/tui/settings-drawer.test.tsx b/test/tui/settings-drawer.test.tsx new file mode 100644 index 00000000..4c5a2bef --- /dev/null +++ b/test/tui/settings-drawer.test.tsx @@ -0,0 +1,193 @@ +import { describe, expect, test } from "bun:test"; +import { getDefaultPluginConfig } from "../../lib/config.js"; +import { mountOpenTuiShellHarness } from "./harness.js"; + +async function moveToSettingsNav( + harness: Awaited>, +): Promise { + harness.mockInput.pressArrow("left"); + await harness.renderOnce(); + for (let step = 0; step < 3; step += 1) { + harness.mockInput.pressArrow("down"); + await harness.renderOnce(); + } + expect(harness.selectionChanges.at(-1)?.navLabel).toBe("Settings"); +} + +async function openSettingsDrawer( + harness: Awaited>, +): Promise { + await moveToSettingsNav(harness); + harness.mockInput.pressArrow("right"); + await harness.renderOnce(); + expect(harness.readyContexts[0]?.modalHostRef.visible).toBe(true); +} + +describe("OpenTUI settings drawer", () => { + test("opens the settings hub as a drawer without replacing the shell", async () => { + const harness = await mountOpenTuiShellHarness(); + + try { + await openSettingsDrawer(harness); + + const frame = harness.captureCharFrame(); + expect(frame).toContain("Settings host"); + expect(frame).toContain("Account List View"); + expect(frame).toContain("Customize menu, behavior, and"); + expect(frame).toContain("backend"); + expect(harness.readyContexts[0]?.statusLineRef.plainText).toContain("tab switch pane"); + } finally { + await harness.destroy(); + } + }); + + test("preserves cancel without save for drawer edits", async () => { + const saveEvents: unknown[] = []; + const harness = await mountOpenTuiShellHarness({ + shell: { + onSettingsSave: (event) => { + saveEvents.push(event); + }, + }, + }); + + try { + await openSettingsDrawer(harness); + harness.mockInput.pressKey("1"); + await harness.renderOnce(); + harness.mockInput.pressKey("1"); + await harness.renderOnce(); + harness.mockInput.pressKey("q"); + await harness.renderOnce(); + harness.mockInput.pressKey("q"); + await harness.renderOnce(); + + expect(saveEvents).toHaveLength(0); + expect(harness.readyContexts[0]?.modalHostRef.visible).toBe(false); + } finally { + await harness.destroy(); + } + }); + + test("resets account-list edits before saving", async () => { + const saveEvents: unknown[] = []; + const harness = await mountOpenTuiShellHarness({ + shell: { + onSettingsSave: (event) => { + saveEvents.push(event); + }, + }, + }); + + try { + await openSettingsDrawer(harness); + harness.mockInput.pressKey("1"); + await harness.renderOnce(); + harness.mockInput.pressKey("1"); + await harness.renderOnce(); + harness.mockInput.pressKey("r"); + await harness.renderOnce(); + harness.mockInput.pressKey("s"); + await harness.renderOnce(); + + expect(saveEvents).toEqual([ + expect.objectContaining({ + kind: "dashboard", + panel: "account-list", + selected: expect.objectContaining({ + menuShowStatusBadge: true, + }), + }), + ]); + } finally { + await harness.destroy(); + } + }); + + test("reorders summary fields with bracket hotkeys inside the drawer", async () => { + const saveEvents: Array> = []; + const harness = await mountOpenTuiShellHarness({ + shell: { + onSettingsSave: (event) => { + saveEvents.push(event as Record); + }, + }, + }); + + try { + await openSettingsDrawer(harness); + harness.mockInput.pressKey("2"); + await harness.renderOnce(); + harness.mockInput.pressKey("]"); + await harness.renderOnce(); + harness.mockInput.pressKey("["); + await harness.renderOnce(); + harness.mockInput.pressKey("]"); + await harness.renderOnce(); + harness.mockInput.pressKey("s"); + await harness.renderOnce(); + + expect(saveEvents).toHaveLength(1); + expect(saveEvents[0]).toEqual( + expect.objectContaining({ + kind: "dashboard", + panel: "summary-fields", + selected: expect.objectContaining({ + menuStatuslineFields: ["limits", "last-used", "status"], + }), + }), + ); + } finally { + await harness.destroy(); + } + }); + + test("saves backend category drafts with numeric and +/- hotkeys", async () => { + const saveEvents: Array> = []; + const baseline = getDefaultPluginConfig(); + const harness = await mountOpenTuiShellHarness({ + shell: { + onSettingsSave: (event) => { + saveEvents.push(event as Record); + }, + }, + }); + + try { + await openSettingsDrawer(harness); + harness.mockInput.pressKey("5"); + await harness.renderOnce(); + harness.mockInput.pressKey("1"); + await harness.renderOnce(); + harness.mockInput.pressKey("1"); + await harness.renderOnce(); + for (let step = 0; step < 5; step += 1) { + harness.mockInput.pressArrow("down"); + await harness.renderOnce(); + } + harness.mockInput.pressKey("+"); + await harness.renderOnce(); + harness.mockInput.pressKey("-"); + await harness.renderOnce(); + harness.mockInput.pressKey("]"); + await harness.renderOnce(); + harness.mockInput.pressKey("q"); + await harness.renderOnce(); + harness.mockInput.pressKey("s"); + await harness.renderOnce(); + + expect(saveEvents).toHaveLength(1); + expect(saveEvents[0]).toEqual( + expect.objectContaining({ + kind: "backend", + selected: expect.objectContaining({ + liveAccountSync: !(baseline.liveAccountSync ?? true), + liveAccountSyncDebounceMs: (baseline.liveAccountSyncDebounceMs ?? 250) + 50, + }), + }), + ); + } finally { + await harness.destroy(); + } + }); +}); diff --git a/test/ui-ink-auth-shell.test.ts b/test/ui-ink-auth-shell.test.ts new file mode 100644 index 00000000..6f6b71ff --- /dev/null +++ b/test/ui-ink-auth-shell.test.ts @@ -0,0 +1,179 @@ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildAuthDashboardViewModel } from "../lib/codex-manager/auth-ui-controller.js"; +import { + createAuthInkShellFocusState, + reduceAuthInkShellFocus, + startInkAuthShell, +} from "../lib/ui-ink/index.js"; + +function createMockInput(): NodeJS.ReadStream { + const stream = new PassThrough() as PassThrough & NodeJS.ReadStream & { + setRawMode: (value: boolean) => void; + ref: () => void; + unref: () => void; + }; + Object.defineProperty(stream, "isTTY", { + value: true, + configurable: true, + }); + stream.setRawMode = vi.fn(); + stream.ref = vi.fn(); + stream.unref = vi.fn(); + return stream; +} + +function createMockOutput(): NodeJS.WriteStream { + const stream = new PassThrough() as PassThrough & NodeJS.WriteStream; + Object.defineProperty(stream, "isTTY", { + value: true, + configurable: true, + }); + Object.defineProperty(stream, "columns", { + value: 120, + configurable: true, + }); + Object.defineProperty(stream, "rows", { + value: 40, + configurable: true, + }); + return stream; +} + +function stripAnsi(value: string): string { + return value.replace(new RegExp("\\u001b\\[[0-9;?]*[ -/]*[@-~]", "g"), ""); +} + +function createDashboardViewModel() { + const now = Date.now(); + return buildAuthDashboardViewModel({ + storage: { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "acc_alpha", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + addedAt: now - 4_000, + lastUsed: now - 4_000, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "acc_beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }, + quotaCache: { + byAccountId: {}, + byEmail: { + "alpha@example.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 30, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 60, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + "beta@example.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 10, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 20, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + }, + }, + displaySettings: { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + flaggedCount: 1, + statusMessage: "Loading live limits...", + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ink auth shell", () => { + it("renders the auth shell frame, sections, and current panel rows", async () => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + const chunks: string[] = []; + output.on("data", (chunk) => { + chunks.push(chunk.toString()); + }); + + const dashboard = createDashboardViewModel(); + const app = startInkAuthShell({ + dashboard, + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + expect(app).not.toBeNull(); + await new Promise((resolve) => setTimeout(resolve, 50)); + app?.unmount(); + app?.cleanup(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const rendered = stripAnsi(chunks.join("")); + expect(rendered).toContain("Accounts Dashboard"); + expect(rendered).toContain("Loading live limits..."); + expect(rendered).toContain("Quick Actions"); + expect(rendered).toContain("Advanced Checks"); + expect(rendered).toContain("Saved Accounts"); + expect(rendered).toContain("Danger Zone"); + expect(rendered).toContain("Add New Account"); + }); + + it("moves focus across sections and rebinds entry focus for saved accounts", () => { + const dashboard = createDashboardViewModel(); + const initial = createAuthInkShellFocusState(dashboard); + expect(initial).toEqual({ sectionIndex: 0, entryIndex: 0 }); + + const advanced = reduceAuthInkShellFocus(dashboard, initial, { + type: "move-section", + direction: 1, + }); + expect(advanced).toEqual({ sectionIndex: 1, entryIndex: 0 }); + + const savedAccounts = reduceAuthInkShellFocus(dashboard, advanced, { + type: "move-section", + direction: 1, + }); + expect(savedAccounts).toEqual({ sectionIndex: 2, entryIndex: 0 }); + + const nextAccount = reduceAuthInkShellFocus(dashboard, savedAccounts, { + type: "move-entry", + direction: 1, + }); + expect(nextAccount).toEqual({ sectionIndex: 2, entryIndex: 1 }); + + const reset = reduceAuthInkShellFocus(dashboard, nextAccount, { type: "reset" }); + expect(reset).toEqual({ sectionIndex: 0, entryIndex: 0 }); + }); +}); diff --git a/test/ui-ink-bootstrap.test.ts b/test/ui-ink-bootstrap.test.ts new file mode 100644 index 00000000..a80b2f83 --- /dev/null +++ b/test/ui-ink-bootstrap.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { resolveInkAuthShellBootstrap } from "../lib/ui-ink/bootstrap.js"; + +function createMockStream(isTTY: boolean): NodeJS.ReadStream & NodeJS.WriteStream { + return { isTTY } as NodeJS.ReadStream & NodeJS.WriteStream; +} + +describe("ink auth shell bootstrap", () => { + it("rejects non-tty input or output streams", () => { + expect(resolveInkAuthShellBootstrap({ + stdin: createMockStream(false), + stdout: createMockStream(true), + env: {}, + })).toEqual({ supported: false, reason: "stdin-not-tty" }); + + expect(resolveInkAuthShellBootstrap({ + stdin: createMockStream(true), + stdout: createMockStream(false), + env: {}, + })).toEqual({ supported: false, reason: "stdout-not-tty" }); + }); + + it("preserves host-managed ui fallback boundaries", () => { + expect(resolveInkAuthShellBootstrap({ + stdin: createMockStream(true), + stdout: createMockStream(true), + env: { CODEX_TUI: "1" }, + })).toEqual({ supported: false, reason: "host-managed-ui" }); + + expect(resolveInkAuthShellBootstrap({ + stdin: createMockStream(true), + stdout: createMockStream(true), + env: { CODEX_TUI: "1", FORCE_INTERACTIVE_MODE: "1" }, + })).toEqual({ supported: true }); + }); +}); diff --git a/test/ui-ink-dashboard.test.ts b/test/ui-ink-dashboard.test.ts new file mode 100644 index 00000000..71b769d0 --- /dev/null +++ b/test/ui-ink-dashboard.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { buildAuthDashboardViewModel } from "../lib/codex-manager/auth-ui-controller.js"; +import { + filterAuthInkDashboardAccounts, + promptInkAuthDashboard, + resolveAuthInkQuickSwitch, +} from "../lib/ui-ink/index.js"; +import { PassThrough } from "node:stream"; + +function createMockInput(): NodeJS.ReadStream { + const stream = new PassThrough() as PassThrough & NodeJS.ReadStream & { + setRawMode: (value: boolean) => void; + ref: () => void; + unref: () => void; + }; + Object.defineProperty(stream, "isTTY", { + value: true, + configurable: true, + }); + stream.setRawMode = () => undefined; + stream.ref = () => undefined; + stream.unref = () => undefined; + return stream; +} + +function createMockOutput(): NodeJS.WriteStream { + const stream = new PassThrough() as PassThrough & NodeJS.WriteStream; + Object.defineProperty(stream, "isTTY", { + value: true, + configurable: true, + }); + Object.defineProperty(stream, "columns", { + value: 120, + configurable: true, + }); + Object.defineProperty(stream, "rows", { + value: 40, + configurable: true, + }); + return stream; +} + +function createDashboardViewModel() { + const now = Date.now(); + return buildAuthDashboardViewModel({ + storage: { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "acc_alpha", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + addedAt: now - 4_000, + lastUsed: now - 4_000, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "acc_beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }, + quotaCache: { + byAccountId: {}, + byEmail: { + "alpha@example.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 30, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 60, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + "beta@example.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { usedPercent: 10, windowMinutes: 300, resetAtMs: now + 1_000 }, + secondary: { usedPercent: 20, windowMinutes: 10080, resetAtMs: now + 2_000 }, + }, + }, + }, + displaySettings: { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + flaggedCount: 1, + statusMessage: "Loading live limits...", + }); +} + +describe("ink auth dashboard", () => { + it("filters saved accounts using the inline search query", () => { + const dashboard = createDashboardViewModel(); + expect(filterAuthInkDashboardAccounts(dashboard, "beta").map((account) => account.email)).toEqual([ + "beta@example.com", + ]); + expect(filterAuthInkDashboardAccounts(dashboard, "1").map((account) => account.quickSwitchNumber)).toEqual([1]); + }); + + it("resolves quick-switch hotkeys from visible account rows and ignores duplicates", () => { + const dashboard = createDashboardViewModel(); + expect(resolveAuthInkQuickSwitch(dashboard, "", "2")).toEqual({ + mode: "manage", + switchAccountIndex: 0, + }); + + const duplicateDashboard = { + ...dashboard, + accounts: dashboard.accounts.map((account) => ({ ...account, quickSwitchNumber: 2 })), + }; + expect(resolveAuthInkQuickSwitch(duplicateDashboard, "", "2")).toBeNull(); + }); + + it("returns a quick-switch result from live Ink input", async () => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + const dashboard = createDashboardViewModel(); + + const resultPromise = promptInkAuthDashboard({ + dashboard, + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + input.push("2"); + + await expect(resultPromise).resolves.toEqual({ + mode: "manage", + switchAccountIndex: 0, + }); + }); +}); diff --git a/test/ui-ink-detail-flows.test.ts b/test/ui-ink-detail-flows.test.ts new file mode 100644 index 00000000..335addc5 --- /dev/null +++ b/test/ui-ink-detail-flows.test.ts @@ -0,0 +1,149 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { + promptInkAccountDetails, + promptInkConfirmAccountDelete, + promptInkConfirmAccountRefresh, + promptInkRestoreForLogin, +} from "../lib/ui-ink/index.js"; + +function createMockInput(): NodeJS.ReadStream { + const stream = new PassThrough() as PassThrough & NodeJS.ReadStream & { + setRawMode: (value: boolean) => void; + ref: () => void; + unref: () => void; + }; + Object.defineProperty(stream, "isTTY", { + value: true, + configurable: true, + }); + stream.setRawMode = () => undefined; + stream.ref = () => undefined; + stream.unref = () => undefined; + return stream; +} + +function createMockOutput(): NodeJS.WriteStream { + const stream = new PassThrough() as PassThrough & NodeJS.WriteStream; + Object.defineProperty(stream, "isTTY", { + value: true, + configurable: true, + }); + Object.defineProperty(stream, "columns", { + value: 120, + configurable: true, + }); + Object.defineProperty(stream, "rows", { + value: 40, + configurable: true, + }); + return stream; +} + +function createAccount() { + const now = Date.now(); + return { + index: 0, + sourceIndex: 2, + quickSwitchNumber: 1, + email: "alpha@example.com", + accountId: "acc_alpha", + addedAt: now - 10_000, + lastUsed: now - 2_000, + status: "active" as const, + quotaSummary: "5h 70% | 7d 40%", + enabled: true, + }; +} + +describe("ink detail and recovery flows", () => { + it.each([ + ["s", "set-current"], + ["r", "refresh"], + ["e", "toggle"], + ["d", "delete"], + ["q", "cancel"], + ])("maps %s to %s on the account detail screen", async (keyPress, expected) => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + + const resultPromise = promptInkAccountDetails({ + account: createAccount(), + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + input.push(keyPress); + + await expect(resultPromise).resolves.toBe(expected); + }); + + it("confirms refresh in the Ink re-login prompt", async () => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + + const resultPromise = promptInkConfirmAccountRefresh({ + account: createAccount(), + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + input.push("r"); + + await expect(resultPromise).resolves.toBe(true); + }); + + it("requires typing DELETE for Ink account deletion", async () => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + + const resultPromise = promptInkConfirmAccountDelete({ + account: createAccount(), + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + for (const key of ["D", "E", "L", "E", "T", "E", "\r"]) { + input.push(key); + } + + await expect(resultPromise).resolves.toBe(true); + }); + + it("supports Ink restore prompt actions", async () => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + + const resultPromise = promptInkRestoreForLogin({ + reasonText: "No saved account pool was found.", + snapshotInfo: "latest backup | 2 accounts | 512 bytes | 3/9/2026, 10:00:00 PM\n/mock/openai-codex-accounts.json.bak", + snapshotCount: 2, + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + input.push("r"); + + await expect(resultPromise).resolves.toBe(true); + }); +}); diff --git a/test/ui-ink-settings.test.ts b/test/ui-ink-settings.test.ts new file mode 100644 index 00000000..416e5ee0 --- /dev/null +++ b/test/ui-ink-settings.test.ts @@ -0,0 +1,281 @@ +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const loadDashboardDisplaySettingsMock = vi.fn(); +const saveDashboardDisplaySettingsMock = vi.fn(); +const getDashboardSettingsPathMock = vi.fn(() => "/mock/dashboard-settings.json"); +const loadPluginConfigMock = vi.fn(); +const savePluginConfigMock = vi.fn(); +const getUnifiedSettingsPathMock = vi.fn(() => "/mock/unified-settings.json"); + +vi.mock("../lib/dashboard-settings.js", async () => { + const actual = await vi.importActual("../lib/dashboard-settings.js"); + return { + ...(actual as Record), + loadDashboardDisplaySettings: loadDashboardDisplaySettingsMock, + saveDashboardDisplaySettings: saveDashboardDisplaySettingsMock, + getDashboardSettingsPath: getDashboardSettingsPathMock, + }; +}); + +vi.mock("../lib/config.js", async () => { + const actual = await vi.importActual("../lib/config.js"); + return { + ...(actual as Record), + loadPluginConfig: loadPluginConfigMock, + savePluginConfig: savePluginConfigMock, + }; +}); + +vi.mock("../lib/unified-settings.js", () => ({ + getUnifiedSettingsPath: getUnifiedSettingsPathMock, +})); + +function createMockInput(): NodeJS.ReadStream { + const stream = new PassThrough() as PassThrough & NodeJS.ReadStream & { + setRawMode: (value: boolean) => void; + ref: () => void; + unref: () => void; + }; + Object.defineProperty(stream, "isTTY", { + value: true, + configurable: true, + }); + stream.setRawMode = () => undefined; + stream.ref = () => undefined; + stream.unref = () => undefined; + return stream; +} + +function createMockOutput(): NodeJS.WriteStream { + const stream = new PassThrough() as PassThrough & NodeJS.WriteStream; + Object.defineProperty(stream, "isTTY", { + value: true, + configurable: true, + }); + Object.defineProperty(stream, "columns", { + value: 120, + configurable: true, + }); + Object.defineProperty(stream, "rows", { + value: 40, + configurable: true, + }); + return stream; +} + +async function sendKeys(stream: PassThrough, keys: string[], delayMs = 35): Promise { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + for (const key of keys) { + stream.push(key); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } +} + +describe("ink settings flows", () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + const dashboardSettingsModule = await import("../lib/dashboard-settings.js"); + const configModule = await import("../lib/config.js"); + loadDashboardDisplaySettingsMock.mockResolvedValue(dashboardSettingsModule.DEFAULT_DASHBOARD_DISPLAY_SETTINGS); + saveDashboardDisplaySettingsMock.mockResolvedValue(undefined); + loadPluginConfigMock.mockReturnValue(configModule.getDefaultPluginConfig()); + savePluginConfigMock.mockResolvedValue(undefined); + getDashboardSettingsPathMock.mockReturnValue("/mock/dashboard-settings.json"); + getUnifiedSettingsPathMock.mockReturnValue("/mock/unified-settings.json"); + }); + + afterEach(async () => { + const runtime = await import("../lib/ui/runtime.js"); + runtime.resetUiRuntimeOptions(); + }); + + it("saves account-list changes through Ink hotkeys", async () => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + const { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = await import("../lib/dashboard-settings.js"); + const { promptInkAccountListSettings } = await import("../lib/ui-ink/index.js"); + + const resultPromise = promptInkAccountListSettings(DEFAULT_DASHBOARD_DISPLAY_SETTINGS, { + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + const typing = sendKeys(input as PassThrough, ["1", "m", "l", "s"]); + await expect(resultPromise).resolves.toEqual( + expect.objectContaining({ + menuShowStatusBadge: false, + menuSortMode: "manual", + menuLayoutMode: "expanded-rows", + menuShowDetailsForUnselectedRows: true, + }), + ); + await typing; + }); + + it("reorders summary fields with bracket hotkeys", async () => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + const { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = await import("../lib/dashboard-settings.js"); + const { promptInkStatuslineSettings } = await import("../lib/ui-ink/index.js"); + + const resultPromise = promptInkStatuslineSettings(DEFAULT_DASHBOARD_DISPLAY_SETTINGS, { + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + const typing = sendKeys(input as PassThrough, ["]", "s"]); + await expect(resultPromise).resolves.toEqual( + expect.objectContaining({ + menuStatuslineFields: ["limits", "last-used", "status"], + }), + ); + await typing; + }); + + it("updates behavior settings with Ink hotkeys", async () => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + const { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = await import("../lib/dashboard-settings.js"); + const { promptInkBehaviorSettings } = await import("../lib/ui-ink/index.js"); + + const resultPromise = promptInkBehaviorSettings(DEFAULT_DASHBOARD_DISPLAY_SETTINGS, { + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + const typing = sendKeys(input as PassThrough, ["p", "l", "f", "t", "1", "s"]); + await expect(resultPromise).resolves.toEqual( + expect.objectContaining({ + actionAutoReturnMs: 1_000, + actionPauseOnKey: false, + menuAutoFetchLimits: false, + menuShowFetchStatus: false, + menuQuotaTtlMs: 600_000, + }), + ); + await typing; + }); + + it("restores the baseline theme when Q cancels Ink theme edits", async () => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + const runtime = await import("../lib/ui/runtime.js"); + const { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = await import("../lib/dashboard-settings.js"); + const { promptInkThemeSettings } = await import("../lib/ui-ink/index.js"); + + runtime.resetUiRuntimeOptions(); + const baseline = runtime.getUiRuntimeOptions(); + const baselineSnapshot = { + v2Enabled: baseline.v2Enabled, + colorProfile: baseline.colorProfile, + glyphMode: baseline.glyphMode, + palette: baseline.palette, + accent: baseline.accent, + }; + + const resultPromise = promptInkThemeSettings(DEFAULT_DASHBOARD_DISPLAY_SETTINGS, { + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + const typing = sendKeys(input as PassThrough, ["2", "q"]); + await expect(resultPromise).resolves.toBeNull(); + await typing; + + const restored = runtime.getUiRuntimeOptions(); + expect({ + v2Enabled: restored.v2Enabled, + colorProfile: restored.colorProfile, + glyphMode: restored.glyphMode, + palette: restored.palette, + accent: restored.accent, + }).toEqual(baselineSnapshot); + }); + + it("routes the full settings hub through Ink and does not save on cancel", async () => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + const { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = await import("../lib/dashboard-settings.js"); + const { configureInkUnifiedSettings } = await import("../lib/ui-ink/index.js"); + + const resultPromise = configureInkUnifiedSettings(DEFAULT_DASHBOARD_DISPLAY_SETTINGS, { + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + const typing = sendKeys(input as PassThrough, ["4", "2", "q", "q"]); + await expect(resultPromise).resolves.toBe(true); + await typing; + + expect(saveDashboardDisplaySettingsMock).not.toHaveBeenCalled(); + expect(savePluginConfigMock).not.toHaveBeenCalled(); + }); + + it("saves backend category toggle and numeric edits with + - and ] hotkeys", async () => { + const input = createMockInput(); + const output = createMockOutput(); + const stderr = createMockOutput(); + const { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } = await import("../lib/dashboard-settings.js"); + const configModule = await import("../lib/config.js"); + const baseline = configModule.getDefaultPluginConfig(); + loadPluginConfigMock.mockReturnValue(baseline); + const { configureInkUnifiedSettings } = await import("../lib/ui-ink/index.js"); + + const resultPromise = configureInkUnifiedSettings(DEFAULT_DASHBOARD_DISPLAY_SETTINGS, { + stdin: input, + stdout: output, + stderr, + patchConsole: false, + exitOnCtrlC: false, + }); + + const typing = sendKeys(input as PassThrough, [ + "5", + "1", + "1", + "\u001b[B", + "\u001b[B", + "\u001b[B", + "\u001b[B", + "\u001b[B", + "+", + "-", + "]", + "q", + "s", + "q", + ]); + await expect(resultPromise).resolves.toBe(true); + await typing; + + expect(savePluginConfigMock).toHaveBeenCalledTimes(1); + expect(savePluginConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + liveAccountSync: !(baseline.liveAccountSync ?? true), + liveAccountSyncDebounceMs: (baseline.liveAccountSyncDebounceMs ?? 250) + 50, + }), + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 5bc0e685..f156c286 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,4 +33,4 @@ "test", "**/*.mjs" ] -} \ No newline at end of file +} diff --git a/tsconfig.runtime-opentui.json b/tsconfig.runtime-opentui.json new file mode 100644 index 00000000..51eb14ef --- /dev/null +++ b/tsconfig.runtime-opentui.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@opentui/solid", + "types": [ + "node", + "bun-types" + ] + }, + "include": [ + "runtime/**/*.ts", + "runtime/**/*.tsx" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts index 929cd21d..815bdab7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ include: ['test/**/*.test.ts'], exclude: [ 'node_modules/**', + 'test/tui/**', '.codex/**', 'dist/**', 'coverage/**',