From fe471f16bdfc733342df32d268994afede955c67 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Fri, 24 Apr 2026 02:45:18 +0200 Subject: [PATCH 01/23] feat: introduce terminal CLI for Pengine with integrated command handling - Added a new CLI plan and implementation for Pengine, allowing users to execute native commands directly from the terminal. - Updated documentation to reflect the new CLI capabilities, including command usage and integration with the Telegram bot. - Enhanced error handling in various API calls and added a new CLI commands panel in the dashboard. - Refactored existing modules to support the CLI functionality, ensuring a seamless user experience across terminal and GUI environments. - Updated package metadata to reflect the new versioning and naming conventions. --- AGENTS.md | 2 +- README.md | 17 + bun.lock | 4422 ++++++++++++++--- cli_plan.md | 286 ++ doc/README.md | 4 +- doc/guides/cli.md | 209 + doc/reference/http-api.md | 6 + package-lock.json | 8 +- package.json | 6 +- scripts/pengine | 14 + src-tauri/Cargo.lock | 158 +- src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 4 +- src-tauri/permissions/cli-shim.toml | 4 + src-tauri/src/app.rs | 21 + src-tauri/src/build_info.rs | 2 - src-tauri/src/infrastructure/http_server.rs | 17 +- .../modules/{bot/agent.rs => agent/mod.rs} | 3 +- .../modules/{bot => agent}/search_followup.rs | 0 src-tauri/src/modules/bot/commands.rs | 19 + src-tauri/src/modules/bot/mod.rs | 3 +- src-tauri/src/modules/bot/service.rs | 52 +- src-tauri/src/modules/bot/token_verify.rs | 13 + src-tauri/src/modules/cli/banner.rs | 39 + src-tauri/src/modules/cli/bootstrap.rs | 552 ++ src-tauri/src/modules/cli/commands.rs | 71 + src-tauri/src/modules/cli/dispatch.rs | 202 + src-tauri/src/modules/cli/handlers.rs | 660 +++ src-tauri/src/modules/cli/mod.rs | 25 + src-tauri/src/modules/cli/output.rs | 532 ++ src-tauri/src/modules/cli/repl.rs | 104 + src-tauri/src/modules/cli/router.rs | 90 + src-tauri/src/modules/cli/shim.rs | 203 + src-tauri/src/modules/cli/telegram_bridge.rs | 85 + src-tauri/src/modules/cron/scheduler.rs | 2 +- src-tauri/src/modules/cron/types.rs | 5 - src-tauri/src/modules/mod.rs | 2 + src-tauri/src/modules/skills/service.rs | 2 +- src-tauri/src/shared/state.rs | 2 +- src-tauri/tauri.conf.json | 136 +- src-tauri/tests/cli_oneshot.rs | 84 + src/modules/bot/api/index.ts | 9 +- src/modules/cli/api/index.ts | 32 + .../cli/components/CliCommandsPanel.tsx | 135 + src/modules/cli/index.ts | 3 + src/modules/cli/types.ts | 8 + src/pages/DashboardPage.tsx | 6 + 47 files changed, 7449 insertions(+), 812 deletions(-) create mode 100644 cli_plan.md create mode 100644 doc/guides/cli.md create mode 100755 scripts/pengine create mode 100644 src-tauri/permissions/cli-shim.toml rename src-tauri/src/modules/{bot/agent.rs => agent/mod.rs} (99%) rename src-tauri/src/modules/{bot => agent}/search_followup.rs (100%) create mode 100644 src-tauri/src/modules/bot/token_verify.rs create mode 100644 src-tauri/src/modules/cli/banner.rs create mode 100644 src-tauri/src/modules/cli/bootstrap.rs create mode 100644 src-tauri/src/modules/cli/commands.rs create mode 100644 src-tauri/src/modules/cli/dispatch.rs create mode 100644 src-tauri/src/modules/cli/handlers.rs create mode 100644 src-tauri/src/modules/cli/mod.rs create mode 100644 src-tauri/src/modules/cli/output.rs create mode 100644 src-tauri/src/modules/cli/repl.rs create mode 100644 src-tauri/src/modules/cli/router.rs create mode 100644 src-tauri/src/modules/cli/shim.rs create mode 100644 src-tauri/src/modules/cli/telegram_bridge.rs create mode 100644 src-tauri/tests/cli_oneshot.rs create mode 100644 src/modules/cli/api/index.ts create mode 100644 src/modules/cli/components/CliCommandsPanel.tsx create mode 100644 src/modules/cli/index.ts create mode 100644 src/modules/cli/types.ts diff --git a/AGENTS.md b/AGENTS.md index 26528dc..71f530b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Read **[doc/architecture/README.md](doc/architecture/README.md)** before adding, | Task | Documentation | Then open in code (non-exhaustive) | | --- | --- | --- | -| Telegram flow, agent loop, tools, prompts, limits | [doc/agent/runtime.md](doc/agent/runtime.md) | `src-tauri/src/modules/bot/agent.rs`, `service.rs` | +| Telegram flow, agent loop, tools, prompts, limits | [doc/agent/runtime.md](doc/agent/runtime.md) | `src-tauri/src/modules/agent/`, `modules/bot/service.rs` | | New or changed HTTP routes / dashboard API | [doc/reference/http-api.md](doc/reference/http-api.md) | `src-tauri/src/infrastructure/http_server.rs`, matching `src/modules/*/api/` | | MCP client, registry, native tools, `mcp.json` | [doc/architecture/mcp.md](doc/architecture/mcp.md), [doc/guides/custom-mcp-tools.md](doc/guides/custom-mcp-tools.md) | `src-tauri/src/modules/mcp/` | | Startup, `AppState`, disk paths, secrets | [doc/platform/data-and-startup.md](doc/platform/data-and-startup.md) | `src-tauri/src/app.rs`, `shared/state.rs`, `modules/secure_store/` | diff --git a/README.md b/README.md index 04befbe..e0bbb69 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,23 @@ bun run dev bun run tauri dev ``` +### Terminal CLI (same binary as the app) + +From the repo root you can run native commands without opening the UI, for example: + +```bash +bun run cli # interactive REPL in the terminal (no app window) +bun run cli -- version +bun run cli -- help +bun run cli -- status +``` + +From a built binary: **`pengine`** in a real terminal starts the **shell only**. **`pengine app`** opens the **desktop window in another process** so you can keep the shell running in parallel. See **[doc/guides/cli.md](doc/guides/cli.md)**. + +To type **`pengine-cli`** on `PATH` (release): open the **installed app → Dashboard → Terminal CLI** and turn **CLI on PATH** on. Use **`pengine-cli app`** for the window. + +Use **`--json` before the subcommand** for machine output (e.g. `bun run cli -- --json status`). Smoke tests: `bun run cli:test`. + ### Build ```bash diff --git a/bun.lock b/bun.lock index 01ac417..a15ad00 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "pengine", + "name": "Pengine", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -46,764 +46,3664 @@ }, }, "packages": { - "@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.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "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-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@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-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-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@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-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - - "@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.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - - "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], - - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - - "@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=="], - - "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], - - "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], - - "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], - - "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], - - "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], - - "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], - - "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], - - "@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.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - - "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], - - "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], - - "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - - "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], - - "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - - "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], - - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - - "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - - "@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=="], - - "@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/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@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=="], - - "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], - - "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], - - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], - - "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], - - "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], - - "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], - - "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], - - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - - "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], - - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], - - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], - - "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], - - "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - - "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], - - "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="], - - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], - - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], - - "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], - - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], - - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], - - "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], - - "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], - - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], - - "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], - - "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], - - "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], - - "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - - "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], - - "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], - - "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], - - "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], - - "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], - - "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], - - "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], - - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], - - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], - - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], - - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], - - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], - - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], - - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], - - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], - - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], - - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], - - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], - - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], - - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], - - "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], - - "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], - - "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], - - "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ=="], - - "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw=="], - - "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w=="], - - "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA=="], - - "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg=="], - - "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.1", "", { "os": "linux", "cpu": "none" }, "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw=="], - - "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw=="], - - "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ=="], - - "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg=="], - - "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw=="], - - "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="], - - "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="], - - "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="], - - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], - - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], - - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - - "@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@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], - - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - - "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], - - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/type-utils": "8.58.1", "@typescript-eslint/utils": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ=="], - - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw=="], - - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.1", "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g=="], - - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1" } }, "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w=="], - - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw=="], - - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="], - - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.1", "@typescript-eslint/tsconfig-utils": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg=="], - - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ=="], - - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="], - - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - - "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=="], - - "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - - "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], - - "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA=="], - - "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], - - "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - - "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], - - "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], - - "color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], - - "color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="], - - "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], - - "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=="], - - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - - "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=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="], - - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], - - "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], - - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - - "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "eslint": ["eslint@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.4", "@eslint/config-helpers": "^0.5.4", "@eslint/core": "^1.2.0", "@eslint/plugin-kit": "^0.7.0", "@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.2.0", "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-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="], - - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], - - "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=="], - - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - - "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - - "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=="], - - "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], - - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - - "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.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - - "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "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=="], - - "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - - "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], - - "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - - "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - - "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "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=="], - - "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], - - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - - "lint-staged": ["lint-staged@16.4.0", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "picomatch": "^4.0.3", "string-argv": "^0.3.2", "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw=="], - - "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=="], - - "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], - - "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=="], - - "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - - "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "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.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], - - "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], - - "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - - "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=="], - - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], - - "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], - - "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], - - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - - "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], - - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - - "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], - - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], - - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - - "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], - - "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - - "react-router": ["react-router@7.14.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ=="], - - "react-router-dom": ["react-router-dom@7.14.0", "", { "dependencies": { "react-router": "7.14.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ=="], - - "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - - "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - - "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - - "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], - - "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=="], - - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], - - "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], - - "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], - - "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=="], - - "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], - - "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], - - "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], - - "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], - - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - - "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], - - "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - - "typescript-eslint": ["typescript-eslint@8.58.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.1", "@typescript-eslint/parser": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg=="], - - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "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=="], - - "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], - - "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], - - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - - "vite": ["vite@7.3.2", "", { "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-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="], - - "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], - - "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=="], - - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], - - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - - "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], - - "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], - - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - - "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], - - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - - "wrap-ansi/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=="], - } + "@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.29.0", + "", + { + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "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-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + ], + + "@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-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-globals": [ + "@babel/helper-globals@7.28.0", + "", + {}, + "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + ], + + "@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-plugin-utils": [ + "@babel/helper-plugin-utils@7.28.6", + "", + {}, + "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + ], + + "@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.29.2", + "", + { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, + "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + ], + + "@babel/parser": [ + "@babel/parser@7.29.2", + "", + { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, + "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + ], + + "@babel/plugin-transform-react-jsx-self": [ + "@babel/plugin-transform-react-jsx-self@7.27.1", + "", + { + "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, + "peerDependencies": { "@babel/core": "^7.0.0-0" }, + }, + "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + ], + + "@babel/plugin-transform-react-jsx-source": [ + "@babel/plugin-transform-react-jsx-source@7.27.1", + "", + { + "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, + "peerDependencies": { "@babel/core": "^7.0.0-0" }, + }, + "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + ], + + "@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==", + ], + + "@colors/colors": [ + "@colors/colors@1.6.0", + "", + {}, + "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + ], + + "@dabh/diagnostics": [ + "@dabh/diagnostics@2.0.8", + "", + { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, + "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + ], + + "@dnd-kit/accessibility": [ + "@dnd-kit/accessibility@3.1.1", + "", + { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, + "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + ], + + "@dnd-kit/core": [ + "@dnd-kit/core@6.3.1", + "", + { + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0", + }, + "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, + }, + "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + ], + + "@dnd-kit/modifiers": [ + "@dnd-kit/modifiers@9.0.0", + "", + { + "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, + "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" }, + }, + "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + ], + + "@dnd-kit/sortable": [ + "@dnd-kit/sortable@10.0.0", + "", + { + "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, + "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" }, + }, + "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + ], + + "@dnd-kit/utilities": [ + "@dnd-kit/utilities@3.2.2", + "", + { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, + "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + ], + + "@esbuild/aix-ppc64": [ + "@esbuild/aix-ppc64@0.27.7", + "", + { "os": "aix", "cpu": "ppc64" }, + "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + ], + + "@esbuild/android-arm": [ + "@esbuild/android-arm@0.27.7", + "", + { "os": "android", "cpu": "arm" }, + "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + ], + + "@esbuild/android-arm64": [ + "@esbuild/android-arm64@0.27.7", + "", + { "os": "android", "cpu": "arm64" }, + "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + ], + + "@esbuild/android-x64": [ + "@esbuild/android-x64@0.27.7", + "", + { "os": "android", "cpu": "x64" }, + "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + ], + + "@esbuild/darwin-arm64": [ + "@esbuild/darwin-arm64@0.27.7", + "", + { "os": "darwin", "cpu": "arm64" }, + "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + ], + + "@esbuild/darwin-x64": [ + "@esbuild/darwin-x64@0.27.7", + "", + { "os": "darwin", "cpu": "x64" }, + "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + ], + + "@esbuild/freebsd-arm64": [ + "@esbuild/freebsd-arm64@0.27.7", + "", + { "os": "freebsd", "cpu": "arm64" }, + "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + ], + + "@esbuild/freebsd-x64": [ + "@esbuild/freebsd-x64@0.27.7", + "", + { "os": "freebsd", "cpu": "x64" }, + "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + ], + + "@esbuild/linux-arm": [ + "@esbuild/linux-arm@0.27.7", + "", + { "os": "linux", "cpu": "arm" }, + "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + ], + + "@esbuild/linux-arm64": [ + "@esbuild/linux-arm64@0.27.7", + "", + { "os": "linux", "cpu": "arm64" }, + "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + ], + + "@esbuild/linux-ia32": [ + "@esbuild/linux-ia32@0.27.7", + "", + { "os": "linux", "cpu": "ia32" }, + "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + ], + + "@esbuild/linux-loong64": [ + "@esbuild/linux-loong64@0.27.7", + "", + { "os": "linux", "cpu": "none" }, + "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + ], + + "@esbuild/linux-mips64el": [ + "@esbuild/linux-mips64el@0.27.7", + "", + { "os": "linux", "cpu": "none" }, + "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + ], + + "@esbuild/linux-ppc64": [ + "@esbuild/linux-ppc64@0.27.7", + "", + { "os": "linux", "cpu": "ppc64" }, + "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + ], + + "@esbuild/linux-riscv64": [ + "@esbuild/linux-riscv64@0.27.7", + "", + { "os": "linux", "cpu": "none" }, + "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + ], + + "@esbuild/linux-s390x": [ + "@esbuild/linux-s390x@0.27.7", + "", + { "os": "linux", "cpu": "s390x" }, + "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + ], + + "@esbuild/linux-x64": [ + "@esbuild/linux-x64@0.27.7", + "", + { "os": "linux", "cpu": "x64" }, + "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + ], + + "@esbuild/netbsd-arm64": [ + "@esbuild/netbsd-arm64@0.27.7", + "", + { "os": "none", "cpu": "arm64" }, + "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + ], + + "@esbuild/netbsd-x64": [ + "@esbuild/netbsd-x64@0.27.7", + "", + { "os": "none", "cpu": "x64" }, + "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + ], + + "@esbuild/openbsd-arm64": [ + "@esbuild/openbsd-arm64@0.27.7", + "", + { "os": "openbsd", "cpu": "arm64" }, + "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + ], + + "@esbuild/openbsd-x64": [ + "@esbuild/openbsd-x64@0.27.7", + "", + { "os": "openbsd", "cpu": "x64" }, + "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + ], + + "@esbuild/openharmony-arm64": [ + "@esbuild/openharmony-arm64@0.27.7", + "", + { "os": "none", "cpu": "arm64" }, + "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + ], + + "@esbuild/sunos-x64": [ + "@esbuild/sunos-x64@0.27.7", + "", + { "os": "sunos", "cpu": "x64" }, + "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + ], + + "@esbuild/win32-arm64": [ + "@esbuild/win32-arm64@0.27.7", + "", + { "os": "win32", "cpu": "arm64" }, + "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + ], + + "@esbuild/win32-ia32": [ + "@esbuild/win32-ia32@0.27.7", + "", + { "os": "win32", "cpu": "ia32" }, + "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + ], + + "@esbuild/win32-x64": [ + "@esbuild/win32-x64@0.27.7", + "", + { "os": "win32", "cpu": "x64" }, + "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + ], + + "@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.5", + "", + { + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4", + }, + }, + "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + ], + + "@eslint/config-helpers": [ + "@eslint/config-helpers@0.5.5", + "", + { "dependencies": { "@eslint/core": "^1.2.1" } }, + "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + ], + + "@eslint/core": [ + "@eslint/core@1.2.1", + "", + { "dependencies": { "@types/json-schema": "^7.0.15" } }, + "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + ], + + "@eslint/js": [ + "@eslint/js@10.0.1", + "", + { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, + "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + ], + + "@eslint/object-schema": [ + "@eslint/object-schema@3.0.5", + "", + {}, + "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + ], + + "@eslint/plugin-kit": [ + "@eslint/plugin-kit@0.7.1", + "", + { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, + "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + ], + + "@floating-ui/core": [ + "@floating-ui/core@1.7.5", + "", + { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, + "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + ], + + "@floating-ui/dom": [ + "@floating-ui/dom@1.7.6", + "", + { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, + "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + ], + + "@floating-ui/react-dom": [ + "@floating-ui/react-dom@2.1.8", + "", + { + "dependencies": { "@floating-ui/dom": "^1.7.6" }, + "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, + }, + "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + ], + + "@floating-ui/utils": [ + "@floating-ui/utils@0.2.11", + "", + {}, + "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + ], + + "@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==", + ], + + "@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/remapping": [ + "@jridgewell/remapping@2.3.5", + "", + { + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24", + }, + }, + "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + ], + + "@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==", + ], + + "@playwright/test": [ + "@playwright/test@1.59.1", + "", + { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, + "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + ], + + "@radix-ui/number": [ + "@radix-ui/number@1.1.1", + "", + {}, + "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + ], + + "@radix-ui/primitive": [ + "@radix-ui/primitive@1.1.3", + "", + {}, + "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + ], + + "@radix-ui/react-accordion": [ + "@radix-ui/react-accordion@1.2.12", + "", + { + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + ], + + "@radix-ui/react-arrow": [ + "@radix-ui/react-arrow@1.1.7", + "", + { + "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + ], + + "@radix-ui/react-collapsible": [ + "@radix-ui/react-collapsible@1.1.12", + "", + { + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + ], + + "@radix-ui/react-collection": [ + "@radix-ui/react-collection@1.1.7", + "", + { + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + ], + + "@radix-ui/react-compose-refs": [ + "@radix-ui/react-compose-refs@1.1.2", + "", + { + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + ], + + "@radix-ui/react-context": [ + "@radix-ui/react-context@1.1.2", + "", + { + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + ], + + "@radix-ui/react-direction": [ + "@radix-ui/react-direction@1.1.1", + "", + { + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + ], + + "@radix-ui/react-dismissable-layer": [ + "@radix-ui/react-dismissable-layer@1.1.11", + "", + { + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + ], + + "@radix-ui/react-focus-guards": [ + "@radix-ui/react-focus-guards@1.1.3", + "", + { + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + ], + + "@radix-ui/react-focus-scope": [ + "@radix-ui/react-focus-scope@1.1.7", + "", + { + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + ], + + "@radix-ui/react-id": [ + "@radix-ui/react-id@1.1.1", + "", + { + "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + ], + + "@radix-ui/react-menu": [ + "@radix-ui/react-menu@2.1.16", + "", + { + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + ], + + "@radix-ui/react-menubar": [ + "@radix-ui/react-menubar@1.1.16", + "", + { + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + ], + + "@radix-ui/react-popper": [ + "@radix-ui/react-popper@1.2.8", + "", + { + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + ], + + "@radix-ui/react-portal": [ + "@radix-ui/react-portal@1.1.9", + "", + { + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + ], + + "@radix-ui/react-presence": [ + "@radix-ui/react-presence@1.1.5", + "", + { + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + ], + + "@radix-ui/react-primitive": [ + "@radix-ui/react-primitive@2.1.3", + "", + { + "dependencies": { "@radix-ui/react-slot": "1.2.3" }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + ], + + "@radix-ui/react-roving-focus": [ + "@radix-ui/react-roving-focus@1.1.11", + "", + { + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + ], + + "@radix-ui/react-scroll-area": [ + "@radix-ui/react-scroll-area@1.2.10", + "", + { + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + ], + + "@radix-ui/react-select": [ + "@radix-ui/react-select@2.2.6", + "", + { + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + ], + + "@radix-ui/react-slider": [ + "@radix-ui/react-slider@1.3.6", + "", + { + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + ], + + "@radix-ui/react-slot": [ + "@radix-ui/react-slot@1.2.3", + "", + { + "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + ], + + "@radix-ui/react-use-callback-ref": [ + "@radix-ui/react-use-callback-ref@1.1.1", + "", + { + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + ], + + "@radix-ui/react-use-controllable-state": [ + "@radix-ui/react-use-controllable-state@1.2.2", + "", + { + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + ], + + "@radix-ui/react-use-effect-event": [ + "@radix-ui/react-use-effect-event@0.0.2", + "", + { + "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + ], + + "@radix-ui/react-use-escape-keydown": [ + "@radix-ui/react-use-escape-keydown@1.1.1", + "", + { + "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + ], + + "@radix-ui/react-use-layout-effect": [ + "@radix-ui/react-use-layout-effect@1.1.1", + "", + { + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + ], + + "@radix-ui/react-use-previous": [ + "@radix-ui/react-use-previous@1.1.1", + "", + { + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + ], + + "@radix-ui/react-use-rect": [ + "@radix-ui/react-use-rect@1.1.1", + "", + { + "dependencies": { "@radix-ui/rect": "1.1.1" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + ], + + "@radix-ui/react-use-size": [ + "@radix-ui/react-use-size@1.1.1", + "", + { + "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + ], + + "@radix-ui/react-visually-hidden": [ + "@radix-ui/react-visually-hidden@1.2.3", + "", + { + "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react", "@types/react-dom"], + }, + "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + ], + + "@radix-ui/rect": [ + "@radix-ui/rect@1.1.1", + "", + {}, + "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + ], + + "@rolldown/pluginutils": [ + "@rolldown/pluginutils@1.0.0-beta.27", + "", + {}, + "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + ], + + "@rollup/rollup-android-arm-eabi": [ + "@rollup/rollup-android-arm-eabi@4.60.1", + "", + { "os": "android", "cpu": "arm" }, + "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + ], + + "@rollup/rollup-android-arm64": [ + "@rollup/rollup-android-arm64@4.60.1", + "", + { "os": "android", "cpu": "arm64" }, + "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + ], + + "@rollup/rollup-darwin-arm64": [ + "@rollup/rollup-darwin-arm64@4.60.1", + "", + { "os": "darwin", "cpu": "arm64" }, + "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + ], + + "@rollup/rollup-darwin-x64": [ + "@rollup/rollup-darwin-x64@4.60.1", + "", + { "os": "darwin", "cpu": "x64" }, + "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + ], + + "@rollup/rollup-freebsd-arm64": [ + "@rollup/rollup-freebsd-arm64@4.60.1", + "", + { "os": "freebsd", "cpu": "arm64" }, + "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + ], + + "@rollup/rollup-freebsd-x64": [ + "@rollup/rollup-freebsd-x64@4.60.1", + "", + { "os": "freebsd", "cpu": "x64" }, + "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + ], + + "@rollup/rollup-linux-arm-gnueabihf": [ + "@rollup/rollup-linux-arm-gnueabihf@4.60.1", + "", + { "os": "linux", "cpu": "arm" }, + "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + ], + + "@rollup/rollup-linux-arm-musleabihf": [ + "@rollup/rollup-linux-arm-musleabihf@4.60.1", + "", + { "os": "linux", "cpu": "arm" }, + "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + ], + + "@rollup/rollup-linux-arm64-gnu": [ + "@rollup/rollup-linux-arm64-gnu@4.60.1", + "", + { "os": "linux", "cpu": "arm64" }, + "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + ], + + "@rollup/rollup-linux-arm64-musl": [ + "@rollup/rollup-linux-arm64-musl@4.60.1", + "", + { "os": "linux", "cpu": "arm64" }, + "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + ], + + "@rollup/rollup-linux-loong64-gnu": [ + "@rollup/rollup-linux-loong64-gnu@4.60.1", + "", + { "os": "linux", "cpu": "none" }, + "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + ], + + "@rollup/rollup-linux-loong64-musl": [ + "@rollup/rollup-linux-loong64-musl@4.60.1", + "", + { "os": "linux", "cpu": "none" }, + "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + ], + + "@rollup/rollup-linux-ppc64-gnu": [ + "@rollup/rollup-linux-ppc64-gnu@4.60.1", + "", + { "os": "linux", "cpu": "ppc64" }, + "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + ], + + "@rollup/rollup-linux-ppc64-musl": [ + "@rollup/rollup-linux-ppc64-musl@4.60.1", + "", + { "os": "linux", "cpu": "ppc64" }, + "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + ], + + "@rollup/rollup-linux-riscv64-gnu": [ + "@rollup/rollup-linux-riscv64-gnu@4.60.1", + "", + { "os": "linux", "cpu": "none" }, + "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + ], + + "@rollup/rollup-linux-riscv64-musl": [ + "@rollup/rollup-linux-riscv64-musl@4.60.1", + "", + { "os": "linux", "cpu": "none" }, + "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + ], + + "@rollup/rollup-linux-s390x-gnu": [ + "@rollup/rollup-linux-s390x-gnu@4.60.1", + "", + { "os": "linux", "cpu": "s390x" }, + "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + ], + + "@rollup/rollup-linux-x64-gnu": [ + "@rollup/rollup-linux-x64-gnu@4.60.1", + "", + { "os": "linux", "cpu": "x64" }, + "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + ], + + "@rollup/rollup-linux-x64-musl": [ + "@rollup/rollup-linux-x64-musl@4.60.1", + "", + { "os": "linux", "cpu": "x64" }, + "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + ], + + "@rollup/rollup-openbsd-x64": [ + "@rollup/rollup-openbsd-x64@4.60.1", + "", + { "os": "openbsd", "cpu": "x64" }, + "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + ], + + "@rollup/rollup-openharmony-arm64": [ + "@rollup/rollup-openharmony-arm64@4.60.1", + "", + { "os": "none", "cpu": "arm64" }, + "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + ], + + "@rollup/rollup-win32-arm64-msvc": [ + "@rollup/rollup-win32-arm64-msvc@4.60.1", + "", + { "os": "win32", "cpu": "arm64" }, + "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + ], + + "@rollup/rollup-win32-ia32-msvc": [ + "@rollup/rollup-win32-ia32-msvc@4.60.1", + "", + { "os": "win32", "cpu": "ia32" }, + "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + ], + + "@rollup/rollup-win32-x64-gnu": [ + "@rollup/rollup-win32-x64-gnu@4.60.1", + "", + { "os": "win32", "cpu": "x64" }, + "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + ], + + "@rollup/rollup-win32-x64-msvc": [ + "@rollup/rollup-win32-x64-msvc@4.60.1", + "", + { "os": "win32", "cpu": "x64" }, + "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + ], + + "@so-ric/colorspace": [ + "@so-ric/colorspace@1.1.6", + "", + { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, + "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + ], + + "@tailwindcss/node": [ + "@tailwindcss/node@4.2.2", + "", + { + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2", + }, + }, + "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + ], + + "@tailwindcss/oxide": [ + "@tailwindcss/oxide@4.2.2", + "", + { + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2", + }, + }, + "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + ], + + "@tailwindcss/oxide-android-arm64": [ + "@tailwindcss/oxide-android-arm64@4.2.2", + "", + { "os": "android", "cpu": "arm64" }, + "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + ], + + "@tailwindcss/oxide-darwin-arm64": [ + "@tailwindcss/oxide-darwin-arm64@4.2.2", + "", + { "os": "darwin", "cpu": "arm64" }, + "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + ], + + "@tailwindcss/oxide-darwin-x64": [ + "@tailwindcss/oxide-darwin-x64@4.2.2", + "", + { "os": "darwin", "cpu": "x64" }, + "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + ], + + "@tailwindcss/oxide-freebsd-x64": [ + "@tailwindcss/oxide-freebsd-x64@4.2.2", + "", + { "os": "freebsd", "cpu": "x64" }, + "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + ], + + "@tailwindcss/oxide-linux-arm-gnueabihf": [ + "@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", + "", + { "os": "linux", "cpu": "arm" }, + "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + ], + + "@tailwindcss/oxide-linux-arm64-gnu": [ + "@tailwindcss/oxide-linux-arm64-gnu@4.2.2", + "", + { "os": "linux", "cpu": "arm64" }, + "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + ], + + "@tailwindcss/oxide-linux-arm64-musl": [ + "@tailwindcss/oxide-linux-arm64-musl@4.2.2", + "", + { "os": "linux", "cpu": "arm64" }, + "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + ], + + "@tailwindcss/oxide-linux-x64-gnu": [ + "@tailwindcss/oxide-linux-x64-gnu@4.2.2", + "", + { "os": "linux", "cpu": "x64" }, + "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + ], + + "@tailwindcss/oxide-linux-x64-musl": [ + "@tailwindcss/oxide-linux-x64-musl@4.2.2", + "", + { "os": "linux", "cpu": "x64" }, + "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + ], + + "@tailwindcss/oxide-wasm32-wasi": [ + "@tailwindcss/oxide-wasm32-wasi@4.2.2", + "", + { + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1", + }, + "cpu": "none", + }, + "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + ], + + "@tailwindcss/oxide-win32-arm64-msvc": [ + "@tailwindcss/oxide-win32-arm64-msvc@4.2.2", + "", + { "os": "win32", "cpu": "arm64" }, + "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + ], + + "@tailwindcss/oxide-win32-x64-msvc": [ + "@tailwindcss/oxide-win32-x64-msvc@4.2.2", + "", + { "os": "win32", "cpu": "x64" }, + "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + ], + + "@tailwindcss/vite": [ + "@tailwindcss/vite@4.2.2", + "", + { + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2", + }, + "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" }, + }, + "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + ], + + "@tauri-apps/api": [ + "@tauri-apps/api@2.10.1", + "", + {}, + "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + ], + + "@tauri-apps/cli": [ + "@tauri-apps/cli@2.10.1", + "", + { + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.1", + "@tauri-apps/cli-darwin-x64": "2.10.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", + "@tauri-apps/cli-linux-arm64-musl": "2.10.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-musl": "2.10.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", + "@tauri-apps/cli-win32-x64-msvc": "2.10.1", + }, + "bin": { "tauri": "tauri.js" }, + }, + "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", + ], + + "@tauri-apps/cli-darwin-arm64": [ + "@tauri-apps/cli-darwin-arm64@2.10.1", + "", + { "os": "darwin", "cpu": "arm64" }, + "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", + ], + + "@tauri-apps/cli-darwin-x64": [ + "@tauri-apps/cli-darwin-x64@2.10.1", + "", + { "os": "darwin", "cpu": "x64" }, + "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", + ], + + "@tauri-apps/cli-linux-arm-gnueabihf": [ + "@tauri-apps/cli-linux-arm-gnueabihf@2.10.1", + "", + { "os": "linux", "cpu": "arm" }, + "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", + ], + + "@tauri-apps/cli-linux-arm64-gnu": [ + "@tauri-apps/cli-linux-arm64-gnu@2.10.1", + "", + { "os": "linux", "cpu": "arm64" }, + "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", + ], + + "@tauri-apps/cli-linux-arm64-musl": [ + "@tauri-apps/cli-linux-arm64-musl@2.10.1", + "", + { "os": "linux", "cpu": "arm64" }, + "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", + ], + + "@tauri-apps/cli-linux-riscv64-gnu": [ + "@tauri-apps/cli-linux-riscv64-gnu@2.10.1", + "", + { "os": "linux", "cpu": "none" }, + "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", + ], + + "@tauri-apps/cli-linux-x64-gnu": [ + "@tauri-apps/cli-linux-x64-gnu@2.10.1", + "", + { "os": "linux", "cpu": "x64" }, + "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", + ], + + "@tauri-apps/cli-linux-x64-musl": [ + "@tauri-apps/cli-linux-x64-musl@2.10.1", + "", + { "os": "linux", "cpu": "x64" }, + "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", + ], + + "@tauri-apps/cli-win32-arm64-msvc": [ + "@tauri-apps/cli-win32-arm64-msvc@2.10.1", + "", + { "os": "win32", "cpu": "arm64" }, + "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", + ], + + "@tauri-apps/cli-win32-ia32-msvc": [ + "@tauri-apps/cli-win32-ia32-msvc@2.10.1", + "", + { "os": "win32", "cpu": "ia32" }, + "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", + ], + + "@tauri-apps/cli-win32-x64-msvc": [ + "@tauri-apps/cli-win32-x64-msvc@2.10.1", + "", + { "os": "win32", "cpu": "x64" }, + "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", + ], + + "@tauri-apps/plugin-dialog": [ + "@tauri-apps/plugin-dialog@2.7.0", + "", + { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, + "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==", + ], + + "@tauri-apps/plugin-opener": [ + "@tauri-apps/plugin-opener@2.5.3", + "", + { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, + "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", + ], + + "@types/babel__core": [ + "@types/babel__core@7.20.5", + "", + { + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*", + }, + }, + "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + ], + + "@types/babel__generator": [ + "@types/babel__generator@7.27.0", + "", + { "dependencies": { "@babel/types": "^7.0.0" } }, + "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + ], + + "@types/babel__template": [ + "@types/babel__template@7.4.4", + "", + { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, + "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + ], + + "@types/babel__traverse": [ + "@types/babel__traverse@7.28.0", + "", + { "dependencies": { "@babel/types": "^7.28.2" } }, + "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + ], + + "@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@22.19.17", + "", + { "dependencies": { "undici-types": "~6.21.0" } }, + "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + ], + + "@types/react": [ + "@types/react@19.2.14", + "", + { "dependencies": { "csstype": "^3.2.2" } }, + "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + ], + + "@types/react-dom": [ + "@types/react-dom@19.2.3", + "", + { "peerDependencies": { "@types/react": "^19.2.0" } }, + "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + ], + + "@types/triple-beam": [ + "@types/triple-beam@1.3.5", + "", + {}, + "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + ], + + "@typescript-eslint/eslint-plugin": [ + "@typescript-eslint/eslint-plugin@8.58.1", + "", + { + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0", + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0", + }, + }, + "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + ], + + "@typescript-eslint/parser": [ + "@typescript-eslint/parser@8.58.1", + "", + { + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0", + }, + }, + "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + ], + + "@typescript-eslint/project-service": [ + "@typescript-eslint/project-service@8.58.1", + "", + { + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3", + }, + "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" }, + }, + "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + ], + + "@typescript-eslint/scope-manager": [ + "@typescript-eslint/scope-manager@8.58.1", + "", + { + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + }, + }, + "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + ], + + "@typescript-eslint/tsconfig-utils": [ + "@typescript-eslint/tsconfig-utils@8.58.1", + "", + { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, + "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + ], + + "@typescript-eslint/type-utils": [ + "@typescript-eslint/type-utils@8.58.1", + "", + { + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0", + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0", + }, + }, + "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + ], + + "@typescript-eslint/types": [ + "@typescript-eslint/types@8.58.1", + "", + {}, + "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + ], + + "@typescript-eslint/typescript-estree": [ + "@typescript-eslint/typescript-estree@8.58.1", + "", + { + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0", + }, + "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" }, + }, + "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + ], + + "@typescript-eslint/utils": [ + "@typescript-eslint/utils@8.58.1", + "", + { + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0", + }, + }, + "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + ], + + "@typescript-eslint/visitor-keys": [ + "@typescript-eslint/visitor-keys@8.58.1", + "", + { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, + "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + ], + + "@vitejs/plugin-react": [ + "@vitejs/plugin-react@4.7.0", + "", + { + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0", + }, + "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, + }, + "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + ], + + "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==", + ], + + "aria-hidden": [ + "aria-hidden@1.2.6", + "", + { "dependencies": { "tslib": "^2.0.0" } }, + "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + ], + + "async": [ + "async@3.2.6", + "", + {}, + "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + ], + + "balanced-match": [ + "balanced-match@4.0.4", + "", + {}, + "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + ], + + "baseline-browser-mapping": [ + "baseline-browser-mapping@2.10.16", + "", + { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, + "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + ], + + "brace-expansion": [ + "brace-expansion@5.0.5", + "", + { "dependencies": { "balanced-match": "^4.0.2" } }, + "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + ], + + "browserslist": [ + "browserslist@4.28.2", + "", + { + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3", + }, + "bin": { "browserslist": "cli.js" }, + }, + "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + ], + + "caniuse-lite": [ + "caniuse-lite@1.0.30001787", + "", + {}, + "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + ], + + "cli-cursor": [ + "cli-cursor@5.0.0", + "", + { "dependencies": { "restore-cursor": "^5.0.0" } }, + "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + ], + + "cli-truncate": [ + "cli-truncate@5.2.0", + "", + { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, + "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + ], + + "color": [ + "color@5.0.3", + "", + { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, + "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + ], + + "color-convert": [ + "color-convert@3.1.3", + "", + { "dependencies": { "color-name": "^2.0.0" } }, + "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + ], + + "color-name": [ + "color-name@2.1.0", + "", + {}, + "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + ], + + "color-string": [ + "color-string@2.1.4", + "", + { "dependencies": { "color-name": "^2.0.0" } }, + "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + ], + + "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==", + ], + + "cookie": [ + "cookie@1.1.1", + "", + {}, + "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + ], + + "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==", + ], + + "detect-libc": [ + "detect-libc@2.1.2", + "", + {}, + "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + ], + + "detect-node-es": [ + "detect-node-es@1.1.0", + "", + {}, + "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + ], + + "electron-to-chromium": [ + "electron-to-chromium@1.5.334", + "", + {}, + "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + ], + + "emoji-regex": [ + "emoji-regex@10.6.0", + "", + {}, + "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + ], + + "enabled": [ + "enabled@2.0.0", + "", + {}, + "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + ], + + "enhanced-resolve": [ + "enhanced-resolve@5.20.1", + "", + { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, + "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + ], + + "environment": [ + "environment@1.1.0", + "", + {}, + "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + ], + + "esbuild": [ + "esbuild@0.27.7", + "", + { + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7", + }, + "bin": { "esbuild": "bin/esbuild" }, + }, + "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + ], + + "escalade": [ + "escalade@3.2.0", + "", + {}, + "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + ], + + "escape-string-regexp": [ + "escape-string-regexp@4.0.0", + "", + {}, + "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + ], + + "eslint": [ + "eslint@10.2.0", + "", + { + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", + "@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.2.0", + "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-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + ], + + "eslint-plugin-react-hooks": [ + "eslint-plugin-react-hooks@7.0.1", + "", + { + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0", + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0", + }, + }, + "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + ], + + "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==", + ], + + "esutils": [ + "esutils@2.0.3", + "", + {}, + "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + ], + + "eventemitter3": [ + "eventemitter3@5.0.4", + "", + {}, + "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + ], + + "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==", + ], + + "fecha": [ + "fecha@4.2.3", + "", + {}, + "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + ], + + "file-entry-cache": [ + "file-entry-cache@8.0.0", + "", + { "dependencies": { "flat-cache": "^4.0.0" } }, + "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + ], + + "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.2", + "", + {}, + "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + ], + + "fn.name": [ + "fn.name@1.1.0", + "", + {}, + "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + ], + + "fsevents": [ + "fsevents@2.3.3", + "", + { "os": "darwin" }, + "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + ], + + "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==", + ], + + "get-nonce": [ + "get-nonce@1.0.1", + "", + {}, + "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + ], + + "glob-parent": [ + "glob-parent@6.0.2", + "", + { "dependencies": { "is-glob": "^4.0.3" } }, + "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + ], + + "graceful-fs": [ + "graceful-fs@4.2.11", + "", + {}, + "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + ], + + "hermes-estree": [ + "hermes-estree@0.25.1", + "", + {}, + "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + ], + + "hermes-parser": [ + "hermes-parser@0.25.1", + "", + { "dependencies": { "hermes-estree": "0.25.1" } }, + "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + ], + + "husky": [ + "husky@9.1.7", + "", + { "bin": { "husky": "bin.js" } }, + "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + ], + + "ignore": [ + "ignore@5.3.2", + "", + {}, + "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + ], + + "imurmurhash": [ + "imurmurhash@0.1.4", + "", + {}, + "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + ], + + "inherits": [ + "inherits@2.0.4", + "", + {}, + "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + ], + + "is-extglob": [ + "is-extglob@2.1.1", + "", + {}, + "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + ], + + "is-fullwidth-code-point": [ + "is-fullwidth-code-point@5.1.0", + "", + { "dependencies": { "get-east-asian-width": "^1.3.1" } }, + "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + ], + + "is-glob": [ + "is-glob@4.0.3", + "", + { "dependencies": { "is-extglob": "^2.1.1" } }, + "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + ], + + "is-stream": [ + "is-stream@2.0.1", + "", + {}, + "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + ], + + "isexe": [ + "isexe@2.0.0", + "", + {}, + "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + ], + + "jiti": [ + "jiti@2.6.1", + "", + { "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + ], + + "js-tokens": [ + "js-tokens@4.0.0", + "", + {}, + "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + ], + + "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==", + ], + + "kuler": [ + "kuler@2.0.0", + "", + {}, + "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + ], + + "levn": [ + "levn@0.4.1", + "", + { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, + "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + ], + + "lightningcss": [ + "lightningcss@1.32.0", + "", + { + "dependencies": { "detect-libc": "^2.0.3" }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0", + }, + }, + "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + ], + + "lightningcss-android-arm64": [ + "lightningcss-android-arm64@1.32.0", + "", + { "os": "android", "cpu": "arm64" }, + "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + ], + + "lightningcss-darwin-arm64": [ + "lightningcss-darwin-arm64@1.32.0", + "", + { "os": "darwin", "cpu": "arm64" }, + "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + ], + + "lightningcss-darwin-x64": [ + "lightningcss-darwin-x64@1.32.0", + "", + { "os": "darwin", "cpu": "x64" }, + "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + ], + + "lightningcss-freebsd-x64": [ + "lightningcss-freebsd-x64@1.32.0", + "", + { "os": "freebsd", "cpu": "x64" }, + "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + ], + + "lightningcss-linux-arm-gnueabihf": [ + "lightningcss-linux-arm-gnueabihf@1.32.0", + "", + { "os": "linux", "cpu": "arm" }, + "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + ], + + "lightningcss-linux-arm64-gnu": [ + "lightningcss-linux-arm64-gnu@1.32.0", + "", + { "os": "linux", "cpu": "arm64" }, + "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + ], + + "lightningcss-linux-arm64-musl": [ + "lightningcss-linux-arm64-musl@1.32.0", + "", + { "os": "linux", "cpu": "arm64" }, + "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + ], + + "lightningcss-linux-x64-gnu": [ + "lightningcss-linux-x64-gnu@1.32.0", + "", + { "os": "linux", "cpu": "x64" }, + "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + ], + + "lightningcss-linux-x64-musl": [ + "lightningcss-linux-x64-musl@1.32.0", + "", + { "os": "linux", "cpu": "x64" }, + "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + ], + + "lightningcss-win32-arm64-msvc": [ + "lightningcss-win32-arm64-msvc@1.32.0", + "", + { "os": "win32", "cpu": "arm64" }, + "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + ], + + "lightningcss-win32-x64-msvc": [ + "lightningcss-win32-x64-msvc@1.32.0", + "", + { "os": "win32", "cpu": "x64" }, + "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + ], + + "lint-staged": [ + "lint-staged@16.4.0", + "", + { + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2", + }, + "bin": { "lint-staged": "bin/lint-staged.js" }, + }, + "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + ], + + "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==", + ], + + "logform": [ + "logform@2.7.0", + "", + { + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0", + }, + }, + "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + ], + + "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==", + ], + + "mimic-function": [ + "mimic-function@5.0.1", + "", + {}, + "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + ], + + "minimatch": [ + "minimatch@10.2.5", + "", + { "dependencies": { "brace-expansion": "^5.0.5" } }, + "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + ], + + "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.37", + "", + {}, + "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + ], + + "one-time": [ + "one-time@1.0.0", + "", + { "dependencies": { "fn.name": "1.x.x" } }, + "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + ], + + "onetime": [ + "onetime@7.0.0", + "", + { "dependencies": { "mimic-function": "^5.0.0" } }, + "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + ], + + "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==", + ], + + "path-exists": [ + "path-exists@4.0.0", + "", + {}, + "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + ], + + "path-key": [ + "path-key@3.1.1", + "", + {}, + "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + ], + + "picocolors": [ + "picocolors@1.1.1", + "", + {}, + "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + ], + + "picomatch": [ + "picomatch@4.0.4", + "", + {}, + "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + ], + + "playwright": [ + "playwright@1.59.1", + "", + { + "dependencies": { "playwright-core": "1.59.1" }, + "optionalDependencies": { "fsevents": "2.3.2" }, + "bin": { "playwright": "cli.js" }, + }, + "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + ], + + "playwright-core": [ + "playwright-core@1.59.1", + "", + { "bin": { "playwright-core": "cli.js" } }, + "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + ], + + "postcss": [ + "postcss@8.5.9", + "", + { + "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, + }, + "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + ], + + "prelude-ls": [ + "prelude-ls@1.2.1", + "", + {}, + "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + ], + + "prettier": [ + "prettier@3.8.1", + "", + { "bin": { "prettier": "bin/prettier.cjs" } }, + "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + ], + + "punycode": [ + "punycode@2.3.1", + "", + {}, + "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + ], + + "qrcode.react": [ + "qrcode.react@4.2.0", + "", + { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + ], + + "react": [ + "react@19.2.4", + "", + {}, + "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + ], + + "react-dom": [ + "react-dom@19.2.4", + "", + { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, + "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + ], + + "react-refresh": [ + "react-refresh@0.17.0", + "", + {}, + "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + ], + + "react-remove-scroll": [ + "react-remove-scroll@2.7.2", + "", + { + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3", + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + ], + + "react-remove-scroll-bar": [ + "react-remove-scroll-bar@2.3.8", + "", + { + "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + ], + + "react-router": [ + "react-router@7.14.0", + "", + { + "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, + "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, + "optionalPeers": ["react-dom"], + }, + "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + ], + + "react-router-dom": [ + "react-router-dom@7.14.0", + "", + { + "dependencies": { "react-router": "7.14.0" }, + "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, + }, + "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", + ], + + "react-style-singleton": [ + "react-style-singleton@2.2.3", + "", + { + "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + ], + + "readable-stream": [ + "readable-stream@3.6.2", + "", + { + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1", + }, + }, + "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + ], + + "restore-cursor": [ + "restore-cursor@5.1.0", + "", + { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, + "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + ], + + "rfdc": [ + "rfdc@1.4.1", + "", + {}, + "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + ], + + "rollup": [ + "rollup@4.60.1", + "", + { + "dependencies": { "@types/estree": "1.0.8" }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2", + }, + "bin": { "rollup": "dist/bin/rollup" }, + }, + "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + ], + + "safe-buffer": [ + "safe-buffer@5.2.1", + "", + {}, + "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + ], + + "safe-stable-stringify": [ + "safe-stable-stringify@2.5.0", + "", + {}, + "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + ], + + "scheduler": [ + "scheduler@0.27.0", + "", + {}, + "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + ], + + "semver": [ + "semver@6.3.1", + "", + { "bin": { "semver": "bin/semver.js" } }, + "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + ], + + "set-cookie-parser": [ + "set-cookie-parser@2.7.2", + "", + {}, + "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + ], + + "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==", + ], + + "signal-exit": [ + "signal-exit@4.1.0", + "", + {}, + "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + ], + + "slice-ansi": [ + "slice-ansi@8.0.0", + "", + { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, + "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + ], + + "source-map-js": [ + "source-map-js@1.2.1", + "", + {}, + "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + ], + + "stack-trace": [ + "stack-trace@0.0.10", + "", + {}, + "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + ], + + "string-argv": [ + "string-argv@0.3.2", + "", + {}, + "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + ], + + "string-width": [ + "string-width@8.2.0", + "", + { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, + "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + ], + + "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==", + ], + + "tailwindcss": [ + "tailwindcss@4.2.2", + "", + {}, + "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + ], + + "tapable": [ + "tapable@2.3.2", + "", + {}, + "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + ], + + "text-hex": [ + "text-hex@1.0.0", + "", + {}, + "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + ], + + "tinyexec": [ + "tinyexec@1.1.1", + "", + {}, + "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + ], + + "tinyglobby": [ + "tinyglobby@0.2.16", + "", + { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, + "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + ], + + "triple-beam": [ + "triple-beam@1.4.1", + "", + {}, + "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + ], + + "ts-api-utils": [ + "ts-api-utils@2.5.0", + "", + { "peerDependencies": { "typescript": ">=4.8.4" } }, + "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + ], + + "tslib": [ + "tslib@2.8.1", + "", + {}, + "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + ], + + "type-check": [ + "type-check@0.4.0", + "", + { "dependencies": { "prelude-ls": "^1.2.1" } }, + "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + ], + + "typescript": [ + "typescript@5.8.3", + "", + { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, + "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + ], + + "typescript-eslint": [ + "typescript-eslint@8.58.1", + "", + { + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0", + }, + }, + "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + ], + + "undici-types": [ + "undici-types@6.21.0", + "", + {}, + "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + ], + + "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==", + ], + + "use-callback-ref": [ + "use-callback-ref@1.3.3", + "", + { + "dependencies": { "tslib": "^2.0.0" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + ], + + "use-sidecar": [ + "use-sidecar@1.1.3", + "", + { + "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + }, + "optionalPeers": ["@types/react"], + }, + "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + ], + + "util-deprecate": [ + "util-deprecate@1.0.2", + "", + {}, + "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + ], + + "vite": [ + "vite@7.3.2", + "", + { + "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-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + ], + + "which": [ + "which@2.0.2", + "", + { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, + "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + ], + + "winston": [ + "winston@3.19.0", + "", + { + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0", + }, + }, + "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + ], + + "winston-transport": [ + "winston-transport@4.9.0", + "", + { + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0", + }, + }, + "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + ], + + "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==", + ], + + "yallist": [ + "yallist@3.1.1", + "", + {}, + "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + ], + + "yaml": [ + "yaml@2.8.3", + "", + { "bin": { "yaml": "bin.mjs" } }, + "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + ], + + "yocto-queue": [ + "yocto-queue@0.1.0", + "", + {}, + "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + ], + + "zod": [ + "zod@4.3.6", + "", + {}, + "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + ], + + "zod-validation-error": [ + "zod-validation-error@4.0.2", + "", + { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, + "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + ], + + "zustand": [ + "zustand@5.0.12", + "", + { + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0", + }, + "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"], + }, + "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + ], + + "@eslint-community/eslint-utils/eslint-visitor-keys": [ + "eslint-visitor-keys@3.4.3", + "", + {}, + "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + ], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": [ + "@emnapi/core@1.9.2", + "", + { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, + "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + ], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": [ + "@emnapi/runtime@1.9.2", + "", + { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, + "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + ], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": [ + "@emnapi/wasi-threads@1.2.1", + "", + { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, + "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + ], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": [ + "@napi-rs/wasm-runtime@1.1.3", + "", + { + "dependencies": { "@tybys/wasm-util": "^0.10.1" }, + "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, + "bundled": true, + }, + "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + ], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": [ + "@tybys/wasm-util@0.10.1", + "", + { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, + "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + ], + + "@tailwindcss/oxide-wasm32-wasi/tslib": [ + "tslib@2.8.1", + "", + { "bundled": true }, + "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + ], + + "@typescript-eslint/eslint-plugin/ignore": [ + "ignore@7.0.5", + "", + {}, + "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + ], + + "@typescript-eslint/typescript-estree/semver": [ + "semver@7.7.4", + "", + { "bin": { "semver": "bin/semver.js" } }, + "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + ], + + "log-update/slice-ansi": [ + "slice-ansi@7.1.2", + "", + { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, + "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + ], + + "playwright/fsevents": [ + "fsevents@2.3.2", + "", + { "os": "darwin" }, + "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + ], + + "wrap-ansi/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==", + ], + }, } diff --git a/cli_plan.md b/cli_plan.md new file mode 100644 index 0000000..4c1dd35 --- /dev/null +++ b/cli_plan.md @@ -0,0 +1,286 @@ +# Pengine CLI Plan (v3 - Tauri-only, implementation-corrected) + +> Last updated: 2026-04-23. +> This version supersedes the earlier v2 draft and aligns with the current repository state. + +## 1. Goal + +Ship a first-class terminal surface for Pengine with the same domain services as the desktop app, while keeping model access constrained to the agent path (`ask` and free text in REPL/Telegram bridge), not native operational commands. + +## 2. Scope and non-goals + +### In scope + +- One binary (`pengine`) with CLI branching through `tauri-plugin-cli`. +- Native subcommands for status/config/model/bot/tools/skills/fs/logs (and `app`, etc.). +- Agent path via `ask` and REPL free text. +- Telegram `$` bridge reusing the same router + handlers. +- Same feature that claude code - https://github.com/anthropics/claude-code + +### Out of scope for initial delivery + +- A separate non-Tauri runtime or a new `pengine-engine` product. +- Full dashboard parity in the same PR as CLI bootstrap stabilization. +- Telegram inline rerun/rollback buttons. + +## 3. Current baseline (code already present) + +Implemented in-tree: + +- `src-tauri/src/modules/cli/` exists with: + - `bootstrap.rs`, `commands.rs`, `dispatch.rs`, `handlers.rs`, `output.rs`, + `repl.rs`, `router.rs`, `telegram_bridge.rs` +- `modules/agent/` exists and is used by both bot + CLI. +- `tauri-plugin-cli` is registered in `app.rs`. +- CLI schema is present in `src-tauri/tauri.conf.json`. +- **How to test:** [doc/guides/cli.md](doc/guides/cli.md) — `bun run cli -- …`, + global flags **before** subcommands (`--json status`), `bun run cli:test` for + `tests/cli_oneshot.rs`. + +## 4. Architecture and boundaries + +### 4.1 Module layout (authoritative for this feature) + +```text +src-tauri/src/modules/cli/ +├── mod.rs +├── bootstrap.rs +├── commands.rs +├── dispatch.rs # shared router dispatch (REPL + Telegram `$`) +├── handlers.rs +├── output.rs +├── repl.rs +├── router.rs +└── telegram_bridge.rs # `$` strip, MCP warmup, Telegram chunk formatting +``` + +### 4.2 Dependency rules + +- Keep DDD direction: `infrastructure -> modules -> shared`. +- CLI handlers are adapters only. Business logic stays in existing domain services. +- No transport formatting in handlers (ANSI/fences/chunking stays in sinks). + +## 5. CLI bootstrap contract + +### 5.1 Entry behavior + +`app.rs` calls `cli_bootstrap::handle_cli_or_continue(app)` before full UI initialization. + +Expected contract: + +1. CLI invocation (`--help`, `--version`, or configured subcommand): run handler and exit process with a deterministic code. +2. Non-CLI invocation (bare app open): return and continue normal UI startup. + +### 5.2 Bootstrap validation + +- One-shot exit is covered by `src-tauri/tests/cli_oneshot.rs` (`version`, `help`, + `pengine --json status`). Run: `bun run cli:test`. +- Optional next: `ask` integration with a stubbed or absent Ollama (flaky in CI). + +### 5.3 Stateful CLI process hydration + +Current one-shot state build is too minimal for some commands. Stateful bootstrap must hydrate: + +- `connection.json` metadata (`bot_id`, `bot_username`, `connected_at`). +- Keychain token only when needed by command semantics. +- `AppState.connection` where commands rely on in-memory connection data. + +Without this, one-shot `status` and `bot disconnect` can misreport or skip token cleanup. + +## 6. Command model + +### 6.1 Source of truth + +- `commands.rs` is the metadata registry for command names and summaries. +- Dispatch is still explicit in `bootstrap.rs` (one match arm per command). +- Therefore, adding a command is currently multi-point, not "one file edit". + +Future improvement: + +- Generate dispatch/help/http metadata from one typed registry to remove drift. + +### 6.2 Native command mapping (corrected to current services) + +| Command | Backing behavior | +| --------------------------------------- | ----------------------------------------------------------------------------------------- | +| `status` | Read hydrated connection state + `ollama::active_model` + MCP registry count + settings | +| `config [k=v ...]` | `shared::user_settings` load/save + clamp | +| `model [name\|--clear]` | `state.preferred_ollama_model` + `ollama::model_catalog` validation | +| `bot connect ` | `bot_service::verify_token` + `secure_store::save_token` + `bot_repo::persist` | +| `bot disconnect` | `bot_lifecycle::stop_and_wait_for_bot` + `bot_repo::clear` + `secure_store::delete_token` | +| `tools [search]` | MCP registry `all_tools()` after `mcp_service::rebuild_registry_into_state` | +| `skills [list\|enable\|disable ]` | `skills::service` list/set enabled | +| `fs [path]` | MCP config load/mutate/save via `mcp_service` helpers | +| `logs [--tail N] [--follow]` | `AppState.log_tx.subscribe()` (follow implemented, historical tail pending) | +| `ask ` | `modules::agent::run_turn` | +| `repl` | `modules::cli::repl` | +| `help`, `version` | native handlers + plugin parse support | + +### 6.3 Agent path invariants + +- Model path is only `ask` or free text in REPL/bridge. +- Native command names are never injected into model prompt context. + +## 7. Router safety contract + +Router outcomes: + +- `Native { name, rest }` +- `Agent(text)` +- `Unknown(name)` + +Invariant: + +- `Unknown` must never fall through to `Agent`. +- Keep unit tests for this invariant on every router change. + +## 8. Output contract + +### 8.1 Reply envelope + +Current `ReplyKind`: + +- `Text` +- `CodeBlock { lang }` +- `Diff` +- `Log` +- `Error` + +### 8.2 Sink status + +Implemented: + +- `TerminalSink` +- `JsonSink` +- Telegram `$` bridge: fenced + `split_by_chars` chunking in + `telegram_bridge::telegram_cli_reply_chunks` (transport-specific; not the + same trait as terminal sinks). + +Planned: + +- `FanOut` multiplexer for `--no-terminal` / `--no-telegram` when wired + +### 8.3 JSON schema (actual current behavior) + +Machine output is versioned and currently emitted as: + +```json +{ "v": 1, "reply": { "kind": "text", "body": "..." } } +``` + +If we want top-level `kind/body`, do it as an explicit schema migration (v2), not as a silent shape change. + +### 8.4 Flag parity issue to resolve + +`--no-terminal` and `--no-telegram` exist in CLI schema/help text, but runtime currently only honors `--json`. + +Decision required: + +- Either implement sink toggling now, or remove unsupported flags until Telegram sink lands. + +## 9. REPL + +Current design is acceptable: + +- `rustyline` line editing/history. +- `/exit`, `/quit`, `exit`, `quit` terminate loop. +- History path: `$APP_DATA/cli_history`. +- Best-effort MCP warmup before interactive loop. + +Needed refinement: + +- Route log streaming through sink abstractions where feasible (`logs --follow` currently prints directly). + +## 10. Telegram bridge (PR C — implemented) + +Behavior: + +- In `bot/service.rs::text_handler`, messages whose trimmed text starts with `$` + are handled **before** `agent::run_turn`. +- Payload after `$` is passed to `cli::telegram_bridge::run_telegram_cli_line`, + which MCP-warmups (best-effort), then `cli::dispatch::dispatch_line` with + `DispatchContext::telegram()` (e.g. `logs --follow` is rejected on Telegram). +- Replies are formatted (Markdown-style fences for code/log/diff) and split + with `shared::text::split_by_chars` under the same UTF-16-safe budget as + normal Telegram replies. + +Policy: + +- `$` prefix is CLI intent (router + native handlers + `/ask`-style slash paths). +- Non-`$` text stays on the normal agent conversation path. + +Dependency note: `verify_token` lives in `modules/bot/token_verify.rs` so +`cli::handlers` does not import `bot::service`, avoiding a `bot → cli → bot` +cycle. + +## 11. Single-instance + shim strategy (phase 3) + +Status: + +- `tauri-plugin-single-instance` is not wired yet. +- No implemented argv forwarding + response channel exists today. + +Plan: + +1. Validate if plugin callback surface can support request/response semantics for CLI output. +2. If not, add a small explicit local IPC mechanism for forwarded CLI output. +3. Only then add OS shim install/uninstall UX. + +Keep one-binary objective; do not block CLI stabilization on this phase. + +## 12. HTTP/dashboard parity (phase 4) + +Implemented: + +- `GET /v1/cli/commands` — JSON from `modules::cli::commands::COMMANDS` (see `http_server.rs`). +- Dashboard: `CliCommandsPanel` on the main dashboard (fetches the endpoint). + +Further work (optional): richer panel (copy snippets, deep links), `doc/guides/cli.md`. + +## 13. Acceptance criteria status + +| Criterion | Status on 2026-04-24 | Notes | +| ---------------------------------------- | -------------------- | ---------------------------------------------------- | +| CLI command list exists | Implemented | Registry + `GET /v1/cli/commands` + dashboard panel | +| One-shot CLI execution exits reliably | Partial | `cli_oneshot` tests + `bun run cli`; packaged builds TBD | +| Agent path isolated from native commands | Implemented | Router invariant; `$` bridge uses same dispatch | +| Versioned JSON output | Implemented | Shape is `{"v":1,"reply":...}` | +| Terminal REPL | Implemented | `rustyline` + history | +| Telegram CLI bridge | Implemented | `$` path + `telegram_bridge.rs` + `dispatch.rs` | +| `--no-terminal` / `--no-telegram` flags | Pending | Declared, not honored | +| `/v1/cli/commands` API + dashboard list | Implemented | Axum route + `CliCommandsPanel` | +| Single-instance forwarding + shim UX | Partial | Dashboard `pengine-cli` launcher install/remove; single-instance TBD | +| SSH guide + setup script | Pending | Docs/script not present | + +## 14. Delivery sequence (updated) + +1. **PR A - Stabilize bootstrap (blocking)** + - Fix one-shot CLI termination behavior. + - Hydrate state correctly for connection-aware commands. + - Add integration tests for one-shot commands. +2. **PR B - Contract cleanup** + - Resolve unsupported flag mismatch. + - Align JSON contract docs with emitted envelope. + - Route streaming output through sinks where practical. +3. **PR C - Telegram bridge** (done in tree) + - `$` path in `bot/service.rs::text_handler` before `agent::run_turn`. + - `cli/telegram_bridge.rs` + `cli/dispatch.rs`; chunk-safe fenced output. + - Optional follow-up: unify streaming/`println` paths with sink traits. +4. **PR D - Parity and operability** (partially done) + - `GET /v1/cli/commands` + dashboard `CliCommandsPanel` — merged. + - Remaining: single-instance forwarding + shim UX; CLI guides (`doc/guides/cli.md`, `doc/guides/cli-ssh.md`); optional sink/`--no-terminal` polish from PR B. + +## 15. Required documentation sync + +Must update with CLI work: + +- `doc/README.md` feature map currently references old agent path (`modules/bot/agent.rs`). +- `doc/architecture/README.md` backend tree still shows `bot/agent.rs`. +- Add CLI guides only when behavior is stable and tested. + +## 16. Decision log (defaults) + +- Keep `$` as Telegram CLI prefix. +- Keep JSON envelope version field from day one. +- Keep Tauri-only single-binary direction. +- Defer broader core/engine tree migration from this CLI track. diff --git a/doc/README.md b/doc/README.md index 191ee18..cf1fb19 100644 --- a/doc/README.md +++ b/doc/README.md @@ -56,6 +56,7 @@ Product overview: [../README.md](../README.md). | [guides/custom-mcp-tools.md](guides/custom-mcp-tools.md) | Concepts, dashboard vs API, `mcp.json` paths, stdio fields, Docker/custom tools, pitfalls | | [guides/releasing.md](guides/releasing.md) | Tag-driven release pipeline, code signing/notarization explained, how to obtain Apple + Windows secrets | | [guides/deploying-web.md](guides/deploying-web.md) | Web-app deploy pipeline: GHCR image + SSH docker-compose rollout to the host | +| [guides/cli.md](guides/cli.md) | Terminal CLI: `bun run cli`, flag order, `tauri dev --`, one-shot tests | ### Tool Engine (maintainers) @@ -72,7 +73,8 @@ Product overview: [../README.md](../README.md). | **Web UI** | Landing, setup wizard, dashboard | `src/pages/`, `src/App.tsx` | | **Loopback HTTP API** | REST + SSE on `127.0.0.1:21516` | `src-tauri/src/infrastructure/http_server.rs` | | **Telegram bot** | Token verify, dispatch, replies | `src-tauri/src/modules/bot/` | -| **Agent loop** | Ollama chat + tools, step cap, policies | `src-tauri/src/modules/bot/agent.rs` | +| **Agent loop** | Ollama chat + tools, step cap, policies | `src-tauri/src/modules/agent/` | +| **Terminal CLI** | `tauri-plugin-cli`, REPL, Telegram `$` bridge | `src-tauri/src/modules/cli/` | | **Ollama** | Models list, active/selected model | `src-tauri/src/modules/ollama/`, `GET/PUT /v1/ollama/*` | | **MCP** | stdio transports, registry, `tools/call` | `src-tauri/src/modules/mcp/` | | **Tool Engine** | Catalog install, custom images, runtime probe | `src-tauri/src/modules/tool_engine/`, `src/modules/toolengine/` | diff --git a/doc/guides/cli.md b/doc/guides/cli.md new file mode 100644 index 0000000..fc59d56 --- /dev/null +++ b/doc/guides/cli.md @@ -0,0 +1,209 @@ +# Pengine terminal CLI (testing and daily use) + +The desktop app binary (`pengine`) also handles **native CLI** commands via +[`tauri-plugin-cli`](https://v2.tauri.app/plugin/cli/). There is no separate CLI +executable. The main webview window is created only when the app stays in **GUI +mode** (after setup), so a terminal **`pengine`** session never opens a webview in that process. + +## `pengine` (shell) vs `pengine app` (window) + +- **`pengine`** with no subcommand in a **real terminal (TTY)** → interactive shell only (REPL). That process is terminal-only (no menu-bar / Dock “app open” state tied to a GUI window from this invocation). +- **`pengine app`** → starts the **desktop UI in a separate process**. You can leave a **`pengine`** shell running in one terminal and **`pengine app`** in another (or run the app from Finder); they can run in parallel. +- **`pengine --shell`** — with no subcommand, never opens the GUI **in-process**; exits with an error if there is no TTY (same idea as the **`pengine-cli`** launcher). +- **No TTY** (Finder / Dock / `.desktop` / Windows Start menu / `open -a pengine`) — the process opens the **GUI window** on every platform, unless `--shell` / `PENGINE_LAUNCH_MODE=cli` explicitly forces terminal-only mode. + +macOS does **not** put the dev build on `PATH` automatically, so a bare `pengine` +in Terminal fails with `command not found` until you either: + +1. **Add the repo `scripts/` folder to `PATH`** (recommended for development). + The repo includes `scripts/pengine`, a small launcher that runs + `src-tauri/target/debug/pengine` from your clone. + + ```bash + # Replace with the path where you cloned pengine + export PATH="/Users/you/Projects/agents/pengine/scripts:$PATH" + ``` + + Add that line to `~/.zshrc` (or `~/.bashrc`), open a new terminal, then: + + ```bash + pengine version + ``` + + The first time, build the real binary once: + + ```bash + cd /path/to/pengine && cargo build --manifest-path src-tauri/Cargo.toml + ``` + +2. **Or** call the binary by full path (no `PATH` change): + + ```bash + /path/to/pengine/src-tauri/target/debug/pengine version + ``` + +3. **Or** stay inside the repo and use **`bun run cli -- …`** (see below). + +Packaged app installs may expose `pengine` differently; this guide focuses on +**local development**. + +## Quick test (development tree) + +From the **repository root**: + +```bash +bun run cli -- # interactive REPL + ASCII welcome (same as bare `pengine` in a real terminal) +bun run cli -- version +bun run cli -- help +bun run cli -- status +``` + +**Bare `pengine`:** when stdin is a **terminal** (TTY) and you pass no +subcommand, the process stays in the REPL only (**no GUI** in that process). +macOS **Finder / Dock** still use a non-TTY launch with `-psn_…`, and that path +starts the GUI **in-process** as before. From a script without a TTY (and no +Finder arg), use **`pengine app`** for the window or **`pengine status`** (etc.) +for one-shots. + +These run `cargo run --manifest-path src-tauri/Cargo.toml -- …`, so the first +build can take a while. + +Direct **Cargo** (same effect): + +```bash +cargo run --manifest-path src-tauri/Cargo.toml -- version +``` + +### Installed app (Tauri desktop): add `pengine-cli` to the terminal + +A normal **.dmg / .app** install does **not** change your shell `PATH` (macOS +Gatekeeper and security best practice). After you open the installed app once: + +1. Open **Dashboard** → **Terminal CLI** panel. +2. Turn **CLI on PATH** on (writes `~/.local/bin/pengine-cli` on macOS/Linux, or + `%LOCALAPPDATA%\Pengine\bin\pengine-cli.cmd` on Windows). The launcher sets + `PENGINE_LAUNCH_MODE=cli` and runs the same binary as the app, so terminal use + matches `bun run cli` (REPL when you run it with no args in a TTY; one-shots + with subcommands). It does **not** open the GUI when stdin is not a TTY. +3. If the panel says the launcher directory is not on `PATH`, add the suggested + `export PATH=…` line to `~/.zshrc` (or Windows user PATH), then open a **new** + terminal. Use **`pengine-cli`** for the shell, **`pengine-cli app`** for the window. + +Re-toggle **CLI on PATH** after moving or updating the app if you want the launcher +to track the new binary path. + +The app bundle still contains the **`pengine`** binary; **`pengine-cli`** on `PATH` is the terminal-first launcher (same idea as dev **`bun run cli`**). + +Fully automatic PATH changes **at install time** would require a custom +installer (e.g. macOS `.pkg` postinstall script); the dashboard flow keeps that +explicit and reversible (**Remove launcher**). + +## Global flags (order matters) + +Flags declared at the **root** of the CLI schema (`--json`, `--no-terminal`, +`--no-telegram`) must appear **before** the subcommand, for example: + +```bash +pengine --json status +``` + +Not: + +```bash +pengine status --json # rejected by the CLI parser +``` + +`pengine help` documents the native command names; machine-readable metadata is +also available from the local HTTP API: `GET /v1/cli/commands`. + +## `tauri dev` and arguments + +To pass CLI args through the Tauri dev runner, put them **after** `--` so they +reach the app binary, for example: + +```bash +bun run tauri dev -- -- version +``` + +Without subcommands after `--`, the app starts in **GUI** mode as usual. + +## What to expect + +- **One-shot commands** (`version`, `help`, `status`, `ask`, …) should print and + exit with code **0** (or non-zero on error), without leaving a window open. +- **Bare `pengine`** (TTY, no subcommand) starts an interactive session (line editor + history); exit with + `/exit`, `exit`, `quit`, or Ctrl+D. +- **`logs --follow`** streams until interrupted (Ctrl+C); avoid it in + automation unless you plan to kill the process. + +## Interactive feedback (REPL + `ask`) + +The REPL renders Claude-Code-style: + +``` +❯ what changed in the fetch tool? + ⎿ · called fetch (step 0) + ⎿ · fetch: 4012 bytes + ⎿ Baked for 4.8s + ⎿ The fetch tool now deduplicates URLs per user message … +``` + +- **Prompt**: bold-cyan `❯ ` on a TTY, plain `> ` when stdout is piped. +- **Reply prefix**: ` ⎿ ` on the first line, five-space continuation for the rest. Replies are coloured by [`ReplyKind`](../../src-tauri/src/modules/cli/output.rs): diff blocks get green `+` / red `-`, code blocks print raw. +- **Inline tool-event blocks**: while `ask` / a free-text REPL line is running, each `"tool"` log event (call start, `name: N bytes` result, errors, host auto-fetch) is printed as its own persistent ` ⎿ · …` line above the spinner (`handlers::inline_tool_block`). This is Claude-Code-like per-step visibility without touching the agent loop — events come from the existing `AppState.log_tx` broadcast. +- **Thinking spinner** (free-text or `/ask …`): between tool events a braille spinner on **stderr** tags the latest `run` / `tool_ctx` / `mcp` / `ollama` event, e.g. `⠋ Thinking · tool_ctx: ranked 4/22 · 2.3s`. The spinner is suppressed when stderr is not a TTY, so `--json`, CI, and piped output stay clean. +- **Elapsed summary**: after the turn finishes the spinner line is cleared and replaced with ` ⎿ Baked for 4.8s`, matching the reply prefix. +- **Diff blocks from the agent**: if the agent's reply contains ` ```diff … ``` ` fences, each fence is pulled out and rendered as its own coloured diff block; surrounding prose stays as text (see `output::split_text_into_blocks`). + +## Audit log (`"kind":"cli"`) + +Every CLI action lands in `{store_dir}/logs/audit-.log` (JSON-lines, same shape as the in-memory log broadcast) alongside the bot / MCP / agent events. Two kinds of audit lines: + +- **One-shot subcommand**: `bootstrap::cli_subcommand_audit_summary` emits `pengine …` with secrets redacted (e.g. `pengine bot connect `) and long args truncated (~400 chars for `config`, 800 for `ask`, etc.). +- **REPL line**: `dispatch::format_repl_line_for_audit` emits `repl ` with the same redaction rules (case-insensitive `/bot connect` and `bot connect` are both caught). + +Tail the last N audit entries from the CLI itself: + +``` +pengine logs --tail 100 +``` + +That reads the newest files under `{store_dir}/logs/` backwards until N lines are collected (or no older files remain). For an on-disk grep: + +```bash +store=$(pengine status | awk '/^store:/ {print $2}' | xargs dirname) +rg '"kind":"cli"' "$store"/logs/audit-$(date +%Y-%m-%d).log +``` + +(`pengine status` prints the `connection.json` path; its parent directory holds the `logs/` folder. `secure_store` keys never touch the audit JSON.) + +## Known gaps vs Claude Code + +These are deliberate omissions for the current feature set — tracked for later but not implemented today: + +- **Streaming tool-call result bodies inside a reply**: each tool call now shows as its own ` ⎿ · …` line, but the **full** result body is still collapsed. Claude Code shows expandable tool outputs; Pengine only shows the one-line summary (`name: N bytes`, `name error: …`). Surfacing full bodies would need `agent::run_turn` to forward content, not just `emit_log` notices. +- **Inline Telegram buttons** (rerun / rollback): explicitly deferred (`cli_plan.md` §11). + +## Automated checks + +```bash +bun run cli:test +``` + +Runs `tests/cli_oneshot.rs` (spawns `target/debug/pengine`). Requires a +successful `cargo build` for the binary to exist. + +## Linux / headless note + +Tauri still initializes the GUI stack on Linux. If `cargo run` / tests fail +with display errors, run under a virtual framebuffer (example): + +```bash +xvfb-run -a cargo run --manifest-path src-tauri/Cargo.toml -- version +``` + +## Telegram + +Messages starting with **`$`** are treated as the same router surface as the +REPL (native `/…` commands or free text to the agent). Normal messages (no +`$`) go straight to the agent. diff --git a/doc/reference/http-api.md b/doc/reference/http-api.md index c24398b..39c22c4 100644 --- a/doc/reference/http-api.md +++ b/doc/reference/http-api.md @@ -29,6 +29,12 @@ Authoritative list: **`Router::new()`** in `http_server.rs`. Below: method, path | GET | `/v1/settings` | `skills_hint_max_bytes` + min/max/default. | | PUT | `/v1/settings` | Body: `{ "skills_hint_max_bytes": }` — clamped; persists `user_settings.json`. | +## CLI (terminal surface) + +| Method | Path | Notes | +| --- | --- | --- | +| GET | `/v1/cli/commands` | JSON: `{ "commands": [ { "name", "summary" }, … ] }` — native command metadata (same registry as `pengine help`). | + ## MCP | Method | Path | Notes | diff --git a/package-lock.json b/package-lock.json index 8696f83..4e5b402 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "pengine", - "version": "1.0.2", + "name": "Pengine", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pengine", - "version": "1.0.2", + "name": "Pengine", + "version": "1.0.3", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", diff --git a/package.json b/package.json index f90d1e6..2d55d26 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "pengine", + "name": "Pengine", "private": true, - "version": "1.0.2", + "version": "1.0.3", "type": "module", "scripts": { "dev": "vite", @@ -10,6 +10,8 @@ "build": "bun run build:web", "preview": "vite preview", "tauri": "tauri", + "cli": "cargo run --manifest-path src-tauri/Cargo.toml --", + "cli:test": "cargo test --manifest-path src-tauri/Cargo.toml --test cli_oneshot", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "generate:logos": "bash scripts/generate-logo-assets.sh", diff --git a/scripts/pengine b/scripts/pengine new file mode 100755 index 0000000..2d0f5e5 --- /dev/null +++ b/scripts/pengine @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Dev launcher: run `pengine` from any directory after `cargo build`. +# Put this directory on PATH, e.g. in ~/.zshrc: +# export PATH="/path/to/pengine/scripts:$PATH" +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +EXE="$ROOT/src-tauri/target/debug/pengine" +if [[ ! -f "$EXE" ]]; then + echo "pengine: binary not found at $EXE" >&2 + echo "Build it once from the repo root:" >&2 + echo " cargo build --manifest-path \"$ROOT/src-tauri/Cargo.toml\"" >&2 + exit 127 +fi +exec "$EXE" "$@" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0c3beed..dc58a3d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -631,6 +631,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -661,6 +667,42 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.58" @@ -1182,6 +1224,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "enumflags2" version = "0.7.12" @@ -1263,6 +1311,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1290,6 +1344,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1910,6 +1975,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2771,6 +2845,27 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -2779,7 +2874,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "memoffset", ] @@ -3183,12 +3278,14 @@ dependencies = [ "log", "regex", "reqwest 0.13.2", + "rustyline", "security-framework", "serde", "serde_json", "socket2 0.5.10", "tauri", "tauri-build", + "tauri-plugin-cli", "tauri-plugin-dialog", "tauri-plugin-opener", "teloxide", @@ -3645,7 +3742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -3686,7 +3783,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2 0.6.3", @@ -3715,6 +3812,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.7.3" @@ -4148,6 +4255,28 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.28.0", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.52.0", +] + [[package]] name = "ryu" version = "1.0.23" @@ -5015,6 +5144,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-cli" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28e78fb2c09a81546bcd376d34db4bda5769270d00990daa9f0d6e7ac1107e25" +dependencies = [ + "clap", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.0" @@ -5745,6 +5889,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -6825,7 +6975,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.5", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2378848..259a9a3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,8 @@ tokio-stream = { version = "0.1", features = ["sync"] } socket2 = "0.5" fastrand = "2" tauri-plugin-dialog = "2" +tauri-plugin-cli = "2" +rustyline = "14" zip = { version = "2", default-features = false, features = ["deflate"] } futures = "0.3.31" regex = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 521f5dc..047c489 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,10 +7,12 @@ "core:default", "opener:default", "dialog:default", + "cli:default", "core:event:default", "core:event:allow-listen", "core:event:allow-emit", "allow-audit-logs", - "allow-mcp-folder-picker" + "allow-mcp-folder-picker", + "allow-cli-shim" ] } diff --git a/src-tauri/permissions/cli-shim.toml b/src-tauri/permissions/cli-shim.toml new file mode 100644 index 0000000..979d00c --- /dev/null +++ b/src-tauri/permissions/cli-shim.toml @@ -0,0 +1,4 @@ +[[permission]] +identifier = "allow-cli-shim" +description = "Install or remove the terminal `pengine-cli` launcher (script that runs the app binary with CLI-only mode)." +commands.allow = ["cli_shim_status", "cli_shim_install", "cli_shim_remove"] diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index bd55b9e..a07fd8d 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -1,6 +1,7 @@ use crate::infrastructure::audit_log; use crate::infrastructure::http_server; use crate::modules::bot::{commands, repository, service as bot_service}; +use crate::modules::cli::bootstrap as cli_bootstrap; use crate::modules::cron::{repository as cron_repository, scheduler as cron_scheduler}; use crate::modules::mcp::service as mcp_service; use crate::modules::ollama::cloud as ollama_cloud; @@ -9,6 +10,16 @@ use crate::shared::state::{AppState, ConnectionData}; use std::path::PathBuf; use tauri::Manager; +/// Main window is created here (not in `tauri.conf.json`) so CLI invocations +/// like bare `pengine` in CLI mode never instantiate a webview — only the GUI path runs this. +fn open_main_window(app: &tauri::App) -> tauri::Result<()> { + tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into())) + .title("pengine") + .inner_size(800.0, 600.0) + .build()?; + Ok(()) +} + fn store_path(app: &tauri::App) -> PathBuf { let base = app .path() @@ -22,9 +33,14 @@ pub fn run() { env_logger::init(); tauri::Builder::default() + .plugin(tauri_plugin_cli::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .setup(|app| { + // CLI mode short-circuits UI startup (`process::exit`) or returns early + // for a GUI child (`PENGINE_OPEN_GUI=1` from `pengine app`). Otherwise + // setup continues and `open_main_window` runs at the end. + cli_bootstrap::handle_cli_or_continue(app); let path = store_path(app); let (mcp_path, mcp_src) = mcp_service::resolve_mcp_config_path(&path); @@ -189,6 +205,8 @@ pub fn run() { let _ = ollama_cloud::list_cloud_models().await; }); + open_main_window(app)?; + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -199,6 +217,9 @@ pub fn run() { commands::audit_list_files, commands::audit_read_file, commands::audit_delete_file, + commands::cli_shim_status, + commands::cli_shim_install, + commands::cli_shim_remove, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/build_info.rs b/src-tauri/src/build_info.rs index 3720b96..8a56302 100644 --- a/src-tauri/src/build_info.rs +++ b/src-tauri/src/build_info.rs @@ -1,4 +1,2 @@ -//! Compile-time app version (root `package.json`, set in `build.rs`) and git commit. - pub const APP_VERSION: &str = env!("PENGINE_APP_VERSION"); pub const GIT_COMMIT: &str = env!("PENGINE_GIT_COMMIT"); diff --git a/src-tauri/src/infrastructure/http_server.rs b/src-tauri/src/infrastructure/http_server.rs index e1bcad4..991932a 100644 --- a/src-tauri/src/infrastructure/http_server.rs +++ b/src-tauri/src/infrastructure/http_server.rs @@ -1,7 +1,9 @@ use crate::build_info; use crate::infrastructure::audit_log; use crate::infrastructure::bot_lifecycle; -use crate::modules::bot::{agent as bot_agent, repository, service as bot_service}; +use crate::modules::agent as bot_agent; +use crate::modules::bot::{repository, service as bot_service}; +use crate::modules::cli::commands as cli_commands; use crate::modules::cron::{ repository as cron_repository, scheduler as cron_scheduler, service as cron_service, types::{CronFile, CronJob, Schedule}, @@ -121,6 +123,12 @@ pub struct PutUserSettingsBody { pub skills_hint_max_bytes: u32, } +#[derive(Serialize)] +pub struct CliCommandsResponse { + /// Same entries as `modules::cli::commands::COMMANDS` (native CLI surface). + pub commands: Vec, +} + pub async fn start_server(state: AppState) { let cors = CorsLayer::new() .allow_origin(Any) @@ -141,6 +149,7 @@ pub async fn start_server(state: AppState) { .route("/v1/ollama/model", put(handle_ollama_model_put)) .route("/v1/settings", get(handle_user_settings_get)) .route("/v1/settings", put(handle_user_settings_put)) + .route("/v1/cli/commands", get(handle_cli_commands_list)) .route("/v1/mcp/tools", get(handle_mcp_tools)) .route("/v1/mcp/config", get(handle_mcp_config_get)) .route("/v1/mcp/filesystem", put(handle_mcp_filesystem_put)) @@ -398,6 +407,12 @@ async fn handle_ollama_models(State(state): State) -> Json) -> Json { + Json(CliCommandsResponse { + commands: cli_commands::COMMANDS.to_vec(), + }) +} + async fn handle_user_settings_get(State(state): State) -> Json { let v = *state.skills_hint_max_bytes.read().await; Json(UserSettingsResponse { diff --git a/src-tauri/src/modules/bot/agent.rs b/src-tauri/src/modules/agent/mod.rs similarity index 99% rename from src-tauri/src/modules/bot/agent.rs rename to src-tauri/src/modules/agent/mod.rs index 90b715d..72b8bab 100644 --- a/src-tauri/src/modules/bot/agent.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -1,4 +1,5 @@ -use super::search_followup; +pub mod search_followup; + use crate::modules::memory::{self, MemoryProvider, SessionCommand}; use crate::modules::ollama::keywords::THINK_ON; use crate::modules::ollama::service::{self as ollama, ChatOptions}; diff --git a/src-tauri/src/modules/bot/search_followup.rs b/src-tauri/src/modules/agent/search_followup.rs similarity index 100% rename from src-tauri/src/modules/bot/search_followup.rs rename to src-tauri/src/modules/agent/search_followup.rs diff --git a/src-tauri/src/modules/bot/commands.rs b/src-tauri/src/modules/bot/commands.rs index 6b15d95..1750e80 100644 --- a/src-tauri/src/modules/bot/commands.rs +++ b/src-tauri/src/modules/bot/commands.rs @@ -1,6 +1,7 @@ use crate::infrastructure::audit_log; use crate::infrastructure::bot_lifecycle; use crate::modules::bot::repository; +use crate::modules::cli::shim as cli_shim; use crate::modules::keywords::all_keyword_groups; use crate::modules::secure_store; use crate::shared::keywords::KeywordGroup; @@ -112,3 +113,21 @@ pub async fn audit_delete_file( .await .map_err(audit_log::command_error_from_io) } + +/// Where the `pengine-cli` launcher would be written and whether `PATH` already includes it. +#[tauri::command] +pub fn cli_shim_status() -> Result { + cli_shim::status() +} + +/// Install `pengine-cli` launcher script (macOS/Linux) or `%LOCALAPPDATA%\\Pengine\\bin\\pengine-cli.cmd` (Windows) +/// pointing at this app’s executable. +#[tauri::command] +pub fn cli_shim_install() -> Result { + cli_shim::install_shim() +} + +#[tauri::command] +pub fn cli_shim_remove() -> Result<(), String> { + cli_shim::remove_shim() +} diff --git a/src-tauri/src/modules/bot/mod.rs b/src-tauri/src/modules/bot/mod.rs index e42a909..198cbdb 100644 --- a/src-tauri/src/modules/bot/mod.rs +++ b/src-tauri/src/modules/bot/mod.rs @@ -1,5 +1,4 @@ -pub mod agent; pub mod commands; pub mod repository; -pub mod search_followup; pub mod service; +pub mod token_verify; diff --git a/src-tauri/src/modules/bot/service.rs b/src-tauri/src/modules/bot/service.rs index 92c6ba8..78ae2fc 100644 --- a/src-tauri/src/modules/bot/service.rs +++ b/src-tauri/src/modules/bot/service.rs @@ -1,13 +1,16 @@ -use crate::modules::bot::agent; +use crate::modules::agent; +use crate::modules::cli::telegram_bridge; use crate::modules::cron::{repository as cron_repository, types::CronFile}; use crate::shared::state::AppState; use crate::shared::text::split_by_chars; use std::sync::Arc; use teloxide::prelude::*; -use teloxide::types::{ChatAction, ChatId, Me}; +use teloxide::types::{ChatAction, ChatId}; use teloxide::utils::command::BotCommands; use tokio::sync::Notify; +pub use super::token_verify::verify_token; + /// Telegram's per-message hard limit is 4096 **UTF-16 code units**, not Unicode /// scalars: one emoji outside the BMP counts as 2 code units. `split_by_chars` /// counts Rust `char`s, so we halve the budget to stay safe even for messages @@ -15,13 +18,6 @@ use tokio::sync::Notify; /// leaves headroom under the 4096 limit. const TELEGRAM_CHUNK_BUDGET: usize = 2000; -pub async fn verify_token(token: &str) -> Result { - let bot = Bot::new(token); - bot.get_me() - .await - .map_err(|e| format!("Telegram getMe failed: {e}")) -} - pub async fn start_bot(state: AppState, token: String, shutdown: Arc) { let bot = Bot::new(&token); let state_clone = state.clone(); @@ -128,6 +124,23 @@ async fn text_handler(bot: Bot, msg: Message, state: AppState) -> ResponseResult }) }; + if let Some(rest) = telegram_bridge::strip_dollar_cli_payload(&incoming) { + let reply = telegram_bridge::run_telegram_cli_line(&state, rest).await; + typing_task.abort(); + let body_preview = match &reply.kind { + crate::modules::cli::output::ReplyKind::Error => format!("error: {}", reply.body), + _ => reply.body.clone(), + }; + state + .emit_log( + "reply", + &format!("[cli:$] {}", body_preview.lines().next().unwrap_or("")), + ) + .await; + send_telegram_cli_reply(&bot, msg.chat.id, &reply, &state).await; + return Ok(()); + } + let result = agent::run_turn(&state, &incoming).await; typing_task.abort(); @@ -186,6 +199,27 @@ async fn remember_chat_id(state: &AppState, chat_id: ChatId) { /// Send `text` to `chat_id`, splitting into Telegram-sized chunks if needed. /// Send failures are logged (not propagated) so one bad chunk doesn't abort /// the handler and leave the user with no reply at all. +async fn send_telegram_cli_reply( + bot: &Bot, + chat_id: ChatId, + reply: &crate::modules::cli::output::CliReply, + state: &AppState, +) { + let chunks = telegram_bridge::telegram_cli_reply_chunks(reply); + let total = chunks.len(); + for (i, chunk) in chunks.iter().enumerate() { + if let Err(e) = bot.send_message(chat_id, chunk).await { + state + .emit_log( + "run", + &format!("telegram cli send failed (chunk {}/{}): {e}", i + 1, total), + ) + .await; + return; + } + } +} + async fn send_reply(bot: &Bot, chat_id: ChatId, text: &str, state: &AppState) { let chunks = split_by_chars(text, TELEGRAM_CHUNK_BUDGET); let total = chunks.len(); diff --git a/src-tauri/src/modules/bot/token_verify.rs b/src-tauri/src/modules/bot/token_verify.rs new file mode 100644 index 0000000..b83f7a6 --- /dev/null +++ b/src-tauri/src/modules/bot/token_verify.rs @@ -0,0 +1,13 @@ +//! Minimal Telegram API calls used before the dispatcher is running. +//! Lives outside [`super::service`] so `modules::cli::handlers` can verify +//! tokens without creating a `bot -> cli -> handlers -> bot` dependency cycle. + +use teloxide::prelude::*; +use teloxide::types::Me; + +pub async fn verify_token(token: &str) -> Result { + let bot = Bot::new(token); + bot.get_me() + .await + .map_err(|e| format!("Telegram getMe failed: {e}")) +} diff --git a/src-tauri/src/modules/cli/banner.rs b/src-tauri/src/modules/cli/banner.rs new file mode 100644 index 0000000..3297dfb --- /dev/null +++ b/src-tauri/src/modules/cli/banner.rs @@ -0,0 +1,39 @@ +//! Terminal branding — ASCII welcome shown when entering the interactive CLI. + +/// Shown above the REPL prompt (bare `pengine` in a TTY). +pub const CLI_WELCOME: &str = concat!( + r" + :; ;; + ;;;;; ; + ;;;;;;;; + ;;;;;;;;,;;;; + ;•◘◘◘○◘◘t;○◘;;;; + ;I;;;;;::;;;;▒:;;; + ;;;;;;;;;;;;;◘◘;;; + ;◘;▓▓▓▓▓,;◘◘◘◘•;;; + ~,◘◘;;:;○◘◘◘◘◘;;; + ;;;•◘◘◘○◘◘◘;;;;;;;. + ;;I;◘◘◘;;♣◘◘◘◘:;iI;;;; + ;;;::•;◘;;◘.◘;○;I:;;;;;; + ;;;;II;◘◘;;◘◘○I;II;;;;;;;; + ;;;;;I;◘;;;◘◘;II;;;;;l;;;; + ;;;;;;;;;;;:I;;;;;;;;I;;;; + ;;W;;;;;;;;;;;;;;;;;;;W,; + WWWWWW▓;;WW;;WW:▓WWWWW~ + %;;W,,W:WWMWW;;WW;;& ; + ;; ; ;W;;! ;W;; + ;W; ; +", + "\n\nPengine CLI — type /help for slash commands.\n", +); + +#[cfg(test)] +mod tests { + use super::CLI_WELCOME; + + #[test] + fn welcome_has_brand_and_hints() { + assert!(CLI_WELCOME.contains("Pengine CLI")); + assert!(CLI_WELCOME.contains("/help")); + } +} diff --git a/src-tauri/src/modules/cli/bootstrap.rs b/src-tauri/src/modules/cli/bootstrap.rs new file mode 100644 index 0000000..43d9b55 --- /dev/null +++ b/src-tauri/src/modules/cli/bootstrap.rs @@ -0,0 +1,552 @@ +//! CLI entry branch — reads `tauri-plugin-cli` matches and dispatches. +//! +//! Invoked from `app::run` before any window is created. If no CLI subcommand +//! is present, returns and setup continues into the normal UI path. +//! Otherwise runs the handler, prints to the chosen sink, and exits the +//! process. No Tauri event loop is needed for one-shot commands. +//! +//! **Bare `pengine`** (no subcommand): in a real terminal (**TTY**), starts the +//! interactive REPL only — never the GUI in that process. Without a TTY the +//! launch is treated as a GUI launch (Finder / Dock / `.desktop` file / +//! Windows Start menu / `open -a pengine`) and setup continues into +//! [`crate::app::open_main_window`]. +//! +//! **`pengine app`** spawns a **separate** GUI process (`PENGINE_OPEN_GUI=1`) so +//! the shell and the desktop can run in parallel. +//! +//! **`PENGINE_LAUNCH_MODE=cli`** (e.g. `pengine-cli` launcher) or **`--shell`**: +//! never opens the GUI in-process. With no subcommand, a TTY is required for +//! the REPL; without a TTY the process exits with an error instead. + +use super::output::{CliReply, JsonSink, OutputSink, TerminalSink}; +use super::{commands, handlers}; +use crate::infrastructure::audit_log; +use crate::modules::bot::repository as bot_repository; +use crate::modules::mcp::service as mcp_service; +use crate::modules::secure_store; +use crate::shared::state::{AppState, ConnectionData}; +use serde_json::Value; +use std::collections::HashMap; +use std::io::IsTerminal; +use tauri::Manager; +use tauri_plugin_cli::{ArgData, CliExt, Matches}; + +/// Entry — call from Tauri `setup`. Returns in UI mode; in CLI mode the +/// handler runs and [`std::process::exit`] is called. +/// +/// Three paths, in priority order: +/// 1. `--help` / auto-`help` subcommand — tauri-plugin-cli surfaces clap's +/// generated text in `matches.args["help"]` (see its `parser.rs`). +/// 2. `--version` — surfaces an empty `matches.args["version"]`. +/// 3. A registered subcommand — dispatch via [`run_subcommand`]. +/// +/// Otherwise (bare `pengine`): **TTY** → REPL then exit; **not a TTY** → GUI +/// (all platforms; covers Finder / Dock / `.desktop` / Start-menu launches). +/// The `pengine-cli` launcher sets `PENGINE_LAUNCH_MODE=cli` (or `--shell`) +/// so non-TTY never falls through to the GUI there. +pub fn handle_cli_or_continue(app: &tauri::App) { + if consume_gui_spawn_env() { + return; + } + + // Tauri defaults to `Regular` (foreground app with Dock icon). For CLI + // invocations we don't want a Dock entry at all — make the process an + // "accessory" up front. If we later decide this is a GUI launch (bare + // `pengine` with no TTY), `switch_to_gui_activation_policy` flips it back + // to `Regular` before we fall through to `setup`. + set_macos_activation_policy(app, tauri::ActivationPolicy::Accessory); + + let matches = match app.cli().matches() { + Ok(m) => m, + Err(e) => { + eprintln!("cli parse error: {e}"); + std::process::exit(2); + } + }; + + let json = flag_true(&matches.args, "json"); + let sink: Box = if json { + Box::new(JsonSink) + } else { + Box::new(TerminalSink::new()) + }; + + if let Some(arg) = matches.args.get("help") { + if matches!(arg.value, Value::String(_)) { + sink.render(&handlers::help()); + std::process::exit(0); + } + } + if matches.args.contains_key("version") { + sink.render(&handlers::version()); + std::process::exit(0); + } + if matches.subcommand.is_none() { + match argv_intent() { + ArgvIntent::None => { + let tty = std::io::stdin().is_terminal(); + let force_terminal_only = + force_cli_launch_mode() || flag_true(&matches.args, "shell"); + if force_terminal_only && !tty { + eprintln!( + "A terminal (TTY) is required for the interactive shell \ + (`pengine --shell`, `pengine-cli`, or `PENGINE_LAUNCH_MODE=cli`)." + ); + eprintln!( + "For one-shot use without a TTY, run e.g. `pengine status` or `pengine ask \"…\"`." + ); + std::process::exit(1); + } + if !tty { + // Double-click from Finder / Dock / `.desktop` file / + // Windows Start menu / `open -a pengine` all land here: + // no CLI subcommand, no `-psn_` guarantee across platforms, + // no TTY. Treat it as a GUI launch — flip the activation + // policy back to `Regular` so the Dock icon appears, and + // return so `setup` continues into `open_main_window`. + set_macos_activation_policy(app, tauri::ActivationPolicy::Regular); + return; + } + let sink = TerminalSink::new(); + let state = match build_state(app) { + Ok(s) => s, + Err(e) => { + sink.render(&CliReply::error(format!("state: {e}"))); + std::process::exit(1); + } + }; + let reply = tauri::async_runtime::block_on(super::repl::run(&state)); + sink.render(&reply); + std::process::exit(0); + } + ArgvIntent::Help => { + sink.render(&handlers::help()); + std::process::exit(0); + } + ArgvIntent::Version => { + sink.render(&handlers::version()); + std::process::exit(0); + } + ArgvIntent::CommandLike => { + let shown = std::env::args().skip(1).collect::>().join(" "); + sink.render(&CliReply::error(format!( + "cli invocation detected (`{shown}`) but no subcommand was parsed; \ + try `pengine help`" + ))); + std::process::exit(2); + } + } + } + + let code = run_subcommand(app, matches, sink.as_ref()); + std::process::exit(code); +} + +fn run_subcommand(app: &tauri::App, matches: Matches, sink: &dyn OutputSink) -> i32 { + let sub = matches + .subcommand + .expect("checked in handle_cli_or_continue"); + let name = sub.name.as_str(); + let sub_args = &sub.matches.args; + let sub_inner = sub.matches.subcommand.as_deref(); + + // Zero-state commands run without constructing AppState. + match name { + "help" => { + sink.render(&handlers::help()); + return 0; + } + "version" => { + sink.render(&handlers::version()); + return 0; + } + "app" => match spawn_gui_app_process() { + Ok(()) => { + sink.render(&CliReply::text( + "Started the Pengine desktop window in a separate process. \ + This terminal is free; run `pengine` here or in another tab for the shell — both can run at once.", + )); + return 0; + } + Err(e) => { + sink.render(&CliReply::error(e)); + return 1; + } + }, + _ => {} + } + + // Stateful commands: build a minimal AppState. + let state = match build_state(app) { + Ok(s) => s, + Err(e) => { + sink.render(&CliReply::error(format!("state: {e}"))); + return 1; + } + }; + + let audit_line = cli_subcommand_audit_summary(name, sub_args, sub_inner); + tauri::async_runtime::block_on(state.emit_log("cli", &audit_line)); + + let reply = + tauri::async_runtime::block_on(dispatch_stateful(name, sub_args, sub_inner, &state)); + let is_error = matches!(reply.kind, crate::modules::cli::output::ReplyKind::Error); + sink.render(&reply); + if is_error { + 1 + } else { + 0 + } +} + +async fn dispatch_stateful( + name: &str, + args: &HashMap, + sub: Option<&tauri_plugin_cli::SubcommandMatches>, + state: &AppState, +) -> CliReply { + match name { + "status" => handlers::status(state).await, + "config" => { + let kvs = multi_string(args, "kv"); + handlers::config(state, &kvs).await + } + "model" => { + let name = single_string(args, "name"); + let clear = flag_true(args, "clear"); + handlers::model(state, name.as_deref(), clear).await + } + "bot" => { + let Some(inner) = sub else { + return CliReply::error("bot: expected `connect ` or `disconnect`"); + }; + match inner.name.as_str() { + "connect" => { + let token = single_string(&inner.matches.args, "token").unwrap_or_default(); + handlers::bot_connect(state, &token).await + } + "disconnect" => handlers::bot_disconnect(state).await, + other => CliReply::error(format!("bot: unknown subcommand `{other}`")), + } + } + "tools" => { + // Warm MCP so the list is meaningful. + if let Err(e) = mcp_service::rebuild_registry_into_state(state).await { + return CliReply::error(format!("mcp warmup failed: {e}")); + } + let search = single_string(args, "search"); + handlers::tools(state, search.as_deref()).await + } + "skills" => { + let action = single_string(args, "action"); + let slug = single_string(args, "slug"); + handlers::skills(state, action.as_deref(), slug.as_deref()).await + } + "fs" => { + let action = single_string(args, "action"); + let path = single_string(args, "path"); + handlers::fs(state, action.as_deref(), path.as_deref()).await + } + "logs" => { + let follow = flag_true(args, "follow"); + let tail = single_string(args, "tail").and_then(|s| s.parse::().ok()); + handlers::logs(state, tail, follow).await + } + "ask" => { + let text = single_string(args, "text").unwrap_or_default(); + if let Err(e) = mcp_service::rebuild_registry_into_state(state).await { + return CliReply::error(format!("mcp warmup failed: {e}")); + } + handlers::ask(state, &text).await + } + other => CliReply::error(format!("unknown subcommand `{other}`")), + } +} + +fn build_state(app: &tauri::App) -> Result { + let base = app + .path() + .app_data_dir() + .map_err(|e| format!("app_data_dir: {e}"))?; + let store_path = base.join("connection.json"); + let (mcp_path, mcp_src) = mcp_service::resolve_mcp_config_path(&store_path); + let (state, audit_rx) = AppState::new(store_path, mcp_path, mcp_src.to_string()); + hydrate_connection_from_disk(&state); + let audit_store = state.store_path.clone(); + tauri::async_runtime::spawn(async move { + audit_log::run_audit_writer(audit_store, audit_rx).await; + }); + Ok(state) +} + +fn truncate_audit_str(s: &str, max_chars: usize) -> String { + let count = s.chars().count(); + if count <= max_chars { + return s.to_string(); + } + let head: String = s.chars().take(max_chars).collect(); + format!("{head}… ({count} chars)") +} + +/// One-line summary for NDJSON audit (no secrets). +fn cli_subcommand_audit_summary( + name: &str, + args: &HashMap, + sub: Option<&tauri_plugin_cli::SubcommandMatches>, +) -> String { + use std::fmt::Write; + let mut out = String::from("pengine "); + out.push_str(name); + match name { + "status" | "app" => {} + "config" => { + let kvs = multi_string(args, "kv"); + if !kvs.is_empty() { + let _ = write!(out, " {}", truncate_audit_str(&kvs.join(" "), 400)); + } + } + "model" => { + if let Some(n) = single_string(args, "name") { + let _ = write!(out, " {}", truncate_audit_str(&n, 120)); + } + if flag_true(args, "clear") { + out.push_str(" --clear"); + } + } + "bot" => { + if let Some(inner) = sub { + match inner.name.as_str() { + "connect" => out.push_str(" connect "), + "disconnect" => out.push_str(" disconnect"), + other => { + let _ = write!(out, " {other}"); + } + } + } + } + "tools" => { + if let Some(q) = single_string(args, "search") { + let _ = write!(out, " {}", truncate_audit_str(&q, 200)); + } + } + "skills" => { + if let Some(a) = single_string(args, "action") { + let _ = write!(out, " {}", truncate_audit_str(&a, 64)); + } + if let Some(sl) = single_string(args, "slug") { + let _ = write!(out, " {}", truncate_audit_str(&sl, 120)); + } + } + "fs" => { + if let Some(a) = single_string(args, "action") { + let _ = write!(out, " {}", truncate_audit_str(&a, 32)); + } + if let Some(p) = single_string(args, "path") { + let _ = write!(out, " {}", truncate_audit_str(&p, 400)); + } + } + "logs" => { + if flag_true(args, "follow") { + out.push_str(" --follow"); + } + if let Some(t) = single_string(args, "tail").and_then(|x| x.parse::().ok()) { + let _ = write!(out, " --tail {t}"); + } + } + "ask" => { + let text = single_string(args, "text").unwrap_or_default(); + if !text.is_empty() { + let _ = write!(out, " {}", truncate_audit_str(&text, 800)); + } + } + _ => out.push_str(" …"), + } + out +} + +/// Best-effort hydration for one-shot CLI mode: +/// - status should reflect persisted bot metadata +/// - disconnect should have bot_id available for keychain cleanup +/// +/// If keychain unlock fails, we still carry bot_id/bot_username with an empty +/// token so metadata-aware commands keep behaving deterministically. +fn hydrate_connection_from_disk(state: &AppState) { + let mut migration_log: Vec = Vec::new(); + let Some(meta) = bot_repository::load(&state.store_path, &mut migration_log) else { + return; + }; + let token = secure_store::load_token(&meta.bot_id).unwrap_or_default(); + tauri::async_runtime::block_on(async { + *state.connection.lock().await = Some(ConnectionData { + bot_token: token, + bot_id: meta.bot_id, + bot_username: meta.bot_username, + connected_at: meta.connected_at, + }); + }); +} + +fn flag_true(args: &HashMap, name: &str) -> bool { + matches!(args.get(name).map(|a| &a.value), Some(Value::Bool(true))) +} + +fn single_string(args: &HashMap, name: &str) -> Option { + match args.get(name)?.value { + Value::String(ref s) => Some(s.clone()), + _ => None, + } +} + +fn multi_string(args: &HashMap, name: &str) -> Vec { + let Some(arg) = args.get(name) else { + return Vec::new(); + }; + match &arg.value { + Value::String(s) => vec![s.clone()], + Value::Array(arr) => arr + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(), + _ => Vec::new(), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ArgvIntent { + None, + Help, + Version, + CommandLike, +} + +fn force_cli_launch_mode() -> bool { + std::env::var("PENGINE_LAUNCH_MODE") + .map(|v| v == "cli") + .unwrap_or(false) +} + +fn argv_intent() -> ArgvIntent { + argv_intent_from(std::env::args().skip(1)) +} + +fn argv_intent_from(args: I) -> ArgvIntent +where + I: IntoIterator, + S: AsRef, +{ + let Some(first) = args + .into_iter() + .map(|a| a.as_ref().trim().to_string()) + .find(|a| !a.is_empty() && !is_ignored_os_arg(a)) + else { + return ArgvIntent::None; + }; + + match first.as_str() { + "--help" | "-h" | "help" => ArgvIntent::Help, + "--version" | "-V" | "version" => ArgvIntent::Version, + "--json" | "--no-terminal" | "--no-telegram" => ArgvIntent::CommandLike, + other if !other.starts_with('-') && commands::lookup(other).is_some() => { + ArgvIntent::CommandLike + } + _ => ArgvIntent::None, + } +} + +fn is_ignored_os_arg(arg: &str) -> bool { + #[cfg(target_os = "macos")] + { + // Finder launches GUI apps with this synthetic process serial number arg. + arg.starts_with("-psn_") + } + #[cfg(not(target_os = "macos"))] + { + let _ = arg; + false + } +} + +/// On macOS, set NSApp's activation policy. No-op on other platforms. +/// +/// `Accessory` removes the process from the Dock / Cmd-Tab; perfect for CLI +/// invocations that don't show a window. `Regular` restores the normal +/// foreground-app behavior (Dock icon + menu bar), used when bare `pengine` +/// turns out to be a GUI launch after all. +fn set_macos_activation_policy(app: &tauri::App, policy: tauri::ActivationPolicy) { + #[cfg(target_os = "macos")] + { + if let Err(e) = app.handle().set_activation_policy(policy) { + log::warn!("set_activation_policy failed: {e}"); + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = (app, policy); + } +} + +/// Second process spawned by `pengine app`; strip markers then continue into full Tauri setup. +fn consume_gui_spawn_env() -> bool { + if std::env::var("PENGINE_OPEN_GUI") + .map(|v| v == "1") + .unwrap_or(false) + { + std::env::remove_var("PENGINE_OPEN_GUI"); + std::env::remove_var("PENGINE_LAUNCH_MODE"); + return true; + } + false +} + +/// Start the desktop app in a **new** process so the current terminal can keep a REPL (or exit). +pub(super) fn spawn_gui_app_process() -> Result<(), String> { + use std::process::{Command, Stdio}; + let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?; + let mut cmd = Command::new(exe); + cmd.env("PENGINE_OPEN_GUI", "1"); + cmd.env_remove("PENGINE_LAUNCH_MODE"); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + cmd.spawn() + .map_err(|e| format!("could not start GUI process: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn argv_intent_none_for_empty_args() { + let v: Vec<&str> = Vec::new(); + assert_eq!(argv_intent_from(v), ArgvIntent::None); + } + + #[test] + fn argv_intent_ignores_macos_psn_arg() { + assert_eq!(argv_intent_from(vec!["-psn_0_12345"]), ArgvIntent::None); + } + + #[test] + fn argv_intent_detects_help_and_version() { + assert_eq!(argv_intent_from(vec!["--help"]), ArgvIntent::Help); + assert_eq!(argv_intent_from(vec!["version"]), ArgvIntent::Version); + } + + #[test] + fn argv_intent_detects_known_command() { + assert_eq!(argv_intent_from(vec!["status"]), ArgvIntent::CommandLike); + assert_eq!(argv_intent_from(vec!["app"]), ArgvIntent::CommandLike); + } + + #[test] + fn argv_intent_detects_global_cli_flags() { + assert_eq!(argv_intent_from(vec!["--json"]), ArgvIntent::CommandLike); + } + + #[test] + fn argv_intent_none_for_shell_flag_alone() { + assert_eq!(argv_intent_from(vec!["--shell"]), ArgvIntent::None); + } +} diff --git a/src-tauri/src/modules/cli/commands.rs b/src-tauri/src/modules/cli/commands.rs new file mode 100644 index 0000000..211f6ed --- /dev/null +++ b/src-tauri/src/modules/cli/commands.rs @@ -0,0 +1,71 @@ +//! Native command registry — single source of truth for the CLI surface. +//! +//! The registry drives `pengine help` and `GET /v1/cli/commands`. Adding a command +//! is one entry here + one handler function + (for subcommand dispatch) one arm +//! in [`super::bootstrap`]. + +use serde::Serialize; + +/// Metadata for a native (CLI-only) command. +#[derive(Debug, Clone, Copy, Serialize)] +pub struct NativeCommand { + pub name: &'static str, + pub summary: &'static str, +} + +/// Canonical registry. Order is the order `help` prints them. +pub const COMMANDS: &[NativeCommand] = &[ + NativeCommand { + name: "help", + summary: "Show this help.", + }, + NativeCommand { + name: "app", + summary: + "Open the desktop window (new process; run alongside a terminal `pengine` session).", + }, + NativeCommand { + name: "version", + summary: "Print the Pengine version and git commit.", + }, + NativeCommand { + name: "status", + summary: "Show bot, Ollama, and MCP status.", + }, + NativeCommand { + name: "config", + summary: "Show or set user settings (e.g. skills_hint_max_bytes=12000).", + }, + NativeCommand { + name: "model", + summary: "Show or set the preferred Ollama model.", + }, + NativeCommand { + name: "bot", + summary: "Connect or disconnect the Telegram bot.", + }, + NativeCommand { + name: "tools", + summary: "List MCP tools (optional search substring).", + }, + NativeCommand { + name: "skills", + summary: "List, enable, or disable skills.", + }, + NativeCommand { + name: "fs", + summary: "List, add, or remove MCP filesystem roots.", + }, + NativeCommand { + name: "logs", + summary: "Stream log events (--follow / --tail).", + }, + NativeCommand { + name: "ask", + summary: "Send a message to the agent (AI path).", + }, +]; + +pub fn lookup(name: &str) -> Option<&'static NativeCommand> { + COMMANDS.iter().find(|c| c.name == name) +} diff --git a/src-tauri/src/modules/cli/dispatch.rs b/src-tauri/src/modules/cli/dispatch.rs new file mode 100644 index 0000000..e1278b4 --- /dev/null +++ b/src-tauri/src/modules/cli/dispatch.rs @@ -0,0 +1,202 @@ +//! Shared argv/REPL/Telegram `$` dispatch — one implementation for router outcomes. +//! +//! Kept separate from [`super::repl`] so [`super::telegram_bridge`] can call it +//! without pulling in `rustyline`. + +use super::handlers; +use super::output::CliReply; +use super::router::{self, RouterOutcome}; +use crate::shared::state::AppState; + +/// Where the line is being executed (affects safety rails). +#[derive(Clone, Copy, Default)] +pub struct DispatchContext { + /// When true, disallow blocking operations that would stall the Telegram bot. + pub telegram_surface: bool, +} + +impl DispatchContext { + pub fn telegram() -> Self { + Self { + telegram_surface: true, + } + } +} + +/// Redact secrets and cap length for `AppState::emit_log` / audit NDJSON (terminal REPL). +pub(crate) fn format_repl_line_for_audit(line: &str) -> String { + let t = line.trim(); + if t.is_empty() { + return String::new(); + } + fn starts_with_ci(hay: &str, needle: &str) -> bool { + let h = hay.len(); + let n = needle.len(); + h >= n && hay[..n].eq_ignore_ascii_case(needle) + } + if starts_with_ci(t, "/bot connect ") { + return "/bot connect ".to_string(); + } + if starts_with_ci(t, "bot connect ") { + return "bot connect ".to_string(); + } + const MAX: usize = 2048; + let n = t.chars().count(); + if n <= MAX { + return t.to_string(); + } + let head: String = t.chars().take(MAX).collect(); + format!("{head}… ({n} chars)") +} + +pub async fn dispatch_line(state: &AppState, line: &str, ctx: DispatchContext) -> CliReply { + match router::classify_line(line) { + RouterOutcome::Unknown(name) => { + CliReply::error(format!("unknown command: /{name} (try /help)",)) + } + RouterOutcome::Agent(text) => handlers::ask(state, text).await, + RouterOutcome::Native { name, rest } => dispatch_native(state, name, rest, ctx).await, + } +} + +async fn dispatch_native( + state: &AppState, + name: &str, + rest: &str, + ctx: DispatchContext, +) -> CliReply { + match name { + "help" => handlers::help(), + "version" => handlers::version(), + "status" => handlers::status(state).await, + "config" => { + let kvs: Vec = rest.split_whitespace().map(str::to_string).collect(); + handlers::config(state, &kvs).await + } + "model" => { + let (first, _) = split_first(rest); + if first == "--clear" || first == "clear" { + handlers::model(state, None, true).await + } else { + handlers::model(state, (!first.is_empty()).then_some(first), false).await + } + } + "bot" => { + let (action, tail) = split_first(rest); + match action { + "connect" => handlers::bot_connect(state, tail.trim()).await, + "disconnect" => handlers::bot_disconnect(state).await, + "" => CliReply::error("bot: expected `connect ` or `disconnect`"), + other => CliReply::error(format!("bot: unknown action `{other}`")), + } + } + "tools" => { + let trimmed = rest.trim(); + let search = (!trimmed.is_empty()).then_some(trimmed); + handlers::tools(state, search).await + } + "skills" => { + let (action, tail) = split_first(rest); + let slug_tok = tail.trim(); + let slug = (!slug_tok.is_empty()).then_some(slug_tok); + let action = (!action.is_empty()).then_some(action); + handlers::skills(state, action, slug).await + } + "fs" => { + let (action, tail) = split_first(rest); + let path_tok = tail.trim(); + let path = (!path_tok.is_empty()).then_some(path_tok); + let action = (!action.is_empty()).then_some(action); + handlers::fs(state, action, path).await + } + "logs" => { + let mut follow = false; + let mut tail: Option = None; + let mut toks = rest.split_whitespace().peekable(); + while let Some(t) = toks.next() { + match t { + "--follow" | "-f" => follow = true, + "--tail" => tail = toks.next().and_then(|s| s.parse().ok()), + _ => {} + } + } + if ctx.telegram_surface && follow { + return CliReply::error( + "logs --follow is not supported over the Telegram `$` bridge (it would block the bot).", + ); + } + handlers::logs(state, tail, follow).await + } + "ask" => handlers::ask(state, rest).await, + "app" => { + if ctx.telegram_surface { + return CliReply::error("app: starting the GUI is not supported over Telegram."); + } + match super::bootstrap::spawn_gui_app_process() { + Ok(()) => CliReply::text( + "Started the Pengine desktop window in a separate process. You can keep this REPL open.", + ), + Err(e) => CliReply::error(e), + } + }, + "exit" | "quit" => CliReply::text("bye."), + other => CliReply::error(format!("unknown command: /{other}")), + } +} + +fn split_first(s: &str) -> (&str, &str) { + let s = s.trim_start(); + match s.find(char::is_whitespace) { + Some(i) => (&s[..i], s[i..].trim_start()), + None => (s, ""), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shared::state::AppState; + use std::path::PathBuf; + + #[test] + fn repl_audit_redacts_bot_connect() { + assert_eq!( + format_repl_line_for_audit(" /BOT CONNECT secret-token-here "), + "/bot connect " + ); + assert_eq!( + format_repl_line_for_audit("bot connect abc"), + "bot connect " + ); + } + + #[test] + fn repl_audit_truncates_long_input() { + let s = "x".repeat(3000); + let out = format_repl_line_for_audit(&s); + assert!(out.contains('…')); + assert!(out.contains("3000 chars")); + assert!(out.len() < 3200); + } + + fn minimal_state() -> AppState { + let store = PathBuf::from("/nonexistent/pengine-test/connection.json"); + let (state, _rx) = AppState::new( + store.clone(), + store.with_file_name("mcp.json"), + "default".into(), + ); + state + } + + #[tokio::test] + async fn telegram_context_rejects_logs_follow() { + let state = minimal_state(); + let reply = dispatch_line(&state, "/logs --follow", DispatchContext::telegram()).await; + assert!(matches!( + reply.kind, + crate::modules::cli::output::ReplyKind::Error + )); + assert!(reply.body.contains("logs --follow")); + } +} diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs new file mode 100644 index 0000000..4400d79 --- /dev/null +++ b/src-tauri/src/modules/cli/handlers.rs @@ -0,0 +1,660 @@ +//! PR 1 handlers — one function per native command. +//! +//! Rules: +//! - Each handler returns a [`CliReply`]; sinks render it. +//! - Handlers reuse existing module services (bot, mcp, ollama, skills, +//! user_settings). No duplicated business logic. + +use super::commands::{self, NativeCommand}; +use super::output::{fmt_elapsed, CliReply, Progress, ProgressStatus}; +use crate::build_info; +use crate::infrastructure::audit_log; +use crate::infrastructure::bot_lifecycle; +use crate::modules::agent; +use crate::modules::bot::{repository as bot_repo, token_verify}; +use crate::modules::mcp::service as mcp_service; +use crate::modules::ollama::service as ollama; +use crate::modules::secure_store; +use crate::modules::skills::service as skills_service; +use crate::shared::state::{AppState, ConnectionData, ConnectionMetadata, LogEntry}; +use crate::shared::user_settings; +use chrono::Utc; +use serde::Deserialize; +use std::io::{IsTerminal, Write}; + +pub fn help() -> CliReply { + let mut out = String::from( + "Pengine CLI\n\nUsage:\n pengine interactive shell in a terminal (TTY only); never starts the GUI in that process\n pengine app open the desktop window in a **separate** process (can run together with a shell)\n pengine one-shot command, then exit (e.g. status, ask, …)\n\nCommands:\n", + ); + let width = commands::COMMANDS + .iter() + .map(|c: &NativeCommand| c.name.len()) + .max() + .unwrap_or(0); + for c in commands::COMMANDS { + out.push_str(&format!( + " {: CliReply { + CliReply::text(format!( + "pengine {} ({})", + build_info::APP_VERSION, + build_info::GIT_COMMIT, + )) +} + +pub async fn status(state: &AppState) -> CliReply { + let bot_line = { + let conn = state.connection.lock().await; + match conn.as_ref() { + Some(c) => format!("bot: connected as @{}", c.bot_username), + None => "bot: not connected".to_string(), + } + }; + + let active = ollama::active_model() + .await + .unwrap_or_else(|e| format!("")); + let preferred = state + .preferred_ollama_model + .read() + .await + .clone() + .unwrap_or_else(|| "".to_string()); + + let mcp_tools = state.mcp.read().await.tool_names().len(); + let skills_cap = *state.skills_hint_max_bytes.read().await; + + let body = format!( + "{bot_line}\n\ + ollama: active={active} preferred={preferred}\n\ + mcp: {mcp_tools} tool(s) connected\n\ + settings: skills_hint_max_bytes={skills_cap}\n\ + store: {}", + state.store_path.display(), + ); + CliReply::code("bash", body) +} + +/// `config` with no args: dump settings. With `key=value`: set (clamped). +pub async fn config(state: &AppState, kvs: &[String]) -> CliReply { + if kvs.is_empty() { + let v = *state.skills_hint_max_bytes.read().await; + return CliReply::code( + "bash", + format!( + "skills_hint_max_bytes={v} (min={}, max={}, default={})", + user_settings::MIN_SKILLS_HINT_MAX_BYTES, + user_settings::MAX_SKILLS_HINT_MAX_BYTES, + user_settings::DEFAULT_SKILLS_HINT_MAX_BYTES, + ), + ); + } + + let mut applied: Vec = Vec::new(); + for kv in kvs { + let Some((key, value)) = kv.split_once('=') else { + return CliReply::error(format!("invalid form `{kv}`; expected `key=value`")); + }; + let key = key.trim(); + let value = value.trim(); + match key { + "skills_hint_max_bytes" => match value.parse::() { + Ok(n) => match user_settings::save_skills_hint_max_bytes(&state.store_path, n) { + Ok(clamped) => { + let mut w = state.skills_hint_max_bytes.write().await; + *w = clamped; + applied.push(format!("{key}={clamped}")); + } + Err(e) => { + return CliReply::error(format!("save failed: {e}")); + } + }, + Err(_) => { + return CliReply::error(format!("{key}: expected u32, got `{value}`")); + } + }, + other => { + return CliReply::error(format!( + "unknown setting `{other}`. Known: skills_hint_max_bytes", + )); + } + } + } + CliReply::code("bash", format!("updated: {}", applied.join(", "))) +} + +/// `model` — show (no args) or set the preferred Ollama model. +/// Mirrors the validation in `handle_ollama_model_put` in `http_server.rs`. +pub async fn model(state: &AppState, name: Option<&str>, clear: bool) -> CliReply { + if clear { + *state.preferred_ollama_model.write().await = None; + return CliReply::code("bash", "preferred model cleared (uses active model)"); + } + let Some(name) = name else { + let preferred = state + .preferred_ollama_model + .read() + .await + .clone() + .unwrap_or_else(|| "".to_string()); + let active = ollama::active_model() + .await + .unwrap_or_else(|e| format!("")); + return CliReply::code("bash", format!("preferred={preferred}\nactive={active}")); + }; + let name = name.trim(); + if name.is_empty() { + return CliReply::error("model: name is empty (use --clear to unset)"); + } + let catalog = match ollama::model_catalog(3000).await { + Ok(c) => c, + Err(e) => return CliReply::error(format!("ollama catalog: {e}")), + }; + let Some(entry) = catalog.models.iter().find(|m| m.name == name) else { + return CliReply::error(format!("model `{name}` is not available in Ollama")); + }; + *state.preferred_ollama_model.write().await = Some(name.to_string()); + if entry.kind == ollama::ModelKind::Local { + *state.last_local_model.write().await = Some(name.to_string()); + } + state + .emit_log("run", &format!("ollama model set to '{name}' (cli)")) + .await; + CliReply::code("bash", format!("preferred model set to {name}")) +} + +/// `bot connect ` — verify, persist, save keychain. Does NOT spawn the +/// bot (the CLI one-shot process would exit). The running desktop app or a +/// REPL session picks up the stored metadata + keychain token. +pub async fn bot_connect(state: &AppState, token: &str) -> CliReply { + let token = token.trim(); + if token.is_empty() { + return CliReply::error("bot connect: token is empty"); + } + let me = match token_verify::verify_token(token).await { + Ok(m) => m, + Err(e) => return CliReply::error(format!("verify: {e}")), + }; + bot_lifecycle::stop_and_wait_for_bot(state).await; + let conn = ConnectionData { + bot_token: token.to_string(), + bot_id: me.id.to_string(), + bot_username: me.username().to_string(), + connected_at: Utc::now(), + }; + if let Err(e) = secure_store::save_token(&conn.bot_id, &conn.bot_token) { + return CliReply::error(format!("keychain save: {e}")); + } + let metadata = ConnectionMetadata::from(&conn); + if let Err(e) = bot_repo::persist(&state.store_path, &metadata) { + let _ = secure_store::delete_token(&conn.bot_id); + return CliReply::error(format!("persist: {e}")); + } + *state.connection.lock().await = Some(conn); + state + .emit_log("ok", &format!("Bot @{} connected via CLI", me.username())) + .await; + CliReply::code( + "bash", + format!( + "connected: @{}\ntoken saved (keychain + {})", + me.username(), + state.store_path.display(), + ), + ) +} + +pub async fn bot_disconnect(state: &AppState) -> CliReply { + bot_lifecycle::stop_and_wait_for_bot(state).await; + let bot_id = { + let mut lock = state.connection.lock().await; + let id = lock.as_ref().map(|c| c.bot_id.clone()); + *lock = None; + id + }; + if let Err(e) = bot_repo::clear(&state.store_path) { + return CliReply::error(format!("clear store: {e}")); + } + if let Some(id) = bot_id { + if let Err(e) = secure_store::delete_token(&id) { + return CliReply::error(format!("keychain delete: {e}")); + } + } + CliReply::code("bash", "disconnected and cleared store") +} + +/// `tools [search]` — list MCP tools from the live registry. +/// The registry is assumed warmed by the caller (bootstrap / REPL startup). +pub async fn tools(state: &AppState, search: Option<&str>) -> CliReply { + let reg = state.mcp.read().await; + let mut rows: Vec<(String, String, String)> = reg + .all_tools() + .into_iter() + .map(|t| { + ( + t.server_name.clone(), + t.name.clone(), + t.description.unwrap_or_default(), + ) + }) + .collect(); + if let Some(q) = search { + let q = q.to_lowercase(); + rows.retain(|(s, n, d)| { + s.to_lowercase().contains(&q) + || n.to_lowercase().contains(&q) + || d.to_lowercase().contains(&q) + }); + } + if rows.is_empty() { + return CliReply::code( + "bash", + "no tools (MCP not warmed or filter matched nothing)", + ); + } + rows.sort_by(|a, b| (a.0.as_str(), a.1.as_str()).cmp(&(b.0.as_str(), b.1.as_str()))); + let name_w = rows.iter().map(|(_, n, _)| n.len()).max().unwrap_or(0); + let server_w = rows.iter().map(|(s, _, _)| s.len()).max().unwrap_or(0); + let mut out = String::new(); + for (server, name, desc) in rows { + let snippet = desc.lines().next().unwrap_or(""); + out.push_str(&format!( + "{:, slug: Option<&str>) -> CliReply { + let action = action.map(str::trim).unwrap_or("list"); + match action { + "list" | "" => { + let rows = skills_service::list_skills(&state.store_path); + if rows.is_empty() { + return CliReply::code("bash", "no skills"); + } + let slug_w = rows.iter().map(|s| s.slug.len()).max().unwrap_or(0); + let mut out = String::new(); + for sk in rows { + let flag = if sk.enabled { "on" } else { "off" }; + out.push_str(&format!( + "[{flag:>3}] {: { + let Some(slug) = slug.map(str::trim).filter(|s| !s.is_empty()) else { + return CliReply::error(format!("skills {action}: slug required")); + }; + let enable = action == "enable"; + if let Err(e) = skills_service::set_skill_enabled(&state.store_path, slug, enable) { + return CliReply::error(format!("skills {action}: {e}")); + } + CliReply::code( + "bash", + format!( + "skill `{slug}` {}", + if enable { "enabled" } else { "disabled" } + ), + ) + } + other => CliReply::error(format!( + "skills: unknown action `{other}` (use list | enable | disable)" + )), + } +} + +/// `fs` — show / mutate MCP filesystem roots. Mutations rewrite +/// `mcp.json` directly; docker-runtime tool sync is a dashboard concern. +pub async fn fs(state: &AppState, action: Option<&str>, path: Option<&str>) -> CliReply { + let action = action.map(str::trim).unwrap_or("list"); + let _guard = state.mcp_config_mutex.lock().await; + match action { + "list" | "" => { + let cfg = match mcp_service::load_or_init_config(&state.mcp_config_path) { + Ok(c) => c, + Err(e) => return CliReply::error(format!("fs: {e}")), + }; + let paths = mcp_service::filesystem_allowed_paths(&cfg); + if paths.is_empty() { + CliReply::code("bash", "(no roots)") + } else { + CliReply::code("bash", paths.join("\n")) + } + } + "add" | "remove" => { + let Some(path) = path.map(str::trim).filter(|p| !p.is_empty()) else { + return CliReply::error(format!("fs {action}: path required")); + }; + let mut cfg = match mcp_service::load_or_init_config(&state.mcp_config_path) { + Ok(c) => c, + Err(e) => return CliReply::error(format!("fs: {e}")), + }; + let mut paths = mcp_service::filesystem_allowed_paths(&cfg); + let before = paths.len(); + if action == "add" { + if !paths.iter().any(|p| p == path) { + paths.push(path.to_string()); + } + } else { + paths.retain(|p| p != path); + } + if paths.len() == before { + return CliReply::code( + "bash", + format!("no change ({action} `{path}` had no effect)"), + ); + } + mcp_service::set_filesystem_allowed_paths(&mut cfg, &paths); + if let Err(e) = mcp_service::save_config(&state.mcp_config_path, &cfg) { + return CliReply::error(format!("save: {e}")); + } + CliReply::code("bash", format!("{action}: {path}")) + } + other => CliReply::error(format!( + "fs: unknown action `{other}` (use list | add | remove)" + )), + } +} + +/// `logs` — three modes: +/// - `--follow` (live) subscribes to the in-memory broadcast and prints each event. +/// - `--tail N` reads the newest N lines from the audit NDJSON files on disk, +/// walking back day-by-day until N is reached or no older file remains. +/// - default (no flag) tails the last 50 lines — the common "what just happened" case. +pub async fn logs(state: &AppState, tail: Option, follow: bool) -> CliReply { + if follow { + return follow_logs_from_broadcast(state).await; + } + let n = tail.unwrap_or(50); + if n == 0 { + return CliReply::error("logs --tail: N must be ≥ 1"); + } + tail_logs_from_audit(state, n).await +} + +async fn follow_logs_from_broadcast(state: &AppState) -> CliReply { + let mut rx = match state.log_tx.lock().await.as_ref() { + Some(tx) => tx.subscribe(), + None => return CliReply::error("logs: broadcast channel is closed"), + }; + loop { + match rx.recv().await { + Ok(ev) => println!("{}", format_log_line(&ev)), + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + eprintln!("[logs lagged: {skipped} event(s) dropped]"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + CliReply::code("bash", "log stream closed") +} + +async fn tail_logs_from_audit(state: &AppState, n: usize) -> CliReply { + let files = match audit_log::list_audit_files(&state.store_path).await { + Ok(f) => f, + Err(e) => return CliReply::error(format!("logs: list audit files: {e}")), + }; + // `list_audit_files` sorts newest-date first. Accumulate lines (oldest first + // of the ones we keep) by walking days backwards; stop once we hit `n`. + let mut out: Vec = Vec::with_capacity(n); + for entry in files { + let content = match audit_log::read_audit_file(&state.store_path, &entry.date).await { + Ok(s) => s, + Err(e) => { + log::warn!("logs: read audit-{}: {e}", entry.date); + continue; + } + }; + let mut day_lines: Vec = content + .lines() + .filter_map(format_audit_ndjson_line) + .collect(); + // Combine `day_lines` (older) with `out` (newer already gathered). + day_lines.append(&mut out); + // Keep tail `n` entries. + let drop = day_lines.len().saturating_sub(n); + out = day_lines.split_off(drop); + if out.len() >= n { + break; + } + } + if out.is_empty() { + return CliReply::code("bash", "(no audit history)"); + } + CliReply::log(out.join("\n")) +} + +fn format_audit_ndjson_line(raw: &str) -> Option { + let line = raw.trim(); + if line.is_empty() { + return None; + } + #[derive(Deserialize)] + struct AuditJson { + timestamp: String, + kind: String, + message: String, + } + let j: AuditJson = serde_json::from_str(line).ok()?; + Some(format!("{} [{}] {}", j.timestamp, j.kind, j.message)) +} + +fn format_log_line(ev: &LogEntry) -> String { + format!("{} [{}] {}", ev.timestamp, ev.kind, ev.message) +} + +pub async fn ask(state: &AppState, text: &str) -> CliReply { + let trimmed = text.trim(); + if trimmed.is_empty() { + return CliReply::error("ask: prompt is empty"); + } + + let progress = Progress::start("Thinking"); + let forwarder = spawn_status_forwarder(state, progress.status_sender()).await; + let result = agent::run_turn(state, trimmed).await; + if let Some(h) = forwarder { + h.abort(); + } + let elapsed = progress.finish().await; + emit_baked_line(elapsed); + + match result { + Ok(turn) if turn.suppress_telegram_reply => CliReply::text("(no reply)"), + Ok(turn) => { + if turn.text.trim().is_empty() { + CliReply::text("(no reply)") + } else { + CliReply::text(turn.text) + } + } + Err(e) => CliReply::error(format!("agent error: {e}")), + } +} + +/// Subscribe to the broadcast log channel; forward summarized events to the +/// spinner status. No-op when the channel is already closed. +async fn spawn_status_forwarder( + state: &AppState, + status: ProgressStatus, +) -> Option> { + let mut rx = state + .log_tx + .lock() + .await + .as_ref() + .map(|tx| tx.subscribe())?; + Some(tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(ev) => { + if ev.kind == "tool" { + if let Some(block) = inline_tool_block(&ev.message) { + status.interject(block).await; + } + } + if let Some(s) = summarize_log_for_status(&ev) { + status.set(s).await; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + })) +} + +/// Render a `"tool"` log message as a persistent REPL block (matches the +/// reply ` ⎿ ` prefix style). Returns `None` for noise we don't echo. +/// +/// Shapes from `modules/agent`: +/// - `[N] name` → `called name (step N)` +/// - `name: bytes` → pass-through (e.g. `fetch: 4012 bytes`) +/// - `name error: <...>` → pass-through +/// - `[host] auto-fetch ` → pass-through +fn inline_tool_block(message: &str) -> Option { + let msg = message.trim(); + if msg.is_empty() || msg.ends_with("does not support tools") { + return None; + } + let rendered = if let Some(rest) = msg.strip_prefix('[') { + if let Some((step, tail)) = rest.split_once(']') { + let name = tail.trim(); + if step.starts_with("host") { + msg.to_string() + } else { + format!("called {name} (step {step})") + } + } else { + msg.to_string() + } + } else { + msg.to_string() + }; + const MAX: usize = 100; + let clipped: String = rendered.chars().take(MAX).collect(); + let suffix = if rendered.chars().count() > MAX { + "…" + } else { + "" + }; + if std::io::stderr().is_terminal() { + Some(format!( + " \x1b[2m⎿\x1b[0m \x1b[2m·\x1b[0m {clipped}{suffix}" + )) + } else { + Some(format!(" ⎿ · {clipped}{suffix}")) + } +} + +/// One-line compaction of a log event for the live spinner suffix. +/// Returns `None` for log kinds that would just echo ourselves. +fn summarize_log_for_status(ev: &LogEntry) -> Option { + match ev.kind.as_str() { + // Self-echo + the final reply — the user is already about to see it. + "cli" | "reply" | "msg" | "auth" | "ok" => None, + _ => { + const MAX: usize = 60; + let msg = ev.message.trim(); + let msg: String = msg.chars().take(MAX).collect(); + let ellipsed = if msg.chars().count() == MAX { + format!("{msg}…") + } else { + msg + }; + Some(format!("{}: {}", ev.kind, ellipsed)) + } + } +} + +/// ` ⎿ Baked for 4.8s` on stderr once the spinner has been cleared. +/// Only emitted when stderr is a TTY, matching the spinner gate. +fn emit_baked_line(elapsed: std::time::Duration) { + if !std::io::stderr().is_terminal() { + return; + } + let line = format!( + " \x1b[2m⎿\x1b[0m \x1b[2mBaked for {}\x1b[0m\n", + fmt_elapsed(elapsed) + ); + let mut err = std::io::stderr().lock(); + let _ = err.write_all(line.as_bytes()); + let _ = err.flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inline_tool_block_rewrites_step_call() { + let out = inline_tool_block("[0] fetch").unwrap(); + assert!(out.contains("called fetch (step 0)"), "got: {out}"); + } + + #[test] + fn inline_tool_block_passes_host_auto_fetch() { + let out = inline_tool_block("[host] auto-fetch https://example.com").unwrap(); + assert!( + out.contains("[host] auto-fetch https://example.com"), + "got: {out}" + ); + } + + #[test] + fn inline_tool_block_passes_result_line() { + let out = inline_tool_block("fetch: 4012 bytes").unwrap(); + assert!(out.ends_with("fetch: 4012 bytes"), "got: {out}"); + } + + #[test] + fn inline_tool_block_passes_error_line() { + let out = inline_tool_block("fetch error: 503 Service Unavailable").unwrap(); + assert!(out.contains("error: 503"), "got: {out}"); + } + + #[test] + fn inline_tool_block_drops_unsupported_marker() { + assert!(inline_tool_block("qwen3:0.5b does not support tools").is_none()); + } + + #[test] + fn format_audit_line_accepts_valid_ndjson() { + let raw = + r#"{"timestamp":"2026-04-23T12:34:56.789Z","kind":"cli","message":"pengine status"}"#; + let out = format_audit_ndjson_line(raw).unwrap(); + assert!(out.contains("[cli]")); + assert!(out.contains("pengine status")); + } + + #[test] + fn format_audit_line_skips_garbage() { + assert!(format_audit_ndjson_line("not-json").is_none()); + assert!(format_audit_ndjson_line("").is_none()); + } +} diff --git a/src-tauri/src/modules/cli/mod.rs b/src-tauri/src/modules/cli/mod.rs new file mode 100644 index 0000000..9a43f5e --- /dev/null +++ b/src-tauri/src/modules/cli/mod.rs @@ -0,0 +1,25 @@ +//! Pengine CLI — transport-agnostic command surface. +//! +//! Implements GitHub issue #90. The CLI is the same binary as the Tauri +//! desktop app, branched via `tauri-plugin-cli`'s `app.cli().matches()`. +//! +//! Invariants: +//! - Native commands (registered in [`commands`]) are never visible to the +//! agent. The router dispatches them synchronously and returns a +//! [`output::CliReply`] without calling [`crate::modules::agent::run_turn`]. +//! - Unknown slash commands surface as [`router::RouterOutcome::Unknown`] — +//! an error to the user's sink, never forwarded to the model. +//! - Handlers call existing module services (bot, mcp, skills, ollama, +//! user_settings) — no duplicated business logic. +//! - Telegram `$` bridge (`telegram_bridge`) reuses [`dispatch`] + [`router`]. + +pub mod banner; +pub mod bootstrap; +pub mod commands; +pub mod dispatch; +pub mod handlers; +pub mod output; +pub mod repl; +pub mod router; +pub mod shim; +pub mod telegram_bridge; diff --git a/src-tauri/src/modules/cli/output.rs b/src-tauri/src/modules/cli/output.rs new file mode 100644 index 0000000..f6cbc72 --- /dev/null +++ b/src-tauri/src/modules/cli/output.rs @@ -0,0 +1,532 @@ +//! CLI reply envelope and output sinks. +//! +//! Handlers produce [`CliReply`] values; rendering (ANSI, Markdown fences, +//! chunking) belongs to sinks. This keeps handlers transport-agnostic and +//! lets a single change to `TelegramSink` affect every reply at once. + +use serde::Serialize; +use std::io::{IsTerminal, Write}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex as AsyncMutex; +use tokio::task::JoinHandle; + +/// What kind of block the body represents. Sinks decide the concrete +/// rendering (ANSI color, code fence language, etc.). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum ReplyKind { + /// Plain prose. + Text, + /// Pre-formatted code / command output. `lang` is the fence hint. + CodeBlock { lang: String }, + /// Unified diff, pre-formatted by the producing tool (e.g. `git diff`). + Diff, + /// Log stream chunk; rendered as a bash code block. + Log, + /// Error message; rendered red on terminals, plain on Telegram. + Error, +} + +/// One user-visible unit of output. Handlers return these; sinks render them. +#[derive(Debug, Clone, Serialize)] +pub struct CliReply { + #[serde(flatten)] + pub kind: ReplyKind, + pub body: String, +} + +impl CliReply { + pub fn text(body: impl Into) -> Self { + Self { + kind: ReplyKind::Text, + body: body.into(), + } + } + + pub fn error(body: impl Into) -> Self { + Self { + kind: ReplyKind::Error, + body: body.into(), + } + } + + pub fn code(lang: impl Into, body: impl Into) -> Self { + Self { + kind: ReplyKind::CodeBlock { lang: lang.into() }, + body: body.into(), + } + } + + pub fn diff(body: impl Into) -> Self { + Self { + kind: ReplyKind::Diff, + body: body.into(), + } + } + + pub fn log(body: impl Into) -> Self { + Self { + kind: ReplyKind::Log, + body: body.into(), + } + } +} + +/// Versioned JSON envelope so scripts can pin against `v`. +#[derive(Debug, Clone, Serialize)] +pub struct JsonEnvelope<'a> { + pub v: u32, + pub reply: &'a CliReply, +} + +/// Rendering target. Implementers must be thread-safe for later FanOut usage. +pub trait OutputSink: Send + Sync { + fn render(&self, reply: &CliReply); +} + +/// Default: ANSI colors on TTYs, plain text otherwise. Prompt lines ("user@pengine:~$") +/// are written by the caller before invoking `render`, not by the sink itself. +pub struct TerminalSink { + color: bool, +} + +impl TerminalSink { + pub fn new() -> Self { + Self { + color: is_terminal_stdout(), + } + } + + #[cfg(test)] + pub fn plain() -> Self { + Self { color: false } + } +} + +impl Default for TerminalSink { + fn default() -> Self { + Self::new() + } +} + +impl OutputSink for TerminalSink { + fn render(&self, reply: &CliReply) { + match &reply.kind { + ReplyKind::Text => println!("{}", reply.body), + ReplyKind::Error => { + if self.color { + eprintln!("\x1b[31m{}\x1b[0m", reply.body); + } else { + eprintln!("{}", reply.body); + } + } + ReplyKind::CodeBlock { .. } | ReplyKind::Log => { + // Print raw — terminal rendering speaks for itself without fences. + println!("{}", reply.body); + } + ReplyKind::Diff => { + if self.color { + print_diff_with_ansi(&reply.body); + } else { + println!("{}", reply.body); + } + } + } + } +} + +/// Emit `{"v":1, "kind": "...", "body": "..."}` one reply per line. +pub struct JsonSink; + +impl OutputSink for JsonSink { + fn render(&self, reply: &CliReply) { + let env = JsonEnvelope { v: 1, reply }; + match serde_json::to_string(&env) { + Ok(line) => println!("{line}"), + Err(e) => eprintln!("{{\"v\":1,\"kind\":\"error\",\"body\":\"json encode: {e}\"}}"), + } + } +} + +fn print_diff_with_ansi(body: &str) { + for line in body.lines() { + if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") { + println!("\x1b[1;36m{line}\x1b[0m"); // cyan, bold for headers + } else if line.starts_with('+') { + println!("\x1b[32m{line}\x1b[0m"); // green + } else if line.starts_with('-') { + println!("\x1b[31m{line}\x1b[0m"); // red + } else { + println!("{line}"); + } + } +} + +fn is_terminal_stdout() -> bool { + // Avoids pulling a new crate; the file-descriptor check is enough for color gating. + #[cfg(unix)] + unsafe { + // SAFETY: `isatty` takes a raw fd and has no memory effects. + extern "C" { + fn isatty(fd: i32) -> i32; + } + isatty(1) == 1 + } + #[cfg(not(unix))] + { + true + } +} + +/// Where a reply is being printed. Controls prefix/continuation layout. +#[derive(Debug, Clone, Copy)] +pub enum RenderStyle { + /// One-shot: print as-is. + Plain, + /// Interactive REPL: ` ⎿ ` first-line prefix, 5-space continuation. + ReplIndent, +} + +const REPL_FIRST_PREFIX: &str = " \x1b[2m⎿\x1b[0m "; +const REPL_FIRST_PREFIX_PLAIN: &str = " ⎿ "; +const REPL_CONT_PREFIX: &str = " "; + +/// Central rendering helper. Handles diff-fence splitting for `Text` replies +/// in REPL mode so ` ```diff ``` ` blocks get coloured inline. +pub fn render_reply(sink: &dyn OutputSink, reply: &CliReply, style: RenderStyle) { + match style { + RenderStyle::Plain => sink.render(reply), + RenderStyle::ReplIndent => render_reply_indented(sink, reply), + } +} + +fn render_reply_indented(sink: &dyn OutputSink, reply: &CliReply) { + match &reply.kind { + ReplyKind::Text => { + let blocks = split_text_into_blocks(&reply.body); + for (i, part) in blocks.iter().enumerate() { + let prefix = if i == 0 { + FirstPrefix::Repl + } else { + FirstPrefix::None + }; + render_with_prefix(sink, part, prefix); + } + } + _ => render_with_prefix(sink, reply, FirstPrefix::Repl), + } +} + +#[derive(Clone, Copy)] +enum FirstPrefix { + /// Indent the first line with ` ⎿ ` (or plain equivalent if no TTY). + Repl, + /// No first-line prefix; still indent continuation lines (for the 2nd+ block in a split reply). + None, +} + +fn render_with_prefix(sink: &dyn OutputSink, reply: &CliReply, first: FirstPrefix) { + let color = is_terminal_stdout(); + let (first_prefix, cont_prefix) = match first { + FirstPrefix::Repl => { + if color { + (REPL_FIRST_PREFIX, REPL_CONT_PREFIX) + } else { + (REPL_FIRST_PREFIX_PLAIN, REPL_CONT_PREFIX) + } + } + FirstPrefix::None => (REPL_CONT_PREFIX, REPL_CONT_PREFIX), + }; + + // We can't route through OutputSink::render directly because it owns the + // `println!` newline placement; rebuild the body with indentation and + // hand that to the sink instead. + let indented = indent_body(&reply.body, first_prefix, cont_prefix); + let shaped = CliReply { + kind: reply.kind.clone(), + body: indented, + }; + sink.render(&shaped); +} + +fn indent_body(body: &str, first_prefix: &str, cont_prefix: &str) -> String { + if body.is_empty() { + return first_prefix.to_string(); + } + let mut out = String::new(); + for (i, line) in body.lines().enumerate() { + if i == 0 { + out.push_str(first_prefix); + } else { + out.push('\n'); + out.push_str(cont_prefix); + } + out.push_str(line); + } + out +} + +/// Pull `` ```diff\n…\n``` `` blocks out of a text body. Surrounding text +/// stays as `Text` replies. Missing closers or no fences → single `Text`. +pub fn split_text_into_blocks(body: &str) -> Vec { + const OPEN: &str = "```diff\n"; + const CLOSE: &str = "\n```"; + let mut out = Vec::new(); + let mut rest = body; + while let Some(open_idx) = rest.find(OPEN) { + let before = &rest[..open_idx]; + let trimmed_before = before.trim_matches('\n'); + if !trimmed_before.is_empty() { + out.push(CliReply::text(trimmed_before.to_string())); + } + let after_open = &rest[open_idx + OPEN.len()..]; + match after_open.find(CLOSE) { + Some(close_idx) => { + let inner = &after_open[..close_idx]; + out.push(CliReply::diff(inner.to_string())); + rest = &after_open[close_idx + CLOSE.len()..]; + } + None => { + // unterminated fence — keep whatever came after open as diff + out.push(CliReply::diff(after_open.to_string())); + rest = ""; + break; + } + } + } + let tail = rest.trim_matches('\n'); + if !tail.is_empty() { + out.push(CliReply::text(tail.to_string())); + } + if out.is_empty() { + out.push(CliReply::text(body.to_string())); + } + out +} + +/// Human elapsed string: `850ms`, `4.8s`, `4m 48s`. +pub fn fmt_elapsed(d: Duration) -> String { + let ms = d.as_millis(); + if ms < 1000 { + return format!("{ms}ms"); + } + let secs = d.as_secs_f64(); + if secs < 60.0 { + return format!("{secs:.1}s"); + } + let total = d.as_secs(); + let m = total / 60; + let s = total % 60; + format!("{m}m {s}s") +} + +/// Live progress indicator written to stderr. No-op when stderr is not a TTY. +/// +/// Lifecycle: +/// - [`Progress::start`] spawns the spinner task and returns a handle. +/// - [`ProgressHandle::status_sender`] hands out a cheap clone for updating the +/// live status suffix from other tasks (e.g. a log forwarder). +/// - [`ProgressHandle::finish`] flips the done flag, waits for the spinner to +/// clear its line, and returns the elapsed time. +pub struct Progress; + +impl Progress { + pub fn start(label: impl Into) -> ProgressHandle { + let start = Instant::now(); + let animate = std::io::stderr().is_terminal(); + let state = Arc::new(AsyncMutex::new(ProgressState { + label: label.into(), + last_status: None, + done: false, + interjects: Vec::new(), + tty: animate, + })); + let task = if animate { + let state = state.clone(); + Some(tokio::spawn(spinner_loop(state, start))) + } else { + None + }; + ProgressHandle { task, start, state } + } +} + +pub struct ProgressHandle { + task: Option>, + start: Instant, + state: Arc>, +} + +pub struct ProgressStatus { + state: Arc>, +} + +struct ProgressState { + label: String, + last_status: Option, + done: bool, + /// Lines to print **above** the spinner on the next tick. Consumers enqueue + /// with [`ProgressStatus::interject`]; the spinner task drains them. + interjects: Vec, + /// When false, [`ProgressStatus::interject`] is a no-op so non-TTY callers + /// don't leak memory into an unread queue. + tty: bool, +} + +impl ProgressHandle { + pub fn status_sender(&self) -> ProgressStatus { + ProgressStatus { + state: self.state.clone(), + } + } + + pub async fn finish(self) -> Duration { + { + let mut s = self.state.lock().await; + s.done = true; + } + if let Some(t) = self.task { + let _ = t.await; + } + self.start.elapsed() + } +} + +impl ProgressStatus { + pub async fn set(&self, s: impl Into) { + let mut st = self.state.lock().await; + st.last_status = Some(s.into()); + } + + /// Queue a line to print above the spinner on the next tick. + /// No-op when the spinner wasn't started (no TTY). + pub async fn interject(&self, line: impl Into) { + let mut st = self.state.lock().await; + if !st.tty { + return; + } + st.interjects.push(line.into()); + } +} + +async fn spinner_loop(state: Arc>, start: Instant) { + const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let mut i: usize = 0; + loop { + // Check done + drain interjects + build line, all under the lock. + let (line, interjects) = { + let mut st = state.lock().await; + if st.done { + break; + } + let interjects = std::mem::take(&mut st.interjects); + let elapsed = fmt_elapsed(start.elapsed()); + let line = match st.last_status.as_deref() { + Some(status) if !status.is_empty() => format!( + "\r\x1b[2K\x1b[2m{} {} · {} · {}\x1b[0m", + FRAMES[i], st.label, status, elapsed + ), + _ => format!( + "\r\x1b[2K\x1b[2m{} {} · {}\x1b[0m", + FRAMES[i], st.label, elapsed + ), + }; + (line, interjects) + }; + // `StderrLock` is `!Send`; scope all writes so nothing crosses `.await`. + write_interjects_above_spinner(&interjects); + write_line_to_stderr(&line); + tokio::time::sleep(Duration::from_millis(90)).await; + i = (i + 1) % FRAMES.len(); + } + // Final drain — pick up anything queued after `done` flipped. + let leftover = { + let mut st = state.lock().await; + std::mem::take(&mut st.interjects) + }; + write_interjects_above_spinner(&leftover); + write_line_to_stderr("\r\x1b[2K"); +} + +fn write_line_to_stderr(s: &str) { + let mut err = std::io::stderr().lock(); + let _ = err.write_all(s.as_bytes()); + let _ = err.flush(); +} + +fn write_interjects_above_spinner(lines: &[String]) { + if lines.is_empty() { + return; + } + let mut err = std::io::stderr().lock(); + // Erase the current spinner line, print each interject with its own + // newline; next spinner tick redraws itself below. + let _ = err.write_all(b"\r\x1b[2K"); + for l in lines { + let _ = err.write_all(l.as_bytes()); + let _ = err.write_all(b"\n"); + } + let _ = err.flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_envelope_is_versioned() { + let reply = CliReply::text("hi"); + let env = JsonEnvelope { + v: 1, + reply: &reply, + }; + let s = serde_json::to_string(&env).unwrap(); + assert!(s.starts_with("{\"v\":1,")); + assert!(s.contains("\"kind\":\"text\"")); + assert!(s.contains("\"body\":\"hi\"")); + } + + #[test] + fn code_block_carries_lang() { + let r = CliReply::code("bash", "ls -la"); + let s = serde_json::to_string(&r).unwrap(); + assert!(s.contains("\"kind\":\"code_block\"")); + assert!(s.contains("\"lang\":\"bash\"")); + } + + #[test] + fn split_text_pulls_diff_fence_out() { + let body = "before text\n```diff\n+a\n-b\n```\nafter text"; + let parts = split_text_into_blocks(body); + assert_eq!(parts.len(), 3); + assert!(matches!(parts[0].kind, ReplyKind::Text)); + assert_eq!(parts[0].body, "before text"); + assert!(matches!(parts[1].kind, ReplyKind::Diff)); + assert_eq!(parts[1].body, "+a\n-b"); + assert!(matches!(parts[2].kind, ReplyKind::Text)); + assert_eq!(parts[2].body, "after text"); + } + + #[test] + fn split_text_passes_plain_through() { + let parts = split_text_into_blocks("just prose, no fences"); + assert_eq!(parts.len(), 1); + assert!(matches!(parts[0].kind, ReplyKind::Text)); + } + + #[test] + fn indent_body_prefixes_first_and_continuation() { + let out = indent_body("one\ntwo\nthree", " ⎿ ", " "); + assert_eq!(out, " ⎿ one\n two\n three"); + } + + #[test] + fn fmt_elapsed_bucketizes_correctly() { + assert_eq!(fmt_elapsed(Duration::from_millis(5)), "5ms"); + assert_eq!(fmt_elapsed(Duration::from_millis(999)), "999ms"); + assert!(fmt_elapsed(Duration::from_secs(4)).ends_with('s')); + assert_eq!(fmt_elapsed(Duration::from_secs(65)), "1m 5s"); + assert_eq!(fmt_elapsed(Duration::from_secs(288)), "4m 48s"); + } +} diff --git a/src-tauri/src/modules/cli/repl.rs b/src-tauri/src/modules/cli/repl.rs new file mode 100644 index 0000000..9feb89d --- /dev/null +++ b/src-tauri/src/modules/cli/repl.rs @@ -0,0 +1,104 @@ +//! Interactive shell. Entered via bare `pengine` in a TTY (or `pengine` from `pengine-cli`). +//! +//! Layered on top of [`super::router`] and [`super::handlers`]: the REPL reads +//! one line, classifies it, dispatches, and renders the reply — nothing +//! special to this file lives outside line editing and history management. + +use super::banner::CLI_WELCOME; +use super::dispatch::{dispatch_line, format_repl_line_for_audit, DispatchContext}; +use super::output::{render_reply, CliReply, OutputSink, RenderStyle, TerminalSink}; +use crate::modules::mcp::service as mcp_service; +use crate::shared::state::AppState; +use rustyline::error::ReadlineError; +use rustyline::history::FileHistory; +use rustyline::{Config, Editor}; +use std::io::IsTerminal; +use std::path::PathBuf; + +/// Styled prompt when stdout is a TTY (cyan-bold `❯`). Falls back to plain +/// `>` when piped, so history grepping stays readable. +const PROMPT_TTY: &str = "\x1b[1;36m❯\x1b[0m "; +const PROMPT_PLAIN: &str = "> "; + +pub async fn run(state: &AppState) -> CliReply { + let sink = TerminalSink::new(); + sink.render(&CliReply::text(format!( + "{}\ +\n\ +Pengine REPL — slash commands + free text; /exit or Ctrl+D to quit.\n\ +store: {}", + CLI_WELCOME.trim_start_matches('\n'), + state.store_path.display() + ))); + + // Best-effort MCP warmup so /tools and free-text /ask land with tools + // available. Failure is reported but non-fatal — some REPL commands don't + // need MCP (e.g. /config, /status). + if let Err(e) = mcp_service::rebuild_registry_into_state(state).await { + sink.render(&CliReply::error(format!("mcp warmup skipped: {e}"))); + } + + let history_path = history_path(&state.store_path); + let mut rl = match build_editor() { + Ok(r) => r, + Err(e) => return CliReply::error(format!("repl: editor init failed: {e}")), + }; + let _ = rl.load_history(&history_path); + + let prompt = if std::io::stdout().is_terminal() { + PROMPT_TTY + } else { + PROMPT_PLAIN + }; + + loop { + match rl.readline(prompt) { + Ok(line) => { + let line = line.trim_end_matches('\n').to_string(); + if line.trim().is_empty() { + continue; + } + let _ = rl.add_history_entry(line.as_str()); + if is_exit(&line) { + break; + } + let audit = format_repl_line_for_audit(&line); + if !audit.is_empty() { + state.emit_log("cli", &format!("repl {audit}")).await; + } + let reply = dispatch_line(state, &line, DispatchContext::default()).await; + render_reply(&sink, &reply, RenderStyle::ReplIndent); + } + Err(ReadlineError::Interrupted) => continue, // ^C clears the line + Err(ReadlineError::Eof) => break, // ^D exits + Err(e) => { + render_reply( + &sink, + &CliReply::error(format!("repl: {e}")), + RenderStyle::ReplIndent, + ); + break; + } + } + } + + let _ = rl.save_history(&history_path); + CliReply::text("bye.") +} + +fn build_editor() -> Result, String> { + let cfg = Config::builder().auto_add_history(false).build(); + Editor::with_config(cfg).map_err(|e| e.to_string()) +} + +fn history_path(store_path: &std::path::Path) -> PathBuf { + store_path + .parent() + .map(|p| p.join("cli_history")) + .unwrap_or_else(|| PathBuf::from("cli_history")) +} + +fn is_exit(line: &str) -> bool { + let t = line.trim(); + matches!(t, "/exit" | "/quit" | "exit" | "quit") +} diff --git a/src-tauri/src/modules/cli/router.rs b/src-tauri/src/modules/cli/router.rs new file mode 100644 index 0000000..9256827 --- /dev/null +++ b/src-tauri/src/modules/cli/router.rs @@ -0,0 +1,90 @@ +//! Transport-agnostic classifier. +//! +//! Two entry points feed into the handler layer: +//! +//! - [`classify_line`] — REPL / Telegram `$`-prefix path. Splits a free-text +//! line into a native slash command, an agent message, or an unknown-slash +//! error. (Used from PR 2 onwards.) +//! - One-shot `tauri-plugin-cli` matches bypass this file and go straight to +//! [`super::bootstrap::dispatch`]. +//! +//! Invariant: [`RouterOutcome::Unknown`] never converts into +//! [`RouterOutcome::Agent`] — this prevents the model from learning native +//! command names by observing echoed error text. + +use super::commands; + +#[derive(Debug, PartialEq, Eq)] +pub enum RouterOutcome<'a> { + /// A recognized native command; `name` is the keyword (without the `/`), + /// `rest` is the remaining argument text (may be empty). + Native { name: &'static str, rest: &'a str }, + /// Free text — forward to the agent. + Agent(&'a str), + /// Slash-prefixed input with an unregistered name. Report to the user + /// only; do not forward to the agent. + Unknown(&'a str), +} + +/// Classify a single line. Leading whitespace is tolerated. +pub fn classify_line(line: &str) -> RouterOutcome<'_> { + let trimmed = line.trim_start(); + let Some(rest) = trimmed.strip_prefix('/') else { + return RouterOutcome::Agent(trimmed); + }; + let (name, tail) = match rest.find(char::is_whitespace) { + Some(idx) => (&rest[..idx], rest[idx..].trim_start()), + None => (rest, ""), + }; + match commands::lookup(name) { + Some(cmd) => RouterOutcome::Native { + name: cmd.name, + rest: tail, + }, + None => RouterOutcome::Unknown(name), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_text_routes_to_agent() { + assert_eq!( + classify_line("hello world"), + RouterOutcome::Agent("hello world") + ); + } + + #[test] + fn slash_known_routes_native_with_args() { + assert_eq!( + classify_line("/config skills_hint_max_bytes=12000"), + RouterOutcome::Native { + name: "config", + rest: "skills_hint_max_bytes=12000", + } + ); + } + + #[test] + fn slash_known_bare() { + assert_eq!( + classify_line("/help"), + RouterOutcome::Native { + name: "help", + rest: "", + } + ); + } + + #[test] + fn slash_unknown_stays_in_user_channel() { + // Critical invariant: must not fall through to Agent. + assert_eq!( + classify_line("/deploy --dry-run"), + RouterOutcome::Unknown("deploy") + ); + } +} diff --git a/src-tauri/src/modules/cli/shim.rs b/src-tauri/src/modules/cli/shim.rs new file mode 100644 index 0000000..cc56a5b --- /dev/null +++ b/src-tauri/src/modules/cli/shim.rs @@ -0,0 +1,203 @@ +//! Install a **`pengine-cli`** entry on the user PATH (no admin on macOS/Linux when using `~/.local/bin`). +//! +//! The dashboard writes a small launcher script that sets `PENGINE_LAUNCH_MODE=cli` +//! and `exec`s the real app binary. That keeps terminal use aligned with `bun run cli` +//! (REPL + one-shots) and avoids falling through to the GUI when stdin is not a TTY. + +use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliShimStatus { + pub shim_path: String, + pub installed: bool, + /// App binary path embedded in the launcher (for display / debugging). + pub resolves_to: Option, + /// Whether a typical `PATH` already includes the launcher directory. + pub local_bin_on_path: bool, + /// One-line shell hint if `local_bin_on_path` is false. + pub path_export_hint: String, +} + +fn path_env_contains_dir(dir: &Path) -> bool { + let Ok(path_var) = std::env::var("PATH") else { + return false; + }; + let dir_norm = normalize_path_for_compare(dir); + path_var + .split(if cfg!(windows) { ';' } else { ':' }) + .any(|entry| { + let p = Path::new(entry.trim()); + !p.as_os_str().is_empty() && normalize_path_for_compare(p) == dir_norm + }) +} + +fn normalize_path_for_compare(p: &Path) -> PathBuf { + let mut b = p.to_path_buf(); + while b.as_os_str().len() > 1 && b.file_name().is_none() { + b.pop(); + } + b +} + +pub fn shim_path() -> Result { + #[cfg(unix)] + { + let home = + std::env::var("HOME").map_err(|_| "HOME is not set; cannot install pengine-cli")?; + Ok(PathBuf::from(home).join(".local/bin/pengine-cli")) + } + #[cfg(windows)] + { + let base = std::env::var("LOCALAPPDATA") + .map_err(|_| "LOCALAPPDATA is not set; cannot install pengine-cli")?; + Ok(PathBuf::from(base) + .join("Pengine") + .join("bin") + .join("pengine-cli.cmd")) + } +} + +fn shim_parent() -> Result { + shim_path().and_then(|p| { + p.parent() + .map(Path::to_path_buf) + .ok_or_else(|| "launcher path has no parent directory".to_string()) + }) +} + +fn path_export_hint(shim_dir: &Path) -> String { + #[cfg(unix)] + { + format!( + "Add to ~/.zshrc or ~/.bashrc: export PATH=\"$HOME/.local/bin:$PATH\" (launcher dir: {})", + shim_dir.display() + ) + } + #[cfg(windows)] + { + format!( + "Add this folder to your user PATH: {} (Settings → System → About → Advanced system settings → Environment Variables)", + shim_dir.display() + ) + } +} + +/// Shell-safe single-quoted string for `/bin/sh` `exec … "$@"`. +fn sh_single_quoted(path: &str) -> String { + let mut out = String::from("'"); + for ch in path.chars() { + if ch == '\'' { + out.push_str("'\\''"); + } else { + out.push(ch); + } + } + out.push('\''); + out +} + +fn parse_unix_launcher_target(body: &str) -> Option { + body.lines().find_map(|l| { + l.strip_prefix("# pengine-exe:") + .map(|s| s.trim().to_string()) + }) +} + +/// Inspect launcher file and PATH. +pub fn status() -> Result { + let shim = shim_path()?; + let shim_dir = shim_parent()?; + let local_bin_on_path = path_env_contains_dir(&shim_dir); + + let meta = fs::symlink_metadata(&shim).ok(); + let installed = meta.is_some(); + let resolves_to = if let Some(m) = &meta { + if m.file_type().is_symlink() { + fs::read_link(&shim).ok().map(|p| p.display().to_string()) + } else { + let body = fs::read_to_string(&shim).unwrap_or_default(); + #[cfg(windows)] + { + let line = body + .lines() + .find(|l| l.trim_start().starts_with("set \"PENGINE_EXE=")); + line.and_then(|l| { + l.trim() + .strip_prefix("set \"PENGINE_EXE=") + .and_then(|s| s.strip_suffix('"')) + .map(str::to_string) + }) + } + #[cfg(not(windows))] + { + parse_unix_launcher_target(&body) + } + } + } else { + None + }; + + Ok(CliShimStatus { + shim_path: shim.display().to_string(), + installed, + resolves_to, + local_bin_on_path, + path_export_hint: path_export_hint(&shim_dir), + }) +} + +/// Create `~/.local/bin/pengine-cli` (Unix shell script) or `%LOCALAPPDATA%\Pengine\bin\pengine-cli.cmd` (Windows). +pub fn install_shim() -> Result { + let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?; + let exe_display = exe.to_string_lossy(); + let shim = shim_path()?; + let parent = shim_parent()?; + fs::create_dir_all(&parent).map_err(|e| format!("create_dir_all {}: {e}", parent.display()))?; + + if fs::symlink_metadata(&shim).is_ok() { + fs::remove_file(&shim).map_err(|e| format!("remove old launcher: {e}"))?; + } + + #[cfg(unix)] + { + let body = format!( + "#!/bin/sh\n# pengine-exe:{}\nexport PENGINE_LAUNCH_MODE=cli\nexec {} \"$@\"\n", + exe_display, + sh_single_quoted(&exe_display) + ); + fs::write(&shim, body).map_err(|e| format!("write {}: {e}", shim.display()))?; + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&shim) + .map_err(|e| format!("metadata {}: {e}", shim.display()))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&shim, perms).map_err(|e| format!("chmod {}: {e}", shim.display()))?; + } + #[cfg(windows)] + { + write_windows_cmd_launcher(&shim, &exe)?; + } + + status() +} + +#[cfg(windows)] +fn write_windows_cmd_launcher(shim: &Path, exe: &Path) -> Result<(), String> { + let exe_str = exe.to_str().ok_or("exe path is not valid UTF-8")?; + let body = format!( + "@echo off\r\nset PENGINE_LAUNCH_MODE=cli\r\nset \"PENGINE_EXE={exe_str}\"\r\n\"%PENGINE_EXE%\" %*\r\n" + ); + fs::write(shim, body).map_err(|e| format!("write {}: {e}", shim.display())) +} + +/// Remove the launcher file only (never deletes the real app binary). +pub fn remove_shim() -> Result<(), String> { + let shim = shim_path()?; + if fs::symlink_metadata(&shim).is_ok() { + fs::remove_file(&shim).map_err(|e| format!("remove {}: {e}", shim.display()))?; + } + Ok(()) +} diff --git a/src-tauri/src/modules/cli/telegram_bridge.rs b/src-tauri/src/modules/cli/telegram_bridge.rs new file mode 100644 index 0000000..5256b07 --- /dev/null +++ b/src-tauri/src/modules/cli/telegram_bridge.rs @@ -0,0 +1,85 @@ +//! Telegram `$` prefix → same router + handlers as the REPL, rendered for Telegram. +//! +//! Policy (see `cli_plan.md` §10): lines whose trimmed text starts with `$` +//! are CLI intent; the rest of the line is classified by [`super::router`]. +//! Non-`$` messages stay on the normal [`crate::modules::agent::run_turn`] path. + +use super::dispatch::{dispatch_line, DispatchContext}; +use super::output::{CliReply, ReplyKind}; +use crate::modules::mcp::service as mcp_service; +use crate::shared::state::AppState; +use crate::shared::text::split_by_chars; + +/// Same UTF-16 safety rationale as `bot/service.rs::TELEGRAM_CHUNK_BUDGET`. +const TELEGRAM_CLI_CHUNK_BUDGET: usize = 2000; + +/// If `msg` is CLI intent (`$…`), returns the payload after `$` (may be empty). +/// Otherwise returns [`None`] so the caller should run the normal agent path. +pub fn strip_dollar_cli_payload(msg: &str) -> Option<&str> { + let trimmed = msg.trim_start(); + trimmed.strip_prefix('$').map(|rest| rest.trim_start()) +} + +/// Run one CLI-classified line for Telegram (MCP warmup + dispatch with Telegram rails). +pub async fn run_telegram_cli_line(state: &AppState, line_after_dollar: &str) -> CliReply { + if line_after_dollar.trim().is_empty() { + return CliReply::error( + "empty message after `$`; try e.g. `$ /help`, `$ /status`, or text without `$` for the agent.", + ); + } + if let Err(e) = mcp_service::rebuild_registry_into_state(state).await { + log::warn!("telegram cli: mcp warmup failed (continuing): {e}"); + } + dispatch_line(state, line_after_dollar, DispatchContext::telegram()).await +} + +/// Format a [`CliReply`] as Telegram message text, then chunk under the UTF-16-safe budget. +pub fn telegram_cli_reply_chunks(reply: &CliReply) -> Vec { + let formatted = format_reply_body_telegram(reply); + split_by_chars(&formatted, TELEGRAM_CLI_CHUNK_BUDGET) +} + +fn format_reply_body_telegram(reply: &CliReply) -> String { + match &reply.kind { + ReplyKind::Text => reply.body.clone(), + ReplyKind::Error => { + if reply.body.is_empty() { + "Error".to_string() + } else { + format!("Error:\n{}", reply.body) + } + } + ReplyKind::CodeBlock { lang } => fenced(lang, &reply.body), + ReplyKind::Log => fenced("bash", &reply.body), + ReplyKind::Diff => fenced("diff", &reply.body), + } +} + +fn fenced(lang: &str, body: &str) -> String { + format!("```{lang}\n{}\n```", body.trim_end()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::cli::output::CliReply; + + #[test] + fn strip_dollar_none_for_plain_agent() { + assert_eq!(strip_dollar_cli_payload("hello"), None); + } + + #[test] + fn strip_dollar_some_payload() { + assert_eq!(strip_dollar_cli_payload("$ /status"), Some("/status")); + assert_eq!(strip_dollar_cli_payload(" $ hello "), Some("hello ")); + } + + #[test] + fn telegram_chunks_fence_diff() { + let reply = CliReply::diff("+a\n-b\n"); + let chunks = telegram_cli_reply_chunks(&reply); + assert_eq!(chunks.len(), 1); + assert!(chunks[0].contains("```diff")); + } +} diff --git a/src-tauri/src/modules/cron/scheduler.rs b/src-tauri/src/modules/cron/scheduler.rs index bde4c38..186383a 100644 --- a/src-tauri/src/modules/cron/scheduler.rs +++ b/src-tauri/src/modules/cron/scheduler.rs @@ -1,7 +1,7 @@ use super::repository; use super::service; use super::types::{CronFile, CronJob}; -use crate::modules::bot::agent; +use crate::modules::agent; use crate::shared::state::AppState; use crate::shared::text::split_by_chars; use futures::FutureExt; diff --git a/src-tauri/src/modules/cron/types.rs b/src-tauri/src/modules/cron/types.rs index 1b3e82b..8102014 100644 --- a/src-tauri/src/modules/cron/types.rs +++ b/src-tauri/src/modules/cron/types.rs @@ -1,9 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -/// When a cron job fires. Kept intentionally small: `EveryMinutes` covers "every 10 -/// minutes / hourly / every 6 hours", `DailyAt` covers "once a day at HH:MM" in the -/// host's local timezone (the machine running the scheduler). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Schedule { @@ -18,8 +15,6 @@ pub struct CronJob { pub instruction: String, #[serde(default)] pub condition: String, - /// If non-empty, only these skills are injected into the system prompt for this job. - /// Empty means all enabled skills (default). #[serde(default)] pub skill_slugs: Vec, pub schedule: Schedule, diff --git a/src-tauri/src/modules/mod.rs b/src-tauri/src/modules/mod.rs index 9aaa732..720cc56 100644 --- a/src-tauri/src/modules/mod.rs +++ b/src-tauri/src/modules/mod.rs @@ -1,4 +1,6 @@ +pub mod agent; pub mod bot; +pub mod cli; pub mod cron; pub mod keywords; pub mod mcp; diff --git a/src-tauri/src/modules/skills/service.rs b/src-tauri/src/modules/skills/service.rs index d3972e6..e304855 100644 --- a/src-tauri/src/modules/skills/service.rs +++ b/src-tauri/src/modules/skills/service.rs @@ -211,7 +211,7 @@ pub const SKILL_HINT_BODY_CAP: usize = 2200; /// Default cap for the full skills fragment (intro + bodies + mandatory snippets), aligned with /// [`crate::shared::user_settings::DEFAULT_SKILLS_HINT_MAX_BYTES`]. The **runtime** limit is -/// [`crate::shared::state::AppState::skills_hint_max_bytes`] (see `bot::agent` turns). +/// [`crate::shared::state::AppState::skills_hint_max_bytes`] (see `modules::agent` turns). pub const DEFAULT_SKILL_HINT_BYTES: usize = crate::shared::user_settings::DEFAULT_SKILLS_HINT_MAX_BYTES as usize; diff --git a/src-tauri/src/shared/state.rs b/src-tauri/src/shared/state.rs index a092865..0139281 100644 --- a/src-tauri/src/shared/state.rs +++ b/src-tauri/src/shared/state.rs @@ -88,7 +88,7 @@ pub struct AppState { pub last_local_model: Arc>>, pub cached_filesystem_paths: Arc>>, pub tool_engine_mutex: Arc>, - /// Active memory-session recording (toggled by keyword commands; see `bot::agent`). + /// Active memory-session recording (toggled by keyword commands; see `modules::agent`). pub memory_session: Arc>>, /// Flat tool names last invoked by the model (FIFO, for routing next turns). pub recent_tool_names: Arc>>, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 74e6a95..b5518cf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "pengine", - "version": "1.0.2", + "productName": "Pengine", + "version": "1.0.3", "identifier": "com.maximedogawa.pengine", "build": { "beforeDevCommand": "bun run dev", @@ -10,17 +10,135 @@ "frontendDist": "../dist" }, "app": { - "windows": [ - { - "title": "pengine", - "width": 800, - "height": 600 - } - ], + "windows": [], "security": { "csp": null } }, + "plugins": { + "cli": { + "description": "Pengine — control Ollama, Telegram bot, and MCP tools from the terminal.", + "args": [ + { "name": "no-terminal", "description": "Disable terminal output" }, + { "name": "no-telegram", "description": "Disable Telegram output" }, + { "name": "json", "description": "Emit CliReply as versioned JSON" }, + { + "name": "shell", + "description": "Terminal-only: start the REPL with no args, or exit if stdin is not a TTY (never opens the GUI)" + } + ], + "subcommands": { + "app": { + "description": "Open the Pengine desktop window (separate process; use with a terminal `pengine` session)" + }, + "version": { + "description": "Print the Pengine version and git commit" + }, + "status": { + "description": "Show bot, Ollama, and MCP status" + }, + "config": { + "description": "Show or set user settings (e.g. skills_hint_max_bytes=12000)", + "args": [ + { + "name": "kv", + "description": "key=value pair; omit to list", + "takesValue": true, + "multiple": true, + "index": 1 + } + ] + }, + "model": { + "description": "Show or set the preferred Ollama model", + "args": [ + { + "name": "name", + "description": "Model name; pass --clear to unset", + "takesValue": true, + "index": 1 + }, + { "name": "clear", "description": "Clear the preference (use loaded model)" } + ] + }, + "bot": { + "description": "Connect or disconnect the Telegram bot", + "subcommands": { + "connect": { + "description": "Connect with a bot token", + "args": [ + { + "name": "token", + "description": "Telegram bot token", + "takesValue": true, + "index": 1 + } + ] + }, + "disconnect": { + "description": "Disconnect the Telegram bot" + } + } + }, + "tools": { + "description": "List MCP tools", + "args": [ + { + "name": "search", + "description": "Optional substring filter", + "takesValue": true, + "index": 1 + } + ] + }, + "skills": { + "description": "List / enable / disable skills", + "args": [ + { + "name": "action", + "description": "list | enable | disable", + "takesValue": true, + "index": 1 + }, + { + "name": "slug", + "description": "Skill slug for enable/disable", + "takesValue": true, + "index": 2 + } + ] + }, + "fs": { + "description": "Manage MCP filesystem roots", + "args": [ + { + "name": "action", + "description": "list | add | remove", + "takesValue": true, + "index": 1 + }, + { + "name": "path", + "description": "Absolute path for add/remove", + "takesValue": true, + "index": 2 + } + ] + }, + "logs": { + "description": "Stream log events", + "args": [ + { "name": "follow", "description": "Follow new events", "short": "f" }, + { "name": "tail", "description": "Print the last N events", "takesValue": true } + ] + }, + "ask": { + "description": "Send a message to the agent (AI path)", + "args": [{ "name": "text", "description": "The prompt", "takesValue": true, "index": 1 }] + } + } + } + }, "bundle": { "active": true, "targets": "all", diff --git a/src-tauri/tests/cli_oneshot.rs b/src-tauri/tests/cli_oneshot.rs new file mode 100644 index 0000000..105896c --- /dev/null +++ b/src-tauri/tests/cli_oneshot.rs @@ -0,0 +1,84 @@ +//! Spawns the real `pengine` binary to guard CLI short-circuit + exit codes. +//! +//! Run: `cargo test --manifest-path src-tauri/Cargo.toml --test cli_oneshot` +//! (or `cd src-tauri && cargo test --test cli_oneshot`). + +use std::path::PathBuf; +use std::process::Command; + +fn pengine_exe() -> PathBuf { + if let Some(p) = std::env::var_os("CARGO_BIN_EXE_pengine") { + return PathBuf::from(p); + } + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + #[cfg(target_os = "windows")] + { + dir.join("target").join("debug").join("pengine.exe") + } + #[cfg(not(target_os = "windows"))] + { + dir.join("target").join("debug").join("pengine") + } +} + +fn pengine() -> Command { + let exe = pengine_exe(); + assert!( + exe.exists(), + "pengine test binary missing at {} — run `cargo build --manifest-path src-tauri/Cargo.toml` first", + exe.display() + ); + Command::new(exe) +} + +#[test] +fn version_exits_zero_with_stdout() { + let out = pengine() + .arg("version") + .output() + .expect("spawn pengine version"); + assert!( + out.status.success(), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("pengine") && stdout.contains('('), + "unexpected stdout: {stdout:?}" + ); +} + +#[test] +fn help_exits_zero() { + let out = pengine().arg("help").output().expect("spawn pengine help"); + assert!( + out.status.success(), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("Pengine CLI"), + "unexpected stdout: {stdout:?}" + ); +} + +#[test] +fn json_status_exits_zero_when_global_json_first() { + let out = pengine() + .args(["--json", "status"]) + .output() + .expect("spawn pengine --json status"); + assert!( + out.status.success(), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + let line = stdout.lines().next().unwrap_or(""); + assert!( + line.starts_with("{\"v\":1,") && line.contains("\"reply\""), + "expected one JSON envelope line, got: {line:?}" + ); +} diff --git a/src/modules/bot/api/index.ts b/src/modules/bot/api/index.ts index 4e6d208..80e64d3 100644 --- a/src/modules/bot/api/index.ts +++ b/src/modules/bot/api/index.ts @@ -12,8 +12,7 @@ export const PENGINE = { export async function auditListFiles(): Promise { try { return await invoke("audit_list_files"); - } catch (err) { - console.error("[auditListFiles] invoke failed", err); + } catch { return null; } } @@ -21,8 +20,7 @@ export async function auditListFiles(): Promise { export async function auditReadFile(date: string): Promise { try { return await invoke("audit_read_file", { date }); - } catch (err) { - console.error("[auditReadFile] invoke failed", { date, err }); + } catch { return null; } } @@ -31,8 +29,7 @@ export async function auditDeleteFile(date: string): Promise { try { await invoke("audit_delete_file", { date }); return true; - } catch (err) { - console.error("[auditDeleteFile] invoke failed", { date, err }); + } catch { return false; } } diff --git a/src/modules/cli/api/index.ts b/src/modules/cli/api/index.ts new file mode 100644 index 0000000..fba82a7 --- /dev/null +++ b/src/modules/cli/api/index.ts @@ -0,0 +1,32 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { CliShimStatus } from "../types"; + +export async function cliShimStatus(): Promise { + try { + return await invoke("cli_shim_status"); + } catch { + return null; + } +} + +export async function cliShimInstall(): Promise< + { ok: true; status: CliShimStatus } | { ok: false; error: string } +> { + try { + const status = await invoke("cli_shim_install"); + return { ok: true, status }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { ok: false, error: msg }; + } +} + +export async function cliShimRemove(): Promise<{ ok: true } | { ok: false; error: string }> { + try { + await invoke("cli_shim_remove"); + return { ok: true }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { ok: false, error: msg }; + } +} diff --git a/src/modules/cli/components/CliCommandsPanel.tsx b/src/modules/cli/components/CliCommandsPanel.tsx new file mode 100644 index 0000000..fbee78f --- /dev/null +++ b/src/modules/cli/components/CliCommandsPanel.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from "react"; +import { isTauriApp } from "../../../shared/runtimeTarget"; +import { cliShimInstall, cliShimRemove, cliShimStatus } from "../api"; +import type { CliShimStatus } from "../types"; + +export function CliCommandsPanel() { + const [shim, setShim] = useState(null); + const [shimError, setShimError] = useState(null); + const [shimBusy, setShimBusy] = useState(false); + const [shimMsg, setShimMsg] = useState(null); + + const refreshShim = useCallback(async () => { + if (!isTauriApp()) { + setShim(null); + setShimError(null); + return; + } + setShimError(null); + const s = await cliShimStatus(); + if (s === null) { + setShim(null); + setShimError("Could not read CLI launcher status (invoke failed)."); + return; + } + setShim(s); + }, []); + + useEffect(() => { + void refreshShim(); + }, [refreshShim]); + + const setLauncherEnabled = async (enabled: boolean) => { + if (!shim || shimBusy) return; + setShimBusy(true); + setShimMsg(null); + if (enabled) { + const r = await cliShimInstall(); + setShimBusy(false); + if (r.ok) { + setShim(r.status); + setShimMsg( + r.status.localBinOnPath + ? "On PATH. In a new terminal: pengine-cli or pengine-cli app" + : "Installed. Add the folder to PATH (see hint below), then: pengine-cli or pengine-cli app", + ); + } else { + setShimMsg(r.error); + } + } else { + const r = await cliShimRemove(); + setShimBusy(false); + if (r.ok) { + await refreshShim(); + setShimMsg("CLI launcher removed from PATH."); + } else { + setShimMsg(r.error); + } + } + }; + + return ( +
+

Terminal CLI

+ + {isTauriApp() && ( +
+
+
+

CLI on PATH

+

+ {shim?.installed ? "Installed" : "Not installed"} — adds or removes the{" "} + pengine-cli launcher file. +

+
+ +
+ + {shimError &&

{shimError}

} + {!shim && !shimError && ( +

Loading…

+ )} + {shim && ( + <> +

+ Launcher file: {shim.shimPath} +

+ {shim.installed && shim.resolvesTo && ( +

+ → {shim.resolvesTo} +

+ )} + {!shim.localBinOnPath && ( +

+ {shim.pathExportHint} +

+ )} + + )} + {shimMsg &&

{shimMsg}

} +
+ )} + + {!isTauriApp() && ( +

+ Open this dashboard in the desktop app to turn + CLI on PATH on or off. +

+ )} +
+ ); +} diff --git a/src/modules/cli/index.ts b/src/modules/cli/index.ts new file mode 100644 index 0000000..1946f58 --- /dev/null +++ b/src/modules/cli/index.ts @@ -0,0 +1,3 @@ +export { CliCommandsPanel } from "./components/CliCommandsPanel"; +export { cliShimInstall, cliShimRemove, cliShimStatus } from "./api"; +export type { CliShimStatus } from "./types"; diff --git a/src/modules/cli/types.ts b/src/modules/cli/types.ts new file mode 100644 index 0000000..283a2ce --- /dev/null +++ b/src/modules/cli/types.ts @@ -0,0 +1,8 @@ +/** Tauri `cli_shim_*` (writes `pengine-cli` launcher) — serde uses camelCase. */ +export type CliShimStatus = { + shimPath: string; + installed: boolean; + resolvesTo: string | null; + localBinOnPath: boolean; + pathExportHint: string; +}; diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index e22069d..8d2e647 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -6,6 +6,7 @@ import { CronPanel } from "../modules/cron"; import { McpToolsPanel } from "../modules/mcp/components/McpToolsPanel"; import { fetchOllamaModels, setPreferredOllamaModel } from "../modules/ollama/api"; import type { OllamaModelInfo } from "../modules/ollama/types"; +import { CliCommandsPanel } from "../modules/cli"; import { SkillsPanel } from "../modules/skills"; import { ToolEnginePanel } from "../modules/toolengine/components/ToolEnginePanel"; import { UpdateIndicator } from "../modules/updater"; @@ -238,6 +239,11 @@ export function DashboardPage() { + {/* ── Terminal CLI (PATH launcher toggle) ───────────────── */} +
+ +
+ {/* ── Saved audit files (disk) — separate from live stream ─ */}
From b42313a6c4e850886113294b7614d7bbe828055d Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Fri, 24 Apr 2026 02:45:40 +0200 Subject: [PATCH 02/23] fix: correct syntax in dispatch_native function for command handling - Fixed a syntax error in the dispatch_native function by removing an unnecessary comma, ensuring proper command handling in the CLI. --- src-tauri/src/modules/cli/dispatch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/modules/cli/dispatch.rs b/src-tauri/src/modules/cli/dispatch.rs index e1278b4..e2b4514 100644 --- a/src-tauri/src/modules/cli/dispatch.rs +++ b/src-tauri/src/modules/cli/dispatch.rs @@ -138,7 +138,7 @@ async fn dispatch_native( ), Err(e) => CliReply::error(e), } - }, + } "exit" | "quit" => CliReply::text("bye."), other => CliReply::error(format!("unknown command: /{other}")), } From abfca3e718ca54d934236f2e953b1239523e5586 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Fri, 24 Apr 2026 03:12:26 +0200 Subject: [PATCH 03/23] refactor: enhance CLI output and syntax highlighting capabilities - Updated the TerminalPreview component to improve log display. - Simplified messages in the CliCommandsPanel for clarity. - Introduced a new syntax highlighting module for better code block rendering in CLI. - Enhanced the output rendering logic to support highlighted code blocks. - Updated Cargo dependencies to include new libraries for syntax highlighting. --- src-tauri/Cargo.lock | 39 +++++ src-tauri/Cargo.toml | 1 + src-tauri/src/modules/cli/commands.rs | 2 +- src-tauri/src/modules/cli/handlers.rs | 142 ++++++++++++++---- src-tauri/src/modules/cli/mod.rs | 1 + src-tauri/src/modules/cli/output.rs | 140 +++++++++++++---- src-tauri/src/modules/cli/shim.rs | 7 +- src-tauri/src/modules/cli/syntax_highlight.rs | 83 ++++++++++ src-tauri/src/modules/ollama/service.rs | 44 ++++++ src-tauri/tauri.conf.json | 4 +- .../bot/components/TerminalPreview.tsx | 2 +- .../cli/components/CliCommandsPanel.tsx | 42 ++---- 12 files changed, 414 insertions(+), 93 deletions(-) create mode 100644 src-tauri/src/modules/cli/syntax_highlight.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dc58a3d..4636c04 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -392,6 +392,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -1338,6 +1347,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3283,6 +3303,7 @@ dependencies = [ "serde", "serde_json", "socket2 0.5.10", + "syntect", "tauri", "tauri-build", "tauri-plugin-cli", @@ -4912,6 +4933,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror 2.0.18", + "walkdir", +] + [[package]] name = "system-configuration" version = "0.7.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 259a9a3..5686e6c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,6 +40,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] } futures = "0.3.31" regex = "1" tempfile = "3" +syntect = { version = "5", default-features = false, features = ["default-themes", "default-syntaxes", "regex-fancy"] } [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3" diff --git a/src-tauri/src/modules/cli/commands.rs b/src-tauri/src/modules/cli/commands.rs index 211f6ed..2ca90e7 100644 --- a/src-tauri/src/modules/cli/commands.rs +++ b/src-tauri/src/modules/cli/commands.rs @@ -38,7 +38,7 @@ pub const COMMANDS: &[NativeCommand] = &[ }, NativeCommand { name: "model", - summary: "Show or set the preferred Ollama model.", + summary: "List Ollama models; set preferred by name, or by # (loads model as daemon active); --clear.", }, NativeCommand { name: "bot", diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index 4400d79..eb44914 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -13,7 +13,7 @@ use crate::infrastructure::bot_lifecycle; use crate::modules::agent; use crate::modules::bot::{repository as bot_repo, token_verify}; use crate::modules::mcp::service as mcp_service; -use crate::modules::ollama::service as ollama; +use crate::modules::ollama::service::{self as ollama, ModelInfo}; use crate::modules::secure_store; use crate::modules::skills::service as skills_service; use crate::shared::state::{AppState, ConnectionData, ConnectionMetadata, LogEntry}; @@ -138,44 +138,117 @@ pub async fn config(state: &AppState, kvs: &[String]) -> CliReply { CliReply::code("bash", format!("updated: {}", applied.join(", "))) } -/// `model` — show (no args) or set the preferred Ollama model. +/// If `token` is all ASCII digits and parses to `1..=len`, returns a **0-based** index. +fn model_catalog_index_token(token: &str, len: usize) -> Option { + if len == 0 || token.is_empty() || !token.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let n: usize = token.parse().ok()?; + if n >= 1 && n <= len { + Some(n - 1) + } else { + None + } +} + +fn format_model_catalog_list( + catalog: &ollama::ModelCatalog, + preferred: Option<&str>, +) -> String { + let n = catalog.models.len(); + let pref_s = preferred.unwrap_or(""); + let active_s = catalog + .active + .as_deref() + .unwrap_or(""); + let mut out = format!( + "ollama models ({n}): preferred={pref_s} daemon_active={active_s}\n", + ); + if n == 0 { + out.push_str("(no models returned — is `ollama serve` running?)\n"); + } else { + for (i, m) in catalog.models.iter().enumerate() { + let mut tags: Vec<&'static str> = Vec::new(); + if catalog.active.as_deref() == Some(m.name.as_str()) { + tags.push("active"); + } + if preferred == Some(m.name.as_str()) { + tags.push("preferred"); + } + let tag = if tags.is_empty() { + String::new() + } else { + format!(" [{}]", tags.join(", ")) + }; + out.push_str(&format!( + " {:>3} {} ({}){tag}\n", + i + 1, + m.name, + m.kind.as_str(), + )); + } + } + out.push_str("\nSet preferred: /model (same as `pengine model …`)\n"); + out.push_str("Set preferred + load in Ollama: /model <#> (1-based row from this list)\n"); + out.push_str("Clear: /model --clear"); + out +} + +async fn apply_preferred_model(state: &AppState, entry: &ModelInfo) -> CliReply { + let name = entry.name.as_str(); + *state.preferred_ollama_model.write().await = Some(name.to_string()); + if entry.kind == ollama::ModelKind::Local { + *state.last_local_model.write().await = Some(name.to_string()); + } + state + .emit_log("run", &format!("ollama model set to '{name}' (cli)")) + .await; + CliReply::code("bash", format!("preferred model set to {name}")) +} + +/// `model` — list models (no args), set preferred by **name** or **1-based #** from the list, or `--clear`. +/// Selecting by **#** also asks Ollama to load that model so it becomes **daemon active** (`/api/ps`). /// Mirrors the validation in `handle_ollama_model_put` in `http_server.rs`. pub async fn model(state: &AppState, name: Option<&str>, clear: bool) -> CliReply { if clear { *state.preferred_ollama_model.write().await = None; return CliReply::code("bash", "preferred model cleared (uses active model)"); } - let Some(name) = name else { - let preferred = state - .preferred_ollama_model - .read() - .await - .clone() - .unwrap_or_else(|| "".to_string()); - let active = ollama::active_model() - .await - .unwrap_or_else(|e| format!("")); - return CliReply::code("bash", format!("preferred={preferred}\nactive={active}")); - }; - let name = name.trim(); - if name.is_empty() { - return CliReply::error("model: name is empty (use --clear to unset)"); - } let catalog = match ollama::model_catalog(3000).await { Ok(c) => c, Err(e) => return CliReply::error(format!("ollama catalog: {e}")), }; - let Some(entry) = catalog.models.iter().find(|m| m.name == name) else { - return CliReply::error(format!("model `{name}` is not available in Ollama")); + let preferred = state.preferred_ollama_model.read().await.clone(); + let preferred_ref = preferred.as_deref(); + + let Some(name) = name.map(str::trim).filter(|s| !s.is_empty()) else { + let body = format_model_catalog_list(&catalog, preferred_ref); + return CliReply::code("bash", body); }; - *state.preferred_ollama_model.write().await = Some(name.to_string()); - if entry.kind == ollama::ModelKind::Local { - *state.last_local_model.write().await = Some(name.to_string()); + + let (entry, activate_in_ollama) = + if let Some(idx) = model_catalog_index_token(name, catalog.models.len()) { + (&catalog.models[idx], true) + } else if let Some(e) = catalog.models.iter().find(|m| m.name == name) { + (e, false) + } else { + return CliReply::error(format!("model `{name}` is not available in Ollama")); + }; + + if activate_in_ollama { + if let Err(e) = ollama::touch_activate_model(entry.name.as_str()).await { + return CliReply::error(format!( + "ollama: could not load model `{}` as daemon active: {e}", + entry.name + )); + } } - state - .emit_log("run", &format!("ollama model set to '{name}' (cli)")) - .await; - CliReply::code("bash", format!("preferred model set to {name}")) + + let mut reply = apply_preferred_model(state, entry).await; + if activate_in_ollama { + reply.body.push_str("\nollama: model loaded (daemon active in /api/ps)"); + } + reply } /// `bot connect ` — verify, persist, save keychain. Does NOT spawn the @@ -657,4 +730,19 @@ mod tests { assert!(format_audit_ndjson_line("not-json").is_none()); assert!(format_audit_ndjson_line("").is_none()); } + + #[test] + fn model_catalog_index_token_parses_one_based() { + assert_eq!(super::model_catalog_index_token("1", 3), Some(0)); + assert_eq!(super::model_catalog_index_token("3", 3), Some(2)); + assert_eq!(super::model_catalog_index_token("0", 3), None); + assert_eq!(super::model_catalog_index_token("4", 3), None); + assert_eq!(super::model_catalog_index_token("02", 3), Some(1)); + } + + #[test] + fn model_catalog_index_token_rejects_non_digits() { + assert_eq!(super::model_catalog_index_token("llama3", 3), None); + assert_eq!(super::model_catalog_index_token("1a", 3), None); + } } diff --git a/src-tauri/src/modules/cli/mod.rs b/src-tauri/src/modules/cli/mod.rs index 9a43f5e..fad2272 100644 --- a/src-tauri/src/modules/cli/mod.rs +++ b/src-tauri/src/modules/cli/mod.rs @@ -22,4 +22,5 @@ pub mod output; pub mod repl; pub mod router; pub mod shim; +pub mod syntax_highlight; pub mod telegram_bridge; diff --git a/src-tauri/src/modules/cli/output.rs b/src-tauri/src/modules/cli/output.rs index f6cbc72..2167331 100644 --- a/src-tauri/src/modules/cli/output.rs +++ b/src-tauri/src/modules/cli/output.rs @@ -4,9 +4,10 @@ //! chunking) belongs to sinks. This keeps handlers transport-agnostic and //! lets a single change to `TelegramSink` affect every reply at once. +use regex::Regex; use serde::Serialize; use std::io::{IsTerminal, Write}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use tokio::sync::Mutex as AsyncMutex; use tokio::task::JoinHandle; @@ -121,8 +122,22 @@ impl OutputSink for TerminalSink { eprintln!("{}", reply.body); } } - ReplyKind::CodeBlock { .. } | ReplyKind::Log => { - // Print raw — terminal rendering speaks for itself without fences. + ReplyKind::CodeBlock { lang } => { + if self.color { + if let Some(lines) = + super::syntax_highlight::highlight_fence_body(lang, &reply.body) + { + for line in &lines { + println!("{line}\x1b[0m"); + } + } else { + println!("{}", reply.body); + } + } else { + println!("{}", reply.body); + } + } + ReplyKind::Log => { println!("{}", reply.body); } ReplyKind::Diff => { @@ -211,13 +226,58 @@ fn render_reply_indented(sink: &dyn OutputSink, reply: &CliReply) { } else { FirstPrefix::None }; - render_with_prefix(sink, part, prefix); + match &part.kind { + ReplyKind::CodeBlock { .. } => { + try_render_highlighted_code_block(sink, part, prefix); + } + _ => render_with_prefix(sink, part, prefix), + } } } + ReplyKind::CodeBlock { .. } => { + try_render_highlighted_code_block(sink, reply, FirstPrefix::Repl); + } _ => render_with_prefix(sink, reply, FirstPrefix::Repl), } } +/// When stdout is a TTY, paint fenced code with a dark theme; otherwise indent as plain text. +fn try_render_highlighted_code_block( + sink: &dyn OutputSink, + reply: &CliReply, + first: FirstPrefix, +) { + let ReplyKind::CodeBlock { lang } = &reply.kind else { + render_with_prefix(sink, reply, first); + return; + }; + if is_terminal_stdout() { + if let Some(lines) = super::syntax_highlight::highlight_fence_body(lang, &reply.body) { + print_highlighted_lines_prefixed(&lines, first); + return; + } + } + render_with_prefix(sink, reply, first); +} + +fn print_highlighted_lines_prefixed(lines: &[String], first: FirstPrefix) { + let color = is_terminal_stdout(); + let (first_p, cont_p) = match first { + FirstPrefix::Repl => { + if color { + (REPL_FIRST_PREFIX, REPL_CONT_PREFIX) + } else { + (REPL_FIRST_PREFIX_PLAIN, REPL_CONT_PREFIX) + } + } + FirstPrefix::None => (REPL_CONT_PREFIX, REPL_CONT_PREFIX), + }; + for (i, line) in lines.iter().enumerate() { + let p = if i == 0 { first_p } else { cont_p }; + println!("{p}{line}\x1b[0m"); + } +} + #[derive(Clone, Copy)] enum FirstPrefix { /// Indent the first line with ` ⎿ ` (or plain equivalent if no TTY). @@ -267,35 +327,43 @@ fn indent_body(body: &str, first_prefix: &str, cont_prefix: &str) -> String { out } -/// Pull `` ```diff\n…\n``` `` blocks out of a text body. Surrounding text -/// stays as `Text` replies. Missing closers or no fences → single `Text`. +static MD_FENCE_RE: OnceLock = OnceLock::new(); + +fn md_fence_regex() -> &'static Regex { + MD_FENCE_RE.get_or_init(|| { + Regex::new(r"```\s*([^\n`]*?)\s*\n([\s\S]*?)```").expect("markdown fence regex") + }) +} + +/// Pull `` ```lang\n…\n``` `` blocks out of a text body. `lang == diff` (case-insensitive) +/// becomes [`ReplyKind::Diff`]; other languages become [`ReplyKind::CodeBlock`]. +/// Unclosed fences are left in the trailing `Text`. No fences → single `Text`. pub fn split_text_into_blocks(body: &str) -> Vec { - const OPEN: &str = "```diff\n"; - const CLOSE: &str = "\n```"; let mut out = Vec::new(); - let mut rest = body; - while let Some(open_idx) = rest.find(OPEN) { - let before = &rest[..open_idx]; - let trimmed_before = before.trim_matches('\n'); - if !trimmed_before.is_empty() { - out.push(CliReply::text(trimmed_before.to_string())); - } - let after_open = &rest[open_idx + OPEN.len()..]; - match after_open.find(CLOSE) { - Some(close_idx) => { - let inner = &after_open[..close_idx]; - out.push(CliReply::diff(inner.to_string())); - rest = &after_open[close_idx + CLOSE.len()..]; - } - None => { - // unterminated fence — keep whatever came after open as diff - out.push(CliReply::diff(after_open.to_string())); - rest = ""; - break; + let re = md_fence_regex(); + let mut last = 0usize; + for cap in re.captures_iter(body) { + let m = cap.get(0).expect("regex capture 0"); + if m.start() > last { + let chunk = body[last..m.start()].trim_matches('\n'); + if !chunk.is_empty() { + out.push(CliReply::text(chunk.to_string())); } } + let lang = cap.get(1).map(|g| g.as_str().trim()).unwrap_or(""); + let inner = cap + .get(2) + .map(|g| g.as_str()) + .unwrap_or("") + .trim_matches('\n'); + if lang.eq_ignore_ascii_case("diff") { + out.push(CliReply::diff(inner.to_string())); + } else { + out.push(CliReply::code(lang.to_string(), inner.to_string())); + } + last = m.end(); } - let tail = rest.trim_matches('\n'); + let tail = body[last..].trim_matches('\n'); if !tail.is_empty() { out.push(CliReply::text(tail.to_string())); } @@ -515,6 +583,22 @@ mod tests { assert!(matches!(parts[0].kind, ReplyKind::Text)); } + #[test] + fn split_text_pulls_rust_fence_out() { + let body = "intro\n```rust\nfn main() {}\n```\ntrailer"; + let parts = split_text_into_blocks(body); + assert_eq!(parts.len(), 3); + assert!(matches!(parts[0].kind, ReplyKind::Text)); + assert_eq!(parts[0].body, "intro"); + match &parts[1].kind { + ReplyKind::CodeBlock { lang } => assert_eq!(lang, "rust"), + _ => panic!("expected code block"), + } + assert_eq!(parts[1].body, "fn main() {}"); + assert!(matches!(parts[2].kind, ReplyKind::Text)); + assert_eq!(parts[2].body, "trailer"); + } + #[test] fn indent_body_prefixes_first_and_continuation() { let out = indent_body("one\ntwo\nthree", " ⎿ ", " "); diff --git a/src-tauri/src/modules/cli/shim.rs b/src-tauri/src/modules/cli/shim.rs index cc56a5b..6950fc8 100644 --- a/src-tauri/src/modules/cli/shim.rs +++ b/src-tauri/src/modules/cli/shim.rs @@ -72,16 +72,13 @@ fn path_export_hint(shim_dir: &Path) -> String { #[cfg(unix)] { format!( - "Add to ~/.zshrc or ~/.bashrc: export PATH=\"$HOME/.local/bin:$PATH\" (launcher dir: {})", + "export PATH=\"{}:$PATH\" · add once to ~/.zshrc or ~/.bashrc", shim_dir.display() ) } #[cfg(windows)] { - format!( - "Add this folder to your user PATH: {} (Settings → System → About → Advanced system settings → Environment Variables)", - shim_dir.display() - ) + format!("Add to user PATH: {}", shim_dir.display()) } } diff --git a/src-tauri/src/modules/cli/syntax_highlight.rs b/src-tauri/src/modules/cli/syntax_highlight.rs new file mode 100644 index 0000000..8ab421f --- /dev/null +++ b/src-tauri/src/modules/cli/syntax_highlight.rs @@ -0,0 +1,83 @@ +//! Optional 24-bit ANSI highlighting for CLI / REPL fenced code (dark theme). + +use std::sync::OnceLock; +use syntect::easy::HighlightLines; +use syntect::highlighting::ThemeSet; +use syntect::parsing::{SyntaxReference, SyntaxSet}; +use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings}; + +struct HighlightEngine { + syntax_set: SyntaxSet, + theme_set: ThemeSet, +} + +fn engine() -> &'static HighlightEngine { + static E: OnceLock = OnceLock::new(); + E.get_or_init(|| HighlightEngine { + syntax_set: SyntaxSet::load_defaults_newlines(), + theme_set: ThemeSet::load_defaults(), + }) +} + +fn dark_theme(ts: &ThemeSet) -> &syntect::highlighting::Theme { + const PREFERRED: &[&str] = &[ + "base16-ocean.dark", + "base16-mocha.dark", + "Solarized (dark)", + "InspiredGitHub", + ]; + for key in PREFERRED { + if let Some(t) = ts.themes.get(*key) { + return t; + } + } + ts.themes + .values() + .next() + .expect("syntect embeds default themes") +} + +fn resolve_syntax<'a>(ss: &'a SyntaxSet, lang: &str) -> &'a SyntaxReference { + let l = lang.trim(); + if l.is_empty() { + return ss.find_syntax_plain_text(); + } + ss.find_syntax_by_extension(l) + .or_else(|| ss.find_syntax_by_token(l)) + .unwrap_or_else(|| ss.find_syntax_plain_text()) +} + +/// One element per source line (no embedded `\n`), with 24-bit ANSI sequences. +/// Returns `None` if highlighting fails so callers can fall back to plain text. +pub fn highlight_fence_body(lang: &str, code: &str) -> Option> { + let eng = engine(); + let syntax = resolve_syntax(&eng.syntax_set, lang); + let theme = dark_theme(&eng.theme_set); + let mut h = HighlightLines::new(syntax, theme); + let mut lines = Vec::new(); + for line in LinesWithEndings::from(code) { + let regions = h.highlight_line(line, &eng.syntax_set).ok()?; + let escaped = as_24_bit_terminal_escaped(®ions[..], true); + lines.push(trim_line_ending(&escaped)); + } + Some(lines) +} + +fn trim_line_ending(s: &str) -> String { + s.trim_end_matches(['\n', '\r']).to_string() +} + +#[cfg(test)] +mod tests { + use super::highlight_fence_body; + + #[test] + fn highlight_rust_emits_ansi() { + let lines = highlight_fence_body("rust", "fn main() {}\n").expect("highlight"); + let joined = lines.join("\n"); + assert!( + joined.contains('\x1b'), + "expected 24-bit ansi escapes: {joined:?}" + ); + } +} diff --git a/src-tauri/src/modules/ollama/service.rs b/src-tauri/src/modules/ollama/service.rs index 3306bf5..7257962 100644 --- a/src-tauri/src/modules/ollama/service.rs +++ b/src-tauri/src/modules/ollama/service.rs @@ -197,6 +197,50 @@ pub async fn active_model() -> Result { .ok_or_else(|| "no models pulled in ollama".to_string()) } +/// Load `model` in the Ollama daemon so `/api/ps` reports it (same as a first chat turn). +/// +/// Uses a minimal `/api/chat` request with `num_predict: 1`. Large models may take +/// minutes on first load; timeout is generous. +pub async fn touch_activate_model(model: &str) -> Result<(), String> { + let payload = serde_json::json!({ + "model": model, + "messages": [{"role": "user", "content": " "}], + "stream": false, + "keep_alive": "30m", + "options": { + "num_predict": 1, + "num_ctx": 2048 + } + }); + let timeout = std::time::Duration::from_secs(300); + let resp = http_client() + .post(OLLAMA_CHAT_URL) + .json(&payload) + .timeout(timeout) + .send() + .await + .map_err(|e| e.to_string())?; + let status = resp.status(); + let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + if !status.is_success() { + let err = body + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or(""); + return Err(if err.is_empty() { + format!("ollama chat HTTP {status}") + } else { + format!("ollama chat HTTP {status}: {err}") + }); + } + if let Some(err) = body.get("error").and_then(|v| v.as_str()) { + if !err.is_empty() { + return Err(err.to_string()); + } + } + Ok(()) +} + /// Detect cloud-side failures that warrant downgrading to a local model. /// Covers explicit rate limits (429 / "rate limit" / "quota"), upstream /// outages proxied as 5xx with the cloud's `ref: ` envelope, and the diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b5518cf..c0330ab 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -50,11 +50,11 @@ ] }, "model": { - "description": "Show or set the preferred Ollama model", + "description": "List Ollama models; set preferred by name, or by 1-based index (also loads that model in Ollama); --clear", "args": [ { "name": "name", - "description": "Model name; pass --clear to unset", + "description": "Model name, or 1-based row # from `pengine model` (loads in Ollama); --clear to unset", "takesValue": true, "index": 1 }, diff --git a/src/modules/bot/components/TerminalPreview.tsx b/src/modules/bot/components/TerminalPreview.tsx index f26811a..33e7a47 100644 --- a/src/modules/bot/components/TerminalPreview.tsx +++ b/src/modules/bot/components/TerminalPreview.tsx @@ -127,7 +127,7 @@ export function TerminalPreview() { -

pengine runtime

+ Log {marketingSite ? "demo" : "live"} diff --git a/src/modules/cli/components/CliCommandsPanel.tsx b/src/modules/cli/components/CliCommandsPanel.tsx index fbee78f..a4fd0b3 100644 --- a/src/modules/cli/components/CliCommandsPanel.tsx +++ b/src/modules/cli/components/CliCommandsPanel.tsx @@ -38,11 +38,7 @@ export function CliCommandsPanel() { setShimBusy(false); if (r.ok) { setShim(r.status); - setShimMsg( - r.status.localBinOnPath - ? "On PATH. In a new terminal: pengine-cli or pengine-cli app" - : "Installed. Add the folder to PATH (see hint below), then: pengine-cli or pengine-cli app", - ); + setShimMsg(r.status.localBinOnPath ? "On PATH." : "Installed."); } else { setShimMsg(r.error); } @@ -51,7 +47,7 @@ export function CliCommandsPanel() { setShimBusy(false); if (r.ok) { await refreshShim(); - setShimMsg("CLI launcher removed from PATH."); + setShimMsg("Removed."); } else { setShimMsg(r.error); } @@ -65,13 +61,9 @@ export function CliCommandsPanel() { {isTauriApp() && (
-
-

CLI on PATH

-

- {shim?.installed ? "Installed" : "Not installed"} — adds or removes the{" "} - pengine-cli launcher file. -

-
+

+ CLI on PATH +

)} {!isTauriApp() && ( -

- Open this dashboard in the desktop app to turn - CLI on PATH on or off. +

+ Use the desktop app for this toggle.

)}
From f75e0f5e3d1d8202ebbac84271fb78878045b44f Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Fri, 24 Apr 2026 03:12:41 +0200 Subject: [PATCH 04/23] refactor: streamline code formatting and output handling in CLI modules - Simplified the formatting logic in the model catalog list function for improved readability. - Enhanced the output handling in the model loading response to maintain consistent formatting. - Cleaned up unnecessary line breaks and improved code structure for better maintainability. --- src-tauri/src/modules/cli/handlers.rs | 18 ++++++------------ src-tauri/src/modules/cli/output.rs | 6 +----- src-tauri/src/modules/ollama/service.rs | 5 +---- 3 files changed, 8 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index eb44914..249be6c 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -151,19 +151,11 @@ fn model_catalog_index_token(token: &str, len: usize) -> Option { } } -fn format_model_catalog_list( - catalog: &ollama::ModelCatalog, - preferred: Option<&str>, -) -> String { +fn format_model_catalog_list(catalog: &ollama::ModelCatalog, preferred: Option<&str>) -> String { let n = catalog.models.len(); let pref_s = preferred.unwrap_or(""); - let active_s = catalog - .active - .as_deref() - .unwrap_or(""); - let mut out = format!( - "ollama models ({n}): preferred={pref_s} daemon_active={active_s}\n", - ); + let active_s = catalog.active.as_deref().unwrap_or(""); + let mut out = format!("ollama models ({n}): preferred={pref_s} daemon_active={active_s}\n",); if n == 0 { out.push_str("(no models returned — is `ollama serve` running?)\n"); } else { @@ -246,7 +238,9 @@ pub async fn model(state: &AppState, name: Option<&str>, clear: bool) -> CliRepl let mut reply = apply_preferred_model(state, entry).await; if activate_in_ollama { - reply.body.push_str("\nollama: model loaded (daemon active in /api/ps)"); + reply + .body + .push_str("\nollama: model loaded (daemon active in /api/ps)"); } reply } diff --git a/src-tauri/src/modules/cli/output.rs b/src-tauri/src/modules/cli/output.rs index 2167331..dce9c78 100644 --- a/src-tauri/src/modules/cli/output.rs +++ b/src-tauri/src/modules/cli/output.rs @@ -242,11 +242,7 @@ fn render_reply_indented(sink: &dyn OutputSink, reply: &CliReply) { } /// When stdout is a TTY, paint fenced code with a dark theme; otherwise indent as plain text. -fn try_render_highlighted_code_block( - sink: &dyn OutputSink, - reply: &CliReply, - first: FirstPrefix, -) { +fn try_render_highlighted_code_block(sink: &dyn OutputSink, reply: &CliReply, first: FirstPrefix) { let ReplyKind::CodeBlock { lang } = &reply.kind else { render_with_prefix(sink, reply, first); return; diff --git a/src-tauri/src/modules/ollama/service.rs b/src-tauri/src/modules/ollama/service.rs index 7257962..58fc3ee 100644 --- a/src-tauri/src/modules/ollama/service.rs +++ b/src-tauri/src/modules/ollama/service.rs @@ -223,10 +223,7 @@ pub async fn touch_activate_model(model: &str) -> Result<(), String> { let status = resp.status(); let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; if !status.is_success() { - let err = body - .get("error") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let err = body.get("error").and_then(|v| v.as_str()).unwrap_or(""); return Err(if err.is_empty() { format!("ollama chat HTTP {status}") } else { From 6b5c38efab5467b44dfba3d5386b4def9adcabe7 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Mon, 27 Apr 2026 22:19:23 +0200 Subject: [PATCH 05/23] feat: enhance CLI functionality with new commands and session management - Introduced new commands: `doctor`, `plan`, `cost`, `resume`, and `compact` for improved session management and diagnostics. - Added support for non-interactive mode with the `--print` flag to run agent prompts directly. - Implemented session persistence and restoration features, allowing users to resume previous sessions seamlessly. - Enhanced the folder trust prompt for first-run scenarios, improving user experience when launching the REPL in untrusted directories. - Updated command documentation to reflect new features and usage examples. --- doc/guides/cli.md | 248 ++++++++- src-tauri/src/modules/agent/mod.rs | 139 +++++- src-tauri/src/modules/bot/service.rs | 2 +- src-tauri/src/modules/cli/bootstrap.rs | 138 ++++- src-tauri/src/modules/cli/commands.rs | 91 +++- src-tauri/src/modules/cli/dispatch.rs | 22 +- src-tauri/src/modules/cli/doctor.rs | 228 +++++++++ src-tauri/src/modules/cli/folder_trust.rs | 242 +++++++++ src-tauri/src/modules/cli/handlers.rs | 302 ++++++++++- src-tauri/src/modules/cli/mcp_cmd.rs | 527 ++++++++++++++++++++ src-tauri/src/modules/cli/mentions.rs | 212 ++++++++ src-tauri/src/modules/cli/mod.rs | 5 + src-tauri/src/modules/cli/repl.rs | 143 +++++- src-tauri/src/modules/cli/session.rs | 199 ++++++++ src-tauri/src/modules/mcp/client.rs | 61 ++- src-tauri/src/modules/mcp/http_transport.rs | 198 ++++++++ src-tauri/src/modules/mcp/mod.rs | 1 + src-tauri/src/modules/mcp/service.rs | 236 ++++++++- src-tauri/src/modules/mcp/types.rs | 13 + src-tauri/src/shared/state.rs | 8 + src-tauri/tauri.conf.json | 64 ++- vite/pengine-logger.ts | 4 +- 22 files changed, 3021 insertions(+), 62 deletions(-) create mode 100644 src-tauri/src/modules/cli/doctor.rs create mode 100644 src-tauri/src/modules/cli/folder_trust.rs create mode 100644 src-tauri/src/modules/cli/mcp_cmd.rs create mode 100644 src-tauri/src/modules/cli/mentions.rs create mode 100644 src-tauri/src/modules/cli/session.rs create mode 100644 src-tauri/src/modules/mcp/http_transport.rs diff --git a/doc/guides/cli.md b/doc/guides/cli.md index fc59d56..ccba8bb 100644 --- a/doc/guides/cli.md +++ b/doc/guides/cli.md @@ -100,8 +100,8 @@ explicit and reversible (**Remove launcher**). ## Global flags (order matters) -Flags declared at the **root** of the CLI schema (`--json`, `--no-terminal`, -`--no-telegram`) must appear **before** the subcommand, for example: +Flags declared at the **root** of the CLI schema must appear **before** the +subcommand, for example: ```bash pengine --json status @@ -113,8 +113,37 @@ Not: pengine status --json # rejected by the CLI parser ``` -`pengine help` documents the native command names; machine-readable metadata is -also available from the local HTTP API: `GET /v1/cli/commands`. +| Flag | Purpose | +| --- | --- | +| `--json` | Emit a versioned JSON envelope per reply (`{"v":1,"reply":{…}}`). | +| `-p`, `--print ""` | Non-interactive: run one agent turn on `` and exit. | +| `--output-format ` | With `-p`: `text` (default), `json`, or `stream-json`. | +| `--continue` | Resume the most recent saved REPL session (works with bare `pengine`, `pengine -p`, and `pengine ask`). | +| `--shell` | With no subcommand, require a TTY for the REPL; never open the GUI in-process. | +| `-V`, `--version` | Print version and exit. | +| `-h`, `--help [topic]` | Print help; with `[topic]`, print detailed help for that command. | +| `--no-terminal`, `--no-telegram` | Reserved for future sink routing. | + +`pengine help [command]` documents the native command names; per-command details +include usage examples. Machine-readable metadata is also available from the +local HTTP API: `GET /v1/cli/commands`. + +### Non-interactive mode (`-p`) + +Stream a single prompt through the agent and exit — useful for scripts: + +```bash +pengine -p "summarize the last hour of MCP tool errors" +pengine --json -p "what's my preferred model?" +pengine --output-format stream-json -p "..." +``` + +Combine with `--continue` to keep using the most recent REPL session (the +session's prior summary + last few turns are prepended automatically): + +```bash +pengine --continue -p "and what about the failures yesterday?" +``` ## `tauri dev` and arguments @@ -129,10 +158,18 @@ Without subcommands after `--`, the app starts in **GUI** mode as usual. ## What to expect -- **One-shot commands** (`version`, `help`, `status`, `ask`, …) should print and - exit with code **0** (or non-zero on error), without leaving a window open. -- **Bare `pengine`** (TTY, no subcommand) starts an interactive session (line editor + history); exit with - `/exit`, `exit`, `quit`, or Ctrl+D. +- **One-shot commands** (`version`, `help`, `status`, `ask`, `doctor`, …) + print and exit with code **0** (or non-zero on error), without leaving a + window open. +- **Bare `pengine`** (TTY, no subcommand) starts an interactive session (line + editor + history). Exit with: + - `/exit`, `/quit`, `exit`, `quit`, Ctrl+D, **or** + - **double Ctrl+C** within 2 seconds — the first Ctrl+C clears the line, + the second exits. +- **Multi-line input**: end a line with `\` to continue on the next line. The + joined message is sent on the first line that does **not** end with `\`. +- **Clear screen**: `/clear` (or bare `clear`) clears the REPL, the same as + Ctrl+L. - **`logs --follow`** streams until interrupted (Ctrl+C); avoid it in automation unless you plan to kill the process. @@ -177,12 +214,207 @@ rg '"kind":"cli"' "$store"/logs/audit-$(date +%Y-%m-%d).log (`pengine status` prints the `connection.json` path; its parent directory holds the `logs/` folder. `secure_store` keys never touch the audit JSON.) +## Sessions: `/compact`, `/resume`, `/cost`, `--continue` + +`pengine` keeps an in-memory session of the REPL turns + token totals. +The session is persisted on every successful agent turn to: + +- `{store_dir}/cli_sessions/.json` — full turn record +- `{store_dir}/cli_session_last.json` — pointer to the most recent session + +Each new user message is decorated with a context prefix built from the +session's optional summary plus the last **6 turns** / **12 KB** of history, +so prompt size stays bounded across long sessions. + +| Command | Effect | +| --- | --- | +| `/cost` | Token totals for the active session + heuristic cloud cost estimate ($1/$3 per M in/out). Local models report $0. | +| `/compact` | Calls the model to summarize the transcript, replaces the turn history with that summary, and saves. Use when the prefix budget gets tight or you want to start fresh without losing context. | +| `/resume` | Loads the most recent saved session into the current REPL. | +| `pengine --continue` | One-shot equivalent of `/resume`; works with bare `pengine` (REPL), `pengine -p "..."`, and `pengine ask "..."`. | + +## First-run folder trust prompt + +When you start the **REPL** inside a directory that is not yet covered by an +MCP filesystem root, Pengine asks once — same idea as Claude Code's "trust +this folder" prompt: + +``` + ⎿ /Users/you/Projects/myapp + Add this folder to Pengine's MCP filesystem roots? [y/n] +``` + +- **`y` / `yes`** — adds the folder to `mcp.json` (same effect as + `pengine fs add `) and records the choice so you're never asked + again for that path. The decision also covers any subdirectory. +- **`n` / `no`** — saves the path on the deny list so you're not asked + again. +- **Any other answer (or `Ctrl+C`)** — skipped; you'll be asked again next + launch. + +Decisions live in `{store_dir}/folder_trust.json`: + +```json +{ + "trusted": ["/Users/you/Projects/myapp"], + "denied": ["/private/tmp"] +} +``` + +The prompt is skipped entirely when: + +- stdin is not a TTY (one-shot commands, `pengine -p`, scripts, CI), +- the cwd is already under an existing MCP fs root, +- the cwd has already been decided (in `trusted` or `denied`), +- the cwd is the filesystem root. + +To revoke trust later, edit `folder_trust.json` directly and remove the +folder from `mcp.json` via `pengine fs remove `. + +## `@file` mentions + +In the REPL or `pengine ask`, tokens like `@README.md` or `@/abs/path/to/file` +are detected and the file content is appended to the prompt under a +`## Mentioned files` block. Trailing punctuation (`,` `:` `.` `)` `]`) is +stripped from the path. + +- **Cap per file**: 64 KB. Larger files are truncated with a `(truncated)` marker. +- **Cap per message**: 8 files. +- **Sandbox**: when MCP filesystem roots are configured (`pengine fs add …`), + mentions are restricted to those roots; otherwise `cwd`-relative paths are + unrestricted. +- Errors (missing file, outside roots) are reported inline at the bottom of the + reply and never abort the turn. + +## Plan mode + +Toggle with `/plan` (or `pengine plan on|off`). When on, the agent is given a +planning system prompt and write-style tools are stripped from the catalog +(name substring match: `write`, `edit`, `append`, `create`, `delete`, `patch`, +`update`, `save`, `set_`/`_set`, `rename`, `move`, `upsert`, `insert`, +`put`, `post`). + +``` +❯ /plan on + ⎿ plan mode: ON + · agent will produce a markdown plan + · write tools (memory writes, fs writes, edits) are stripped from the catalog +❯ migrate the user table to add a `created_at` column + ⎿ ## Plan + 1. Add `created_at TIMESTAMPTZ DEFAULT now()` to `users` … + 2. Backfill existing rows in batches … +``` + +Plan mode is process-local state on `AppState.plan_mode`; it resets when the +process exits. + +## Adding MCP servers + +Pengine speaks the same MCP wire protocol as Claude Code, so any server you +can run in Claude can run in Pengine. Three install paths: + +### 1. Docker image (recommended) + +Wraps the server in a container the same way pengine's built-in Tool Engine +catalog does. Requires podman or docker on the host. + +```bash +pengine mcp add github \ + --image ghcr.io/example/github-mcp:latest \ + --mount-workspace \ + --append-roots +``` + +Flags: + +- `--mount-workspace` — bind-mount every MCP filesystem root into the container +- `--mount-rw` — make those mounts read-write (default is read-only) +- `--append-roots` — append the container-side mount paths as argv after the image +- `--cmd ` — extra argv after the image (repeatable; for images whose ENTRYPOINT is not the MCP server) +- `--direct-return` — send tool output straight to the user, no model summarisation + +### 2. HTTP (Claude Code's `"type": "http"`) + +For remote MCP servers (Anthropic-hosted, GitHub Copilot's, etc.): + +```bash +pengine mcp add gh \ + --url https://api.example.com/mcp/ \ + --header "Authorization: Bearer $GITHUB_TOKEN" +``` + +Headers can be repeated. `Key: value` and `Key=value` are both accepted. +Pengine accepts `application/json` and `text/event-stream` responses. + +### 3. Plain stdio + +For Node `npx` servers when you don't want the Docker wrap (faster iteration, +no container runtime needed): + +```bash +pengine mcp add fs \ + --command npx \ + --arg -y --arg @modelcontextprotocol/server-filesystem --arg "$PWD" \ + --env DEBUG=1 +``` + +### List / remove + +```bash +pengine mcp # alias for `pengine mcp list` +pengine mcp list +pengine mcp remove fs # remove from mcp.json (and custom_tools, if applicable) +``` + +## Importing a Claude Code config + +If you already have a `~/.claude.json` (or any `mcpServers`-shaped file), +import its servers into pengine's global `mcp.json`: + +```bash +pengine mcp import ~/.claude.json +``` + +Servers with the same name are overwritten; new ones are added. + +## Project-local `.mcp.json` + +Drop a Claude Code-style `.mcp.json` at the root of any project: + +```json +{ + "mcpServers": { + "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] }, + "gh": { "type": "http", "url": "https://api.example.com/mcp/", "headers": { "Authorization": "Bearer …" } } + } +} +``` + +When pengine starts in that directory, the file is loaded **on top of** the +global `mcp.json` for the lifetime of the process — it is never written to +the global config. Project entries with the same key as a global entry win +for that session. A `loaded project .mcp.json (N server(s)) from …` line +appears in the audit log on every rebuild. + +## Diagnostics (`pengine doctor`) + +Probe each subsystem and print a checklist (`[ok]` / `[warn]` / `[fail]`): + +``` +pengine doctor +``` + +Checks: store writability, Ollama daemon reachability, model catalog, MCP +registry rebuild, keychain (when a bot is connected), and outbound HTTPS. +Exit code is non-zero if any check fails. + ## Known gaps vs Claude Code These are deliberate omissions for the current feature set — tracked for later but not implemented today: - **Streaming tool-call result bodies inside a reply**: each tool call now shows as its own ` ⎿ · …` line, but the **full** result body is still collapsed. Claude Code shows expandable tool outputs; Pengine only shows the one-line summary (`name: N bytes`, `name error: …`). Surfacing full bodies would need `agent::run_turn` to forward content, not just `emit_log` notices. - **Inline Telegram buttons** (rerun / rollback): explicitly deferred (`cli_plan.md` §11). +- **In-flight turn cancellation**: double Ctrl+C exits the REPL but does not currently abort an in-progress agent turn — Pengine's tool loop is not cooperatively cancellable yet. Press Ctrl+C twice to exit; the in-flight turn's tool calls run to completion in the background. ## Automated checks diff --git a/src-tauri/src/modules/agent/mod.rs b/src-tauri/src/modules/agent/mod.rs index 72b8bab..4197058 100644 --- a/src-tauri/src/modules/agent/mod.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -375,6 +375,9 @@ pub struct TurnResult { pub text: String, pub source: ReplySource, pub suppress_telegram_reply: bool, + pub prompt_tokens: u64, + pub eval_tokens: u64, + pub model: String, } impl TurnResult { @@ -383,6 +386,9 @@ impl TurnResult { text: text.into(), source: ReplySource::Model, suppress_telegram_reply: false, + prompt_tokens: 0, + eval_tokens: 0, + model: String::new(), } } @@ -391,6 +397,9 @@ impl TurnResult { text: String::new(), source: ReplySource::Model, suppress_telegram_reply: true, + prompt_tokens: 0, + eval_tokens: 0, + model: String::new(), } } } @@ -732,10 +741,13 @@ async fn run_model_turn( think: bool, skills_slug_filter: Option<&[String]>, ) -> Result { + let plan_mode = *state.plan_mode.read().await; let mut model = match state.preferred_ollama_model.read().await.clone() { Some(m) => m, None => ollama::active_model().await?, }; + let mut tokens_in: u64 = 0; + let mut tokens_out: u64 = 0; let recent_tools = state.recent_tools_snapshot().await; let (has_tools, has_memory, memory_server_key) = { @@ -768,6 +780,9 @@ async fn run_model_turn( allow_brave_web_search, ) }; + if plan_mode { + plan_mode_filter_writes(&mut tool_ctx.tools_json); + } state .emit_log( "tool_ctx", @@ -787,7 +802,7 @@ async fn run_model_turn( .await; state.record_tool_selection_ms(tool_ctx.select_ms).await; - let system = build_system_prompt( + let mut system = build_system_prompt( state, user_message, has_tools, @@ -795,6 +810,13 @@ async fn run_model_turn( skills_slug_filter, ) .await; + if plan_mode { + system.push_str( + "\n\nPLAN MODE: You are in read-only planning mode. Do NOT call tools that modify state \ + (memory writes, fs writes, append, edit, create). Produce a numbered, markdown plan that the \ + user can review and apply. End with a one-line summary of expected impact.", + ); + } // Order matters for Ollama KV-cache reuse across turns: system message // first, user second. Changing fragment order would invalidate the cached @@ -840,6 +862,8 @@ async fn run_model_turn( } let result = result?; let tokens = fmt_tokens(result.prompt_tokens, result.eval_tokens); + tokens_in = tokens_in.saturating_add(result.prompt_tokens.unwrap_or(0)); + tokens_out = tokens_out.saturating_add(result.eval_tokens.unwrap_or(0)); let msg = result.message; if !result.tools_sent && tools_supported { @@ -893,10 +917,18 @@ async fn run_model_turn( if tool_calls.is_empty() { if !content.is_empty() { - return Ok(TurnResult::reply(content)); + let mut r = TurnResult::reply(content); + r.prompt_tokens = tokens_in; + r.eval_tokens = tokens_out; + r.model = model.clone(); + return Ok(r); } if tool_results.is_empty() { - return Ok(TurnResult::reply("")); + let mut r = TurnResult::reply(""); + r.prompt_tokens = tokens_in; + r.eval_tokens = tokens_out; + r.model = model.clone(); + return Ok(r); } break; } @@ -1050,6 +1082,9 @@ async fn run_model_turn( text: direct_replies.join("\n\n"), source: ReplySource::Tool, suppress_telegram_reply: false, + prompt_tokens: tokens_in, + eval_tokens: tokens_out, + model: model.clone(), }); } } @@ -1089,6 +1124,8 @@ async fn run_model_turn( ) .await?; let tokens = fmt_tokens(result.prompt_tokens, result.eval_tokens); + tokens_in = tokens_in.saturating_add(result.prompt_tokens.unwrap_or(0)); + tokens_out = tokens_out.saturating_add(result.eval_tokens.unwrap_or(0)); state .emit_log( "time", @@ -1107,6 +1144,9 @@ async fn run_model_turn( text, source: ReplySource::Model, suppress_telegram_reply: false, + prompt_tokens: tokens_in, + eval_tokens: tokens_out, + model: model.clone(), }); } @@ -1115,6 +1155,9 @@ async fn run_model_turn( text: fallback, source: ReplySource::Tool, suppress_telegram_reply: false, + prompt_tokens: tokens_in, + eval_tokens: tokens_out, + model: model.clone(), }); } @@ -1123,10 +1166,100 @@ async fn run_model_turn( )) } +/// Strip tool entries whose name suggests state mutation (memory writes, fs +/// writes, edits, appends, deletes) so the plan-mode catalog stays read-only. +/// Operates in place on the JSON array produced by `select_tools_for_turn`. +/// +/// Why a curated list, not free substring search: short fragments like `put` +/// or `post` collide with `output_*` / `compose_*` and would over-filter. The +/// list below sticks to verbs that unambiguously mean "mutates state" when +/// they appear as a token boundary in the tool name. +fn plan_mode_filter_writes(tools_json: &mut serde_json::Value) { + const WRITE_TOKENS: &[&str] = &[ + "write", "edit", "append", "create", "delete", "remove", "patch", "update", "save", + "rename", "move", "upsert", "insert", "destroy", "mutate", "replace", + ]; + let Some(arr) = tools_json.as_array_mut() else { + return; + }; + arr.retain(|t| { + let name = t + .get("function") + .and_then(|f| f.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if name.is_empty() { + return true; + } + !contains_write_token(&name, WRITE_TOKENS) + }); +} + +/// True when any of `tokens` appears in `name` at a name-component boundary +/// (start, end, or surrounded by `_` / `.` / `-`). Avoids false positives like +/// `output` containing `put`. +fn contains_write_token(name: &str, tokens: &[&str]) -> bool { + let bytes = name.as_bytes(); + for tok in tokens { + let needle = tok.as_bytes(); + if needle.is_empty() || bytes.len() < needle.len() { + continue; + } + let mut i = 0; + while i + needle.len() <= bytes.len() { + if &bytes[i..i + needle.len()] == needle { + let left_ok = i == 0 || matches!(bytes[i - 1], b'_' | b'.' | b'-'); + let right = i + needle.len(); + let right_ok = + right == bytes.len() || matches!(bytes[right], b'_' | b'.' | b'-'); + if left_ok && right_ok { + return true; + } + } + i += 1; + } + } + false +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn plan_mode_filter_drops_write_tools() { + let mut v = json!([ + { "type": "function", "function": { "name": "fetch" } }, + { "type": "function", "function": { "name": "memory_create_entity" } }, + { "type": "function", "function": { "name": "fs_write" } }, + { "type": "function", "function": { "name": "brave_web_search" } }, + { "type": "function", "function": { "name": "te_provider.edit_file" } }, + ]); + plan_mode_filter_writes(&mut v); + let names: Vec<&str> = v + .as_array() + .unwrap() + .iter() + .map(|t| t["function"]["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["fetch", "brave_web_search"]); + } + + #[test] + fn plan_mode_filter_keeps_read_only_lookalikes() { + // Names that contain write-verb substrings but not at a token boundary + // must not be filtered out (e.g. `compose_*`, `output_*`). + let mut v = json!([ + { "type": "function", "function": { "name": "compose_message" } }, + { "type": "function", "function": { "name": "output_format" } }, + { "type": "function", "function": { "name": "asset_lookup" } }, + { "type": "function", "function": { "name": "search_results" } }, + ]); + plan_mode_filter_writes(&mut v); + assert_eq!(v.as_array().unwrap().len(), 4); + } + #[test] fn think_prefix_parsed_and_stripped() { assert_eq!( diff --git a/src-tauri/src/modules/bot/service.rs b/src-tauri/src/modules/bot/service.rs index 78ae2fc..e8fa824 100644 --- a/src-tauri/src/modules/bot/service.rs +++ b/src-tauri/src/modules/bot/service.rs @@ -134,7 +134,7 @@ async fn text_handler(bot: Bot, msg: Message, state: AppState) -> ResponseResult state .emit_log( "reply", - &format!("[cli:$] {}", body_preview.lines().next().unwrap_or("")), + &format!("(cli:$) {}", body_preview.lines().next().unwrap_or("")), ) .await; send_telegram_cli_reply(&bot, msg.chat.id, &reply, &state).await; diff --git a/src-tauri/src/modules/cli/bootstrap.rs b/src-tauri/src/modules/cli/bootstrap.rs index 43d9b55..15f1c4c 100644 --- a/src-tauri/src/modules/cli/bootstrap.rs +++ b/src-tauri/src/modules/cli/bootstrap.rs @@ -65,15 +65,17 @@ pub fn handle_cli_or_continue(app: &tauri::App) { }; let json = flag_true(&matches.args, "json"); - let sink: Box = if json { - Box::new(JsonSink) - } else { - Box::new(TerminalSink::new()) + let output_format = single_string(&matches.args, "output-format") + .map(|s| s.to_ascii_lowercase()) + .unwrap_or_else(|| if json { "json".to_string() } else { "text".to_string() }); + let sink: Box = match output_format.as_str() { + "json" | "stream-json" => Box::new(JsonSink), + _ => Box::new(TerminalSink::new()), }; if let Some(arg) = matches.args.get("help") { if matches!(arg.value, Value::String(_)) { - sink.render(&handlers::help()); + sink.render(&handlers::help(help_topic_from_argv().as_deref())); std::process::exit(0); } } @@ -81,6 +83,41 @@ pub fn handle_cli_or_continue(app: &tauri::App) { sink.render(&handlers::version()); std::process::exit(0); } + + // `-p` / `--print` short-circuits to a single agent turn and exits. + if let Some(prompt) = single_string(&matches.args, "print") { + let state = match build_state(app) { + Ok(s) => s, + Err(e) => { + sink.render(&CliReply::error(format!("state: {e}"))); + std::process::exit(1); + } + }; + if flag_true(&matches.args, "continue") { + if let Ok(Some(s)) = + crate::modules::cli::session::load_last(&state.store_path) + { + tauri::async_runtime::block_on(async { + *state.cli_session.write().await = Some(s); + }); + } + } + let reply = tauri::async_runtime::block_on(async { + if let Err(e) = mcp_service::rebuild_registry_into_state(&state).await { + return CliReply::error(format!("mcp warmup failed: {e}")); + } + handlers::ask_in_session( + &state, + &prompt, + flag_true(&matches.args, "continue"), + ) + .await + }); + let is_error = matches!(reply.kind, crate::modules::cli::output::ReplyKind::Error); + sink.render(&reply); + std::process::exit(if is_error { 1 } else { 0 }); + } + if matches.subcommand.is_none() { match argv_intent() { ArgvIntent::None => { @@ -115,12 +152,21 @@ pub fn handle_cli_or_continue(app: &tauri::App) { std::process::exit(1); } }; + if flag_true(&matches.args, "continue") { + if let Ok(Some(s)) = + crate::modules::cli::session::load_last(&state.store_path) + { + tauri::async_runtime::block_on(async { + *state.cli_session.write().await = Some(s); + }); + } + } let reply = tauri::async_runtime::block_on(super::repl::run(&state)); sink.render(&reply); std::process::exit(0); } ArgvIntent::Help => { - sink.render(&handlers::help()); + sink.render(&handlers::help(help_topic_from_argv().as_deref())); std::process::exit(0); } ArgvIntent::Version => { @@ -153,7 +199,9 @@ fn run_subcommand(app: &tauri::App, matches: Matches, sink: &dyn OutputSink) -> // Zero-state commands run without constructing AppState. match name { "help" => { - sink.render(&handlers::help()); + // `help` here is clap's auto-generated subcommand. Read the topic + // from argv since the subcommand schema isn't ours to extend. + sink.render(&handlers::help(help_topic_from_argv().as_deref())); return 0; } "version" => { @@ -207,6 +255,15 @@ async fn dispatch_stateful( ) -> CliReply { match name { "status" => handlers::status(state).await, + "doctor" => handlers::doctor(state).await, + "plan" => { + let action = single_string(args, "action"); + handlers::plan(state, action.as_deref()).await + } + "cost" => handlers::cost(state).await, + "resume" => handlers::resume(state).await, + "compact" => handlers::compact(state).await, + "clear" => handlers::clear(), "config" => { let kvs = multi_string(args, "kv"); handlers::config(state, &kvs).await @@ -237,6 +294,12 @@ async fn dispatch_stateful( let search = single_string(args, "search"); handlers::tools(state, search.as_deref()).await } + "mcp" => { + let action = single_string(args, "action").unwrap_or_default(); + let rest_tokens = multi_string(args, "rest"); + let rest = shellish_join(&rest_tokens); + super::mcp_cmd::run_from_args(state, action.trim(), rest.trim()).await + } "skills" => { let action = single_string(args, "action"); let slug = single_string(args, "slug"); @@ -257,7 +320,13 @@ async fn dispatch_stateful( if let Err(e) = mcp_service::rebuild_registry_into_state(state).await { return CliReply::error(format!("mcp warmup failed: {e}")); } - handlers::ask(state, &text).await + let cont = flag_true(args, "continue"); + if cont { + if let Ok(Some(s)) = crate::modules::cli::session::load_last(&state.store_path) { + *state.cli_session.write().await = Some(s); + } + } + handlers::ask_in_session(state, &text, cont).await } other => CliReply::error(format!("unknown subcommand `{other}`")), } @@ -298,7 +367,12 @@ fn cli_subcommand_audit_summary( let mut out = String::from("pengine "); out.push_str(name); match name { - "status" | "app" => {} + "status" | "app" | "doctor" | "cost" | "resume" | "compact" | "clear" => {} + "plan" => { + if let Some(a) = single_string(args, "action") { + let _ = write!(out, " {}", truncate_audit_str(&a, 32)); + } + } "config" => { let kvs = multi_string(args, "kv"); if !kvs.is_empty() { @@ -345,6 +419,16 @@ fn cli_subcommand_audit_summary( let _ = write!(out, " {}", truncate_audit_str(&p, 400)); } } + "mcp" => { + if let Some(a) = single_string(args, "action") { + let _ = write!(out, " {}", truncate_audit_str(&a, 32)); + } + let rest = multi_string(args, "rest"); + if !rest.is_empty() { + let joined = rest.join(" "); + let _ = write!(out, " {}", truncate_audit_str(&joined, 400)); + } + } "logs" => { if flag_true(args, "follow") { out.push_str(" --follow"); @@ -397,6 +481,23 @@ fn single_string(args: &HashMap, name: &str) -> Option } } +/// Re-join argv-style tokens into a single string the slash dispatch parser +/// can re-tokenize. Only quotes tokens that contain whitespace; everything +/// else passes through verbatim so simple flags stay readable in audit logs. +fn shellish_join(tokens: &[String]) -> String { + tokens + .iter() + .map(|t| { + if t.chars().any(char::is_whitespace) { + format!("\"{}\"", t.replace('"', "\\\"")) + } else { + t.clone() + } + }) + .collect::>() + .join(" ") +} + fn multi_string(args: &HashMap, name: &str) -> Vec { let Some(arg) = args.get(name) else { return Vec::new(); @@ -429,6 +530,22 @@ fn argv_intent() -> ArgvIntent { argv_intent_from(std::env::args().skip(1)) } +/// When the user runs `pengine help ` (or `pengine --help `), +/// return ``. Returns `None` if no topic word is present after the help token. +fn help_topic_from_argv() -> Option { + let mut iter = std::env::args().skip(1).filter(|a| !is_ignored_os_arg(a)); + while let Some(a) = iter.next() { + let t = a.trim(); + if matches!(t, "--help" | "-h" | "help") { + return iter + .find(|next| !next.starts_with('-')) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + } + } + None +} + fn argv_intent_from(args: I) -> ArgvIntent where I: IntoIterator, @@ -445,7 +562,8 @@ where match first.as_str() { "--help" | "-h" | "help" => ArgvIntent::Help, "--version" | "-V" | "version" => ArgvIntent::Version, - "--json" | "--no-terminal" | "--no-telegram" => ArgvIntent::CommandLike, + "--json" | "--no-terminal" | "--no-telegram" | "--continue" | "-p" | "--print" + | "--output-format" => ArgvIntent::CommandLike, other if !other.starts_with('-') && commands::lookup(other).is_some() => { ArgvIntent::CommandLike } diff --git a/src-tauri/src/modules/cli/commands.rs b/src-tauri/src/modules/cli/commands.rs index 2ca90e7..d1ae2f9 100644 --- a/src-tauri/src/modules/cli/commands.rs +++ b/src-tauri/src/modules/cli/commands.rs @@ -11,58 +11,147 @@ use serde::Serialize; pub struct NativeCommand { pub name: &'static str, pub summary: &'static str, + /// Long-form help shown by `/help ` (and `pengine help `). + /// Lines are printed as-is, so format usage examples here. + pub details: &'static str, } /// Canonical registry. Order is the order `help` prints them. pub const COMMANDS: &[NativeCommand] = &[ NativeCommand { name: "help", - summary: "Show this help.", + summary: "Show this help (or detailed help for a specific command).", + details: + "Usage: /help [command]\n\nWith no argument, lists every command.\nWith an argument, prints detailed usage for that command.", }, NativeCommand { name: "app", summary: "Open the desktop window (new process; run alongside a terminal `pengine` session).", + details: + "Usage: pengine app\n\nLaunches the Pengine desktop window in a separate process so the\nterminal session can keep running. Not available over the Telegram bridge.", }, NativeCommand { name: "version", summary: "Print the Pengine version and git commit.", + details: "Usage: pengine version (alias: -V, --version)", }, NativeCommand { name: "status", summary: "Show bot, Ollama, and MCP status.", + details: + "Usage: pengine status\n\nReports: Telegram bot connection, active + preferred Ollama model,\nnumber of MCP tools, key user settings, and store path.", + }, + NativeCommand { + name: "doctor", + summary: "Run environment diagnostics (Ollama, MCP, keychain, store, network).", + details: + "Usage: pengine doctor\n\nProbes each subsystem and prints a checklist with [ok] / [warn] / [fail]\nplus a one-line hint for any failures.", }, NativeCommand { name: "config", summary: "Show or set user settings (e.g. skills_hint_max_bytes=12000).", + details: + "Usage: pengine config # list settings\n pengine config key=value # set (clamped to allowed range)\n\nKnown keys: skills_hint_max_bytes", }, NativeCommand { name: "model", summary: "List Ollama models; set preferred by name, or by # (loads model as daemon active); --clear.", + details: + "Usage: pengine model # list models\n pengine model # set preferred by name\n pengine model <#> # set preferred + load in Ollama daemon\n pengine model --clear # clear preference (use daemon active)", }, NativeCommand { name: "bot", summary: "Connect or disconnect the Telegram bot.", + details: + "Usage: pengine bot connect \n pengine bot disconnect\n\nVerifies the token, persists metadata to connection.json, and stores the\ntoken in the OS keychain. Tokens never reach disk.", }, NativeCommand { name: "tools", summary: "List MCP tools (optional search substring).", + details: + "Usage: pengine tools # list every connected MCP tool\n pengine tools # filter by name/server/description", + }, + NativeCommand { + name: "mcp", + summary: "List, add, remove, or import MCP servers.", + details: + "Usage:\n \ + pengine mcp # list servers\n \ + pengine mcp list # list servers\n \ + pengine mcp add --url [--header K:V]… # add HTTP MCP server (Claude `\"type\":\"http\"`)\n \ + pengine mcp add --image [flags] # install Docker MCP server (uses podman/docker)\n \ + pengine mcp add --command [--arg ]… # add plain stdio server\n \ + pengine mcp remove # remove a server (and its custom_tool entry, if any)\n \ + pengine mcp import # merge a Claude Code mcpServers config\n\n\ + Common flags:\n \ + --header \"Key: value\" / --header Key=value # for HTTP servers\n \ + --env KEY=value # for stdio servers\n \ + --mount-workspace / --mount-rw / --append-roots # for Docker images\n \ + --direct-return # send tool output straight to the user (no model summarisation)", }, NativeCommand { name: "skills", summary: "List, enable, or disable skills.", + details: + "Usage: pengine skills # list\n pengine skills enable # enable\n pengine skills disable # disable", }, NativeCommand { name: "fs", summary: "List, add, or remove MCP filesystem roots.", + details: + "Usage: pengine fs # list current roots\n pengine fs add # add an absolute path\n pengine fs remove # remove a root", }, NativeCommand { name: "logs", summary: "Stream log events (--follow / --tail).", + details: + "Usage: pengine logs # tail last 50 audit lines\n pengine logs --tail 200 # tail last N\n pengine logs --follow # stream live (REPL/CLI only; not Telegram)", }, NativeCommand { name: "ask", summary: "Send a message to the agent (AI path).", + details: + "Usage: pengine ask \"\"\n\nRuns one agent turn. In REPL, free text without a leading `/` is the same\npath. Prefix with /think or /nothink to override reasoning mode.\n\nFile mentions: tokens like @path/to/file are inlined (capped at 64 KB)\nbefore the prompt is sent.", + }, + NativeCommand { + name: "clear", + summary: "Clear the REPL screen (REPL-only).", + details: "Usage: /clear (REPL-only; same as Ctrl+L on most terminals)", + }, + NativeCommand { + name: "compact", + summary: "Summarize the current REPL session and reset history (REPL-only).", + details: + "Usage: /compact\n\nGenerates a one-shot summary of the current session and seeds a fresh\nsession with the summary as context. Use when the conversation gets\ntoo long for the model's context window.", + }, + NativeCommand { + name: "resume", + summary: "Resume the most recent saved REPL session (REPL-only).", + details: + "Usage: /resume # in REPL\n pengine --continue # one-shot equivalent", + }, + NativeCommand { + name: "cost", + summary: "Show token usage and estimated cost for the current session.", + details: + "Usage: /cost\n\nShows prompt + completion tokens for the current REPL session, plus a\nrough cost estimate when running a cloud Ollama model.", + }, + NativeCommand { + name: "plan", + summary: "Toggle plan mode (read-only; agent produces plans, doesn't execute writes).", + details: + "Usage: /plan # toggle\n /plan on # force on\n /plan off # force off\n\nIn plan mode, the agent receives a planning system prompt and write tools\n(memory writes, fs writes) are removed from the tool catalog.", + }, + NativeCommand { + name: "exit", + summary: "Exit the REPL.", + details: "Usage: /exit (alias: /quit, exit, quit, Ctrl+D)", + }, + NativeCommand { + name: "quit", + summary: "Exit the REPL.", + details: "Usage: /quit (alias: /exit, exit, quit, Ctrl+D)", }, ]; diff --git a/src-tauri/src/modules/cli/dispatch.rs b/src-tauri/src/modules/cli/dispatch.rs index e2b4514..3b7ca99 100644 --- a/src-tauri/src/modules/cli/dispatch.rs +++ b/src-tauri/src/modules/cli/dispatch.rs @@ -54,7 +54,7 @@ pub async fn dispatch_line(state: &AppState, line: &str, ctx: DispatchContext) - RouterOutcome::Unknown(name) => { CliReply::error(format!("unknown command: /{name} (try /help)",)) } - RouterOutcome::Agent(text) => handlers::ask(state, text).await, + RouterOutcome::Agent(text) => handlers::ask_in_session(state, text, !ctx.telegram_surface).await, RouterOutcome::Native { name, rest } => dispatch_native(state, name, rest, ctx).await, } } @@ -66,9 +66,21 @@ async fn dispatch_native( ctx: DispatchContext, ) -> CliReply { match name { - "help" => handlers::help(), + "help" => { + let topic = rest.split_whitespace().next(); + handlers::help(topic) + } "version" => handlers::version(), "status" => handlers::status(state).await, + "doctor" => handlers::doctor(state).await, + "plan" => { + let action = rest.split_whitespace().next(); + handlers::plan(state, action).await + } + "cost" => handlers::cost(state).await, + "resume" => handlers::resume(state).await, + "compact" => handlers::compact(state).await, + "clear" => handlers::clear(), "config" => { let kvs: Vec = rest.split_whitespace().map(str::to_string).collect(); handlers::config(state, &kvs).await @@ -95,6 +107,10 @@ async fn dispatch_native( let search = (!trimmed.is_empty()).then_some(trimmed); handlers::tools(state, search).await } + "mcp" => { + let (action, tail) = split_first(rest); + super::mcp_cmd::run_from_args(state, action, tail).await + } "skills" => { let (action, tail) = split_first(rest); let slug_tok = tail.trim(); @@ -127,7 +143,7 @@ async fn dispatch_native( } handlers::logs(state, tail, follow).await } - "ask" => handlers::ask(state, rest).await, + "ask" => handlers::ask_in_session(state, rest, !ctx.telegram_surface).await, "app" => { if ctx.telegram_surface { return CliReply::error("app: starting the GUI is not supported over Telegram."); diff --git a/src-tauri/src/modules/cli/doctor.rs b/src-tauri/src/modules/cli/doctor.rs new file mode 100644 index 0000000..d841ad9 --- /dev/null +++ b/src-tauri/src/modules/cli/doctor.rs @@ -0,0 +1,228 @@ +//! `pengine doctor` — probes each subsystem and prints a checklist. +//! +//! Adapter only: every check delegates to existing services. The handler in +//! [`super::handlers::doctor`] formats the report. + +use crate::modules::mcp::service as mcp_service; +use crate::modules::ollama::service as ollama; +use crate::modules::secure_store; +use crate::shared::state::AppState; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub enum Status { + Ok, + Warn, + Fail, +} + +impl Status { + fn tag(&self) -> &'static str { + match self { + Status::Ok => "[ok]", + Status::Warn => "[warn]", + Status::Fail => "[fail]", + } + } +} + +#[derive(Debug, Clone)] +pub struct Check { + pub name: &'static str, + pub status: Status, + pub detail: String, +} + +pub async fn run(state: &AppState) -> Vec { + let mut out = Vec::new(); + out.push(check_store_writable(state).await); + out.push(check_ollama_reachable().await); + out.push(check_active_model().await); + out.push(check_mcp(state).await); + out.push(check_keychain(state).await); + out.push(check_network().await); + out +} + +async fn check_store_writable(state: &AppState) -> Check { + let parent = state.store_path.parent(); + let Some(p) = parent else { + return Check { + name: "store", + status: Status::Fail, + detail: "no parent directory".into(), + }; + }; + let probe = p.join(".pengine_doctor_probe"); + match std::fs::write(&probe, b"ok") { + Ok(()) => { + let _ = std::fs::remove_file(&probe); + Check { + name: "store", + status: Status::Ok, + detail: p.display().to_string(), + } + } + Err(e) => Check { + name: "store", + status: Status::Fail, + detail: format!("{}: {e}", p.display()), + }, + } +} + +async fn check_ollama_reachable() -> Check { + match tokio::time::timeout(Duration::from_millis(2000), ollama::active_model()).await { + Ok(Ok(m)) => Check { + name: "ollama", + status: Status::Ok, + detail: format!("daemon up; active={m}"), + }, + Ok(Err(e)) => Check { + name: "ollama", + status: Status::Fail, + detail: format!("{e} — is `ollama serve` running?"), + }, + Err(_) => Check { + name: "ollama", + status: Status::Fail, + detail: "timed out after 2s".into(), + }, + } +} + +async fn check_active_model() -> Check { + match tokio::time::timeout(Duration::from_millis(3000), ollama::model_catalog(2500)).await { + Ok(Ok(c)) => { + let n = c.models.len(); + if n == 0 { + Check { + name: "models", + status: Status::Warn, + detail: "no models installed (pull one via `ollama pull `)".into(), + } + } else { + Check { + name: "models", + status: Status::Ok, + detail: format!("{n} model(s) available"), + } + } + } + Ok(Err(e)) => Check { + name: "models", + status: Status::Warn, + detail: format!("could not list catalog: {e}"), + }, + Err(_) => Check { + name: "models", + status: Status::Warn, + detail: "model catalog timed out".into(), + }, + } +} + +async fn check_mcp(state: &AppState) -> Check { + match mcp_service::rebuild_registry_into_state(state).await { + Ok(()) => { + let n = state.mcp.read().await.tool_names().len(); + if n == 0 { + Check { + name: "mcp", + status: Status::Warn, + detail: "no tools registered (Dashboard → MCP Tools)".into(), + } + } else { + Check { + name: "mcp", + status: Status::Ok, + detail: format!("{n} tool(s) connected"), + } + } + } + Err(e) => Check { + name: "mcp", + status: Status::Fail, + detail: format!("registry rebuild failed: {e}"), + }, + } +} + +async fn check_keychain(state: &AppState) -> Check { + let bot_id = state.connection.lock().await.as_ref().map(|c| c.bot_id.clone()); + let Some(id) = bot_id else { + return Check { + name: "keychain", + status: Status::Ok, + detail: "no bot connected (skipped)".into(), + }; + }; + match secure_store::load_token(&id) { + Ok(t) if !t.is_empty() => Check { + name: "keychain", + status: Status::Ok, + detail: format!("token present for bot {id}"), + }, + Ok(_) => Check { + name: "keychain", + status: Status::Warn, + detail: format!("entry empty for bot {id} — reconnect with `pengine bot connect`"), + }, + Err(e) => Check { + name: "keychain", + status: Status::Fail, + detail: format!("{e}"), + }, + } +} + +async fn check_network() -> Check { + let client = match reqwest::Client::builder() + .timeout(Duration::from_millis(2500)) + .build() + { + Ok(c) => c, + Err(e) => { + return Check { + name: "network", + status: Status::Warn, + detail: format!("reqwest: {e}"), + } + } + }; + // Generic outbound HTTPS probe — Cloudflare is widely reachable and + // returns quickly. We don't probe ollama.com here because that would + // conflate "no internet" with "Ollama Cloud product unreachable". + match client.head("https://1.1.1.1/").send().await { + Ok(_) => Check { + name: "network", + status: Status::Ok, + detail: "outbound https reachable".into(), + }, + Err(e) if e.is_timeout() => Check { + name: "network", + status: Status::Warn, + detail: "outbound https timeout (offline?)".into(), + }, + Err(e) => Check { + name: "network", + status: Status::Warn, + detail: format!("outbound https: {e}"), + }, + } +} + +pub fn format_report(checks: &[Check]) -> String { + let name_w = checks.iter().map(|c| c.name.len()).max().unwrap_or(6); + let mut out = String::new(); + for c in checks { + out.push_str(&format!( + " {:<6} {:, + #[serde(default)] + pub denied: Vec, +} + +impl FolderTrust { + /// True when the path is in `trusted` or `denied` (exact match — used to + /// avoid re-prompting after an explicit user decision). + pub fn is_decided(&self, path: &Path) -> bool { + self.trusted.iter().any(|p| p == path) || self.denied.iter().any(|p| p == path) + } + + /// True when the path lives under any previously-trusted entry. Lets a + /// single "yes" cover the whole subtree. + pub fn is_under_trusted(&self, path: &Path) -> bool { + self.trusted.iter().any(|t| path.starts_with(t)) + } +} + +fn trust_path(store_path: &Path) -> PathBuf { + store_path + .parent() + .map(|p| p.join(TRUST_FILE)) + .unwrap_or_else(|| PathBuf::from(TRUST_FILE)) +} + +pub fn load(store_path: &Path) -> FolderTrust { + let path = trust_path(store_path); + let body = match fs::read_to_string(&path) { + Ok(b) => b, + Err(_) => return FolderTrust::default(), + }; + serde_json::from_str(&body).unwrap_or_default() +} + +pub fn save(store_path: &Path, trust: &FolderTrust) -> Result<(), String> { + let path = trust_path(store_path); + let body = serde_json::to_string_pretty(trust).map_err(|e| format!("encode: {e}"))?; + fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display())) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptDecision { + Yes, + No, + /// User skipped (empty input, ambiguous answer, or non-TTY). The decision + /// is *not* persisted, so the next launch re-asks. + Skip, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptOutcome { + Added, + Declined, + AlreadyCovered, + NoTty, + NotPrompted, +} + +/// Default prompt + answer reader. Writes the prompt to stderr (so it stays +/// out of `--json` stdout pipelines) and reads one line from stdin. +fn ask(prompt: &str) -> PromptDecision { + if !std::io::stdin().is_terminal() { + return PromptDecision::Skip; + } + { + let mut err = std::io::stderr().lock(); + let _ = err.write_all(prompt.as_bytes()); + let _ = err.flush(); + } + let mut line = String::new(); + if std::io::stdin().lock().read_line(&mut line).is_err() { + return PromptDecision::Skip; + } + parse_answer(&line) +} + +fn parse_answer(line: &str) -> PromptDecision { + match line.trim().to_lowercase().as_str() { + "y" | "yes" => PromptDecision::Yes, + "n" | "no" => PromptDecision::No, + _ => PromptDecision::Skip, + } +} + +/// Run the prompt for `cwd`. Returns the outcome so the caller can render a +/// confirmation line in the same style as the rest of the REPL boot output. +pub async fn maybe_prompt_for_cwd(state: &AppState, cwd: &Path) -> Result { + let cwd = match fs::canonicalize(cwd) { + Ok(p) => p, + Err(_) => return Ok(PromptOutcome::NotPrompted), + }; + if cwd.parent().is_none() { + return Ok(PromptOutcome::NotPrompted); + } + + let mut trust = load(&state.store_path); + if trust.is_decided(&cwd) || trust.is_under_trusted(&cwd) { + return Ok(PromptOutcome::NotPrompted); + } + + if cwd_already_covered_by_mcp(state, &cwd)? { + // Treat as implicit trust so we don't ask again next launch. + if !trust.trusted.iter().any(|p| p == &cwd) { + trust.trusted.push(cwd); + let _ = save(&state.store_path, &trust); + } + return Ok(PromptOutcome::AlreadyCovered); + } + + if !std::io::stdin().is_terminal() { + return Ok(PromptOutcome::NoTty); + } + + let prompt = format!( + "\n ⎿ {}\n Add this folder to Pengine's MCP filesystem roots? [y/n] ", + cwd.display() + ); + match ask(&prompt) { + PromptDecision::Yes => { + add_to_mcp(state, &cwd).await?; + trust.trusted.push(cwd); + save(&state.store_path, &trust)?; + Ok(PromptOutcome::Added) + } + PromptDecision::No => { + trust.denied.push(cwd); + save(&state.store_path, &trust)?; + Ok(PromptOutcome::Declined) + } + PromptDecision::Skip => Ok(PromptOutcome::NotPrompted), + } +} + +fn cwd_already_covered_by_mcp(state: &AppState, cwd: &Path) -> Result { + let cfg = mcp_service::load_or_init_config(&state.mcp_config_path) + .map_err(|e| format!("load mcp config: {e}"))?; + let existing = mcp_service::filesystem_allowed_paths(&cfg); + Ok(existing.iter().any(|p| { + let pb = PathBuf::from(p); + let canon = fs::canonicalize(&pb).unwrap_or(pb); + cwd.starts_with(&canon) + })) +} + +async fn add_to_mcp(state: &AppState, cwd: &Path) -> Result<(), String> { + let _guard = state.mcp_config_mutex.lock().await; + let mut cfg = mcp_service::load_or_init_config(&state.mcp_config_path) + .map_err(|e| format!("load mcp config: {e}"))?; + let mut paths = mcp_service::filesystem_allowed_paths(&cfg); + let cwd_str = cwd.display().to_string(); + if !paths.iter().any(|p| p == &cwd_str) { + paths.push(cwd_str); + mcp_service::set_filesystem_allowed_paths(&mut cfg, &paths); + mcp_service::save_config(&state.mcp_config_path, &cfg) + .map_err(|e| format!("save mcp config: {e}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn parse_answer_accepts_yes_no() { + assert_eq!(parse_answer("y\n"), PromptDecision::Yes); + assert_eq!(parse_answer("Yes"), PromptDecision::Yes); + assert_eq!(parse_answer("YES"), PromptDecision::Yes); + assert_eq!(parse_answer("n"), PromptDecision::No); + assert_eq!(parse_answer("No"), PromptDecision::No); + assert_eq!(parse_answer(""), PromptDecision::Skip); + assert_eq!(parse_answer("maybe"), PromptDecision::Skip); + } + + #[test] + fn round_trip_save_and_load() { + let dir = tempdir().unwrap(); + let store = dir.path().join("connection.json"); + fs::write(&store, "{}").unwrap(); + let mut trust = FolderTrust::default(); + trust.trusted.push(PathBuf::from("/a")); + trust.denied.push(PathBuf::from("/b")); + save(&store, &trust).unwrap(); + let loaded = load(&store); + assert_eq!(loaded.trusted, vec![PathBuf::from("/a")]); + assert_eq!(loaded.denied, vec![PathBuf::from("/b")]); + } + + #[test] + fn is_decided_matches_exact_paths() { + let mut t = FolderTrust::default(); + t.trusted.push(PathBuf::from("/a")); + t.denied.push(PathBuf::from("/b")); + assert!(t.is_decided(Path::new("/a"))); + assert!(t.is_decided(Path::new("/b"))); + assert!(!t.is_decided(Path::new("/c"))); + } + + #[test] + fn is_under_trusted_walks_subtree() { + let mut t = FolderTrust::default(); + t.trusted.push(PathBuf::from("/work")); + assert!(t.is_under_trusted(Path::new("/work"))); + assert!(t.is_under_trusted(Path::new("/work/src"))); + assert!(!t.is_under_trusted(Path::new("/elsewhere"))); + } + + #[test] + fn load_returns_default_when_file_missing() { + let dir = tempdir().unwrap(); + let store = dir.path().join("connection.json"); + let trust = load(&store); + assert!(trust.trusted.is_empty()); + assert!(trust.denied.is_empty()); + } +} diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index 249be6c..4ca21a5 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -6,25 +6,33 @@ //! user_settings). No duplicated business logic. use super::commands::{self, NativeCommand}; +use super::doctor; +use super::mentions; use super::output::{fmt_elapsed, CliReply, Progress, ProgressStatus}; +use super::session::{self, CliSession}; use crate::build_info; use crate::infrastructure::audit_log; use crate::infrastructure::bot_lifecycle; use crate::modules::agent; use crate::modules::bot::{repository as bot_repo, token_verify}; use crate::modules::mcp::service as mcp_service; -use crate::modules::ollama::service::{self as ollama, ModelInfo}; +use crate::modules::ollama::service::{self as ollama, ChatOptions, ModelInfo, ModelKind}; use crate::modules::secure_store; use crate::modules::skills::service as skills_service; use crate::shared::state::{AppState, ConnectionData, ConnectionMetadata, LogEntry}; use crate::shared::user_settings; use chrono::Utc; use serde::Deserialize; +use serde_json::json; use std::io::{IsTerminal, Write}; +use std::path::PathBuf; -pub fn help() -> CliReply { +pub fn help(topic: Option<&str>) -> CliReply { + if let Some(t) = topic.map(str::trim).filter(|s| !s.is_empty()) { + return help_for_topic(t); + } let mut out = String::from( - "Pengine CLI\n\nUsage:\n pengine interactive shell in a terminal (TTY only); never starts the GUI in that process\n pengine app open the desktop window in a **separate** process (can run together with a shell)\n pengine one-shot command, then exit (e.g. status, ask, …)\n\nCommands:\n", + "Pengine CLI\n\nUsage:\n pengine interactive shell in a terminal (TTY only); never starts the GUI in that process\n pengine app open the desktop window in a **separate** process (can run together with a shell)\n pengine one-shot command, then exit (e.g. status, ask, …)\n pengine -p \"…\" non-interactive: run the agent on the prompt and exit\n\nCommands:\n", ); let width = commands::COMMANDS .iter() @@ -41,14 +49,230 @@ pub fn help() -> CliReply { } out.push_str( "\nGlobal flags (must appear BEFORE the subcommand):\n \ - --json Emit JSON envelope (one per line), e.g. pengine --json status\n \ - --shell With no subcommand: require a TTY for REPL; never open the GUI in-process (like `pengine-cli`)\n \ - --no-terminal Reserved for future sink routing\n \ - --no-telegram Reserved for future sink routing\n", + --json Emit JSON envelope (one per line), e.g. pengine --json status\n \ + --shell With no subcommand: require a TTY for REPL; never open the GUI in-process (like `pengine-cli`)\n \ + -p, --print Non-interactive: run agent on and exit\n \ + --output-format With -p: text (default), json, stream-json\n \ + --continue Resume the most recent saved REPL session\n \ + -V, --version Print version and exit\n \ + --no-terminal Reserved for future sink routing\n \ + --no-telegram Reserved for future sink routing\n\n\ + Run `pengine help ` (or `/help ` in the REPL) for command-specific usage.", ); CliReply::text(out.trim_end()) } +fn help_for_topic(topic: &str) -> CliReply { + match commands::lookup(topic) { + Some(cmd) => CliReply::code( + "bash", + format!( + "{} — {}\n\n{}", + cmd.name, + cmd.summary, + cmd.details.trim_end() + ), + ), + None => CliReply::error(format!( + "help: unknown command `{topic}` (try `/help` for the full list)" + )), + } +} + +/// `/clear` outside a REPL is a no-op error. The REPL itself intercepts the +/// command before dispatch, so this handler only fires from the Telegram +/// bridge or one-shot execution. +pub fn clear() -> CliReply { + CliReply::error("clear: only available inside the interactive REPL") +} + +pub async fn doctor(state: &AppState) -> CliReply { + let checks = doctor::run(state).await; + let any_fail = checks + .iter() + .any(|c| matches!(c.status, doctor::Status::Fail)); + let body = doctor::format_report(&checks); + if any_fail { + CliReply::error(format!("pengine doctor — issues found:\n\n{body}")) + } else { + CliReply::code("bash", format!("pengine doctor — all good\n\n{body}")) + } +} + +/// `/plan [on|off|toggle]` — toggles plan mode on the AppState. +pub async fn plan(state: &AppState, action: Option<&str>) -> CliReply { + let action = action.map(str::trim).unwrap_or("toggle"); + let mut guard = state.plan_mode.write().await; + let new_value = match action { + "on" | "enable" | "true" | "1" => true, + "off" | "disable" | "false" | "0" => false, + "toggle" | "" => !*guard, + other => { + return CliReply::error(format!( + "plan: unknown action `{other}` (use on | off | toggle)" + )) + } + }; + *guard = new_value; + if new_value { + CliReply::code( + "bash", + "plan mode: ON\n · agent will produce a markdown plan\n · write tools (memory writes, fs writes, edits) are stripped from the catalog", + ) + } else { + CliReply::code("bash", "plan mode: OFF") + } +} + +/// `/cost` — show token usage + rough cost estimate for the current session. +pub async fn cost(state: &AppState) -> CliReply { + let session = state.cli_session.read().await.clone(); + let Some(s) = session else { + return CliReply::code( + "bash", + "no active session — token totals available after the first /ask", + ); + }; + let model = state + .preferred_ollama_model + .read() + .await + .clone() + .unwrap_or_else(|| "".to_string()); + let kind = ollama::classify_model(&model); + let cost_line = match kind { + ModelKind::Local => " est_cost: $0.00 (local model)".to_string(), + ModelKind::Cloud => { + // Conservative blended estimate: $1 / 1M prompt + $3 / 1M completion. + // Pengine doesn't have per-model pricing; this is an upper-bound hint. + let in_cost = (s.prompt_tokens_total as f64) * 1.0e-6; + let out_cost = (s.eval_tokens_total as f64) * 3.0e-6; + format!( + " est_cost: ~${:.4} (cloud, rough $1/$3 per M in/out)", + in_cost + out_cost + ) + } + }; + let body = format!( + "session: {}\n turns: {}\n tokens_in: {}\n tokens_out: {}\n model: {}\n{}", + s.id, + s.turns.len(), + s.prompt_tokens_total, + s.eval_tokens_total, + model, + cost_line + ); + CliReply::code("bash", body) +} + +/// `/resume` — load the most recent saved session into AppState. +pub async fn resume(state: &AppState) -> CliReply { + match session::load_last(&state.store_path) { + Ok(Some(s)) => { + let summary_line = if s.summary.is_some() { + " summary: present (set by /compact)\n" + } else { + "" + }; + let body = format!( + "resumed session: {}\n started: {}\n turns: {}\n{} tokens_in: {}\n tokens_out: {}", + s.id, s.started_at, s.turns.len(), summary_line, s.prompt_tokens_total, s.eval_tokens_total + ); + *state.cli_session.write().await = Some(s); + CliReply::code("bash", body) + } + Ok(None) => CliReply::code("bash", "no saved session to resume"), + Err(e) => CliReply::error(format!("resume: {e}")), + } +} + +/// `/compact` — summarize the current session and reset turns. The summary is +/// kept on the session and prefixed to future user messages. +pub async fn compact(state: &AppState) -> CliReply { + let snapshot = state.cli_session.read().await.clone(); + let Some(mut s) = snapshot else { + return CliReply::code("bash", "no active session to compact"); + }; + if s.turns.is_empty() && s.summary.is_none() { + return CliReply::code("bash", "session has no turns yet — nothing to compact"); + } + + let mut transcript = String::new(); + if let Some(prev) = s.summary.as_deref() { + transcript.push_str("Prior summary:\n"); + transcript.push_str(prev); + transcript.push_str("\n\n"); + } + for t in &s.turns { + transcript.push_str(&format!( + "[user] {}\n[assistant] {}\n", + t.user.trim(), + t.assistant.trim() + )); + } + + let mut model = state + .preferred_ollama_model + .read() + .await + .clone() + .unwrap_or_else(String::new); + if model.is_empty() { + model = match ollama::active_model().await { + Ok(m) => m, + Err(e) => return CliReply::error(format!("compact: ollama: {e}")), + }; + } + + let messages = json!([ + { + "role": "system", + "content": "You compress a chat transcript. Output a tight markdown summary covering: (1) topics, (2) decisions, (3) outstanding tasks. Max 250 words. No chain-of-thought." + }, + { + "role": "user", + "content": format!("Compress this transcript:\n\n{transcript}") + } + ]); + let opts = ChatOptions { + think: Some(false), + num_predict: Some(512), + temperature: Some(0.3), + ..ChatOptions::default() + }; + let result = match ollama::chat_with_tools(&model, &messages, &json!([]), &opts).await { + Ok(r) => r, + Err(e) => return CliReply::error(format!("compact: model: {e}")), + }; + let summary_text = result + .message + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if summary_text.is_empty() { + return CliReply::error("compact: model returned empty summary"); + } + + let prior_turn_count = s.turns.len(); + let summary_chars = summary_text.chars().count(); + s.summary = Some(summary_text); + s.turns.clear(); + if let Err(e) = session::save(&state.store_path, &s) { + return CliReply::error(format!("compact: save: {e}")); + } + let id = s.id.clone(); + *state.cli_session.write().await = Some(s); + + CliReply::code( + "bash", + format!( + "compacted: {prior_turn_count} turn(s) → summary ({summary_chars} chars), session `{id}` saved" + ), + ) +} + pub fn version() -> CliReply { CliReply::text(format!( "pengine {} ({})", @@ -537,14 +761,49 @@ fn format_log_line(ev: &LogEntry) -> String { } pub async fn ask(state: &AppState, text: &str) -> CliReply { + ask_in_session(state, text, true).await +} + +/// `ask` variant that lets callers (one-shot CLI vs REPL vs Telegram) decide +/// whether to extend the persistent session. +pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) -> CliReply { let trimmed = text.trim(); if trimmed.is_empty() { return CliReply::error("ask: prompt is empty"); } + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let allowed_roots: Vec = state + .cached_filesystem_paths + .read() + .await + .iter() + .map(PathBuf::from) + .collect(); + let expanded = mentions::expand_mentions(trimmed, &cwd, &allowed_roots); + for err in &expanded.errors { + state.emit_log("cli", &format!("mention: {err}")).await; + } + + let context_prefix = if persist_session { + let snap = state.cli_session.read().await.clone(); + snap.map(|s| s.context_prefix()).unwrap_or_default() + } else { + String::new() + }; + + let prompt_for_agent = if context_prefix.is_empty() { + expanded.message.clone() + } else { + format!( + "{context_prefix}## New user message\n{}", + expanded.message + ) + }; + let progress = Progress::start("Thinking"); let forwarder = spawn_status_forwarder(state, progress.status_sender()).await; - let result = agent::run_turn(state, trimmed).await; + let result = agent::run_turn(state, &prompt_for_agent).await; if let Some(h) = forwarder { h.abort(); } @@ -555,10 +814,31 @@ pub async fn ask(state: &AppState, text: &str) -> CliReply { Ok(turn) if turn.suppress_telegram_reply => CliReply::text("(no reply)"), Ok(turn) => { if turn.text.trim().is_empty() { - CliReply::text("(no reply)") - } else { - CliReply::text(turn.text) + return CliReply::text("(no reply)"); + } + if persist_session { + let mut guard = state.cli_session.write().await; + let session = guard.get_or_insert_with(CliSession::fresh); + session.record_turn( + &expanded.message, + &turn.text, + turn.prompt_tokens, + turn.eval_tokens, + &turn.model, + ); + let snapshot = session.clone(); + drop(guard); + if let Err(e) = session::save(&state.store_path, &snapshot) { + state.emit_log("cli", &format!("session save: {e}")).await; + } + } + let mut body = turn.text; + if !expanded.errors.is_empty() { + body.push_str("\n\n_Note: "); + body.push_str(&expanded.errors.join("; ")); + body.push('_'); } + CliReply::text(body) } Err(e) => CliReply::error(format!("agent error: {e}")), } diff --git a/src-tauri/src/modules/cli/mcp_cmd.rs b/src-tauri/src/modules/cli/mcp_cmd.rs new file mode 100644 index 0000000..4950a5d --- /dev/null +++ b/src-tauri/src/modules/cli/mcp_cmd.rs @@ -0,0 +1,527 @@ +//! `pengine mcp` CLI — list/add/remove/import MCP servers from the terminal. +//! +//! Three install paths: +//! - **Docker image** (`add --image `): registers a `CustomToolEntry` and +//! pulls the image via the existing Tool Engine flow (podman/docker). The +//! image is run as a stdio MCP server inside the container. +//! - **HTTP** (`add --url [--header K=V]`): adds an [`ServerEntry::Http`] +//! for remote streamable-HTTP servers (Claude Code's `"type": "http"` shape). +//! - **stdio** (`add --command [--arg ]…`): plain child-process MCP +//! server, no container. Use this for Node `npx` servers when you don't want +//! the Docker wrap. +//! +//! `import ` reads a Claude Code-style `mcp.json` (`mcpServers: {…}`) +//! and merges its servers into pengine's global `mcp.json`. + +use super::output::CliReply; +use crate::modules::mcp::service as mcp_service; +use crate::modules::mcp::types::{CustomToolEntry, ServerEntry}; +use crate::modules::tool_engine::runtime::detect_runtime; +use crate::modules::tool_engine::service as tool_engine; +use crate::shared::state::AppState; +use std::collections::HashMap; +use std::path::Path; + +/// Args parsed from the CLI surface; identical regardless of whether the call +/// came from `pengine mcp add …`, `/mcp add …` in REPL, or the Telegram bridge. +#[derive(Debug, Default)] +pub struct AddArgs { + pub name: String, + pub url: Option, + pub headers: Vec<(String, String)>, + pub image: Option, + pub mcp_server_cmd: Vec, + pub mount_workspace: bool, + pub mount_read_only: bool, + pub append_workspace_roots: bool, + pub command: Option, + pub stdio_args: Vec, + pub stdio_env: Vec<(String, String)>, + pub direct_return: bool, +} + +pub async fn list(state: &AppState) -> CliReply { + let cfg = match mcp_service::load_or_init_config(&state.mcp_config_path) { + Ok(c) => c, + Err(e) => return CliReply::error(format!("mcp list: {e}")), + }; + if cfg.servers.is_empty() { + return CliReply::code("bash", "(no MCP servers configured)"); + } + let name_w = cfg.servers.keys().map(String::len).max().unwrap_or(0); + let mut out = String::new(); + for (name, entry) in &cfg.servers { + let (kind, detail) = describe_entry(entry); + out.push_str(&format!( + " {kind:<6} {name: CliReply { + if args.name.trim().is_empty() { + return CliReply::error("mcp add: name is required"); + } + + let installed: Vec<&'static str> = [ + args.url.as_ref().map(|_| "url"), + args.image.as_ref().map(|_| "image"), + args.command.as_ref().map(|_| "command"), + ] + .into_iter() + .flatten() + .collect(); + if installed.len() != 1 { + return CliReply::error( + "mcp add: pick exactly one of --url, --image, or --command (and --arg/--header/...)", + ); + } + + if let Some(url) = args.url.clone() { + return add_http(state, &args.name, url, args.headers, args.direct_return).await; + } + if let Some(image) = args.image.clone() { + return add_docker(state, &args.name, image, &args).await; + } + if let Some(command) = args.command.clone() { + return add_stdio(state, &args.name, command, &args).await; + } + CliReply::error("mcp add: nothing to install") +} + +pub async fn remove(state: &AppState, name: &str) -> CliReply { + let name = name.trim(); + if name.is_empty() { + return CliReply::error("mcp remove: name is required"); + } + let _guard = state.mcp_config_mutex.lock().await; + let mut cfg = match mcp_service::load_or_init_config(&state.mcp_config_path) { + Ok(c) => c, + Err(e) => return CliReply::error(format!("mcp remove: {e}")), + }; + + // Custom Docker tools live under both `custom_tools[]` and `servers[te_custom_]`. + let custom_idx = cfg.custom_tools.iter().position(|t| t.key == name); + let direct_key_present = cfg.servers.contains_key(name); + let custom_server_key = format!("te_custom_{name}"); + let custom_present = cfg.servers.contains_key(&custom_server_key); + + if !direct_key_present && custom_idx.is_none() && !custom_present { + return CliReply::error(format!("mcp remove: `{name}` not found")); + } + + if let Some(i) = custom_idx { + cfg.custom_tools.remove(i); + } + cfg.servers.remove(name); + cfg.servers.remove(&custom_server_key); + + if let Err(e) = mcp_service::save_config(&state.mcp_config_path, &cfg) { + return CliReply::error(format!("mcp remove: save: {e}")); + } + CliReply::code("bash", format!("removed `{name}` (mcp.json updated)")) +} + +pub async fn import(state: &AppState, path: &str) -> CliReply { + let path = Path::new(path.trim()); + if !path.exists() { + return CliReply::error(format!("mcp import: file not found: {}", path.display())); + } + let raw = match std::fs::read_to_string(path) { + Ok(r) => r, + Err(e) => return CliReply::error(format!("mcp import: read: {e}")), + }; + let value: serde_json::Value = match serde_json::from_str(&raw) { + Ok(v) => v, + Err(e) => return CliReply::error(format!("mcp import: parse: {e}")), + }; + let new_servers = match mcp_service::parse_claude_mcp_servers(&value) { + Ok(s) => s, + Err(e) => return CliReply::error(format!("mcp import: {e}")), + }; + + let _guard = state.mcp_config_mutex.lock().await; + let mut cfg = match mcp_service::load_or_init_config(&state.mcp_config_path) { + Ok(c) => c, + Err(e) => return CliReply::error(format!("mcp import: load: {e}")), + }; + + let mut added: Vec = Vec::new(); + let mut overwritten: Vec = Vec::new(); + for (name, entry) in new_servers { + if cfg.servers.contains_key(&name) { + overwritten.push(name.clone()); + } else { + added.push(name.clone()); + } + cfg.servers.insert(name, entry); + } + + if added.is_empty() && overwritten.is_empty() { + return CliReply::code("bash", "import: nothing to add (file contained zero servers)"); + } + + if let Err(e) = mcp_service::save_config(&state.mcp_config_path, &cfg) { + return CliReply::error(format!("mcp import: save: {e}")); + } + + let mut body = String::new(); + if !added.is_empty() { + body.push_str(&format!("added: {}\n", added.join(", "))); + } + if !overwritten.is_empty() { + body.push_str(&format!("overwritten: {}\n", overwritten.join(", "))); + } + body.push_str("\nRun `pengine status` after MCP warmup to see new tools."); + CliReply::code("bash", body.trim().to_string()) +} + +async fn add_http( + state: &AppState, + name: &str, + url: String, + headers: Vec<(String, String)>, + direct_return: bool, +) -> CliReply { + let header_map: HashMap = headers.into_iter().collect(); + let entry = ServerEntry::Http { + url: url.clone(), + headers: header_map, + direct_return, + }; + if let Err(e) = upsert_and_save(state, name.to_string(), entry).await { + return CliReply::error(format!("mcp add: {e}")); + } + CliReply::code( + "bash", + format!("added http server `{name}` → {url}\n Run `/tools` (or restart the REPL) to refresh the live registry."), + ) +} + +async fn add_stdio( + state: &AppState, + name: &str, + command: String, + args: &AddArgs, +) -> CliReply { + let entry = ServerEntry::Stdio { + command: command.clone(), + args: args.stdio_args.clone(), + env: args.stdio_env.clone().into_iter().collect(), + direct_return: args.direct_return, + private_host_path: None, + catalog_passthrough_keys: Vec::new(), + }; + if let Err(e) = upsert_and_save(state, name.to_string(), entry).await { + return CliReply::error(format!("mcp add: {e}")); + } + let argv = std::iter::once(command.as_str()) + .chain(args.stdio_args.iter().map(String::as_str)) + .collect::>() + .join(" "); + CliReply::code( + "bash", + format!("added stdio server `{name}` → {argv}\n Run `/tools` (or restart the REPL) to refresh the live registry."), + ) +} + +async fn add_docker( + state: &AppState, + name: &str, + image: String, + args: &AddArgs, +) -> CliReply { + let runtime = match detect_runtime().await { + Some(r) => r, + None => { + return CliReply::error( + "mcp add --image: no container runtime found (install podman or docker)", + ) + } + }; + let entry = CustomToolEntry { + key: name.to_string(), + name: name.to_string(), + image: image.clone(), + mcp_server_cmd: args.mcp_server_cmd.clone(), + mount_workspace: args.mount_workspace, + mount_read_only: args.mount_read_only, + append_workspace_roots: args.append_workspace_roots, + direct_return: args.direct_return, + }; + let log: tool_engine::LogFn = { + let state = state.clone(); + Box::new(move |line| { + let state = state.clone(); + let line = line.to_string(); + // emit_log is async; spawn a fire-and-forget so the install thread + // can keep streaming pull progress. + tokio::spawn(async move { + state.emit_log("mcp", &line).await; + }); + }) + }; + match tool_engine::add_custom_tool( + entry, + &runtime, + &state.mcp_config_path, + &state.mcp_config_mutex, + &log, + ) + .await + { + Ok(()) => CliReply::code( + "bash", + format!( + "installed Docker MCP server `{name}` → {image}\n Run `/tools` (or restart the REPL) to refresh the live registry." + ), + ), + Err(e) => CliReply::error(format!("mcp add --image: {e}")), + } +} + +async fn upsert_and_save(state: &AppState, name: String, entry: ServerEntry) -> Result<(), String> { + let _guard = state.mcp_config_mutex.lock().await; + let mut cfg = mcp_service::load_or_init_config(&state.mcp_config_path)?; + cfg.servers.insert(name, entry); + mcp_service::save_config(&state.mcp_config_path, &cfg) +} + +fn describe_entry(entry: &ServerEntry) -> (&'static str, String) { + match entry { + ServerEntry::Native { id } => ("native", format!("id={id}")), + ServerEntry::Stdio { + command, + args, + direct_return, + .. + } => { + let argv = if args.is_empty() { + command.clone() + } else { + format!("{command} {}", args.join(" ")) + }; + let dr = if *direct_return { " [direct_return]" } else { "" }; + ("stdio", format!("{argv}{dr}")) + } + ServerEntry::Http { + url, + headers, + direct_return, + } => { + let dr = if *direct_return { " [direct_return]" } else { "" }; + let h = if headers.is_empty() { + String::new() + } else { + format!( + " headers=[{}]", + headers + .keys() + .cloned() + .collect::>() + .join(",") + ) + }; + ("http", format!("{url}{h}{dr}")) + } + } +} + +/// Slash/native dispatch entry point. Parses `rest` (whitespace-tokenized) into +/// a sub-action + AddArgs and runs the right handler. Kept here so REPL, +/// Telegram bridge, and one-shot CLI all share the same parser. +pub async fn run_from_args(state: &AppState, action: &str, rest: &str) -> CliReply { + match action { + "" | "list" => list(state).await, + "add" => match parse_add_args(rest) { + Ok(args) => add(state, args).await, + Err(e) => CliReply::error(format!("mcp add: {e}")), + }, + "remove" | "rm" => { + let name = rest.split_whitespace().next().unwrap_or(""); + remove(state, name).await + } + "import" => { + let path = rest.trim(); + if path.is_empty() { + return CliReply::error("mcp import: path required"); + } + import(state, path).await + } + other => CliReply::error(format!( + "mcp: unknown action `{other}` (use list | add | remove | import)" + )), + } +} + +/// Tiny flag parser for `add` — kept here so we never reach for `clap` from a +/// hot dispatch path. Accepts `--flag value`, `--flag=value`, and repeating +/// `--arg`/`--header` for argv/header lists. +pub fn parse_add_args(rest: &str) -> Result { + let mut out = AddArgs { + mount_read_only: true, + ..AddArgs::default() + }; + let tokens: Vec = shellish_split(rest)?; + let mut i = 0; + while i < tokens.len() { + let tok = &tokens[i]; + let (flag, inline_val) = match tok.split_once('=') { + Some((k, v)) if k.starts_with("--") => (k.to_string(), Some(v.to_string())), + _ => (tok.clone(), None), + }; + let take_value = |out_idx: &mut usize, label: &str| -> Result { + if let Some(v) = inline_val.clone() { + return Ok(v); + } + *out_idx += 1; + if *out_idx >= tokens.len() { + return Err(format!("{label} requires a value")); + } + Ok(tokens[*out_idx].clone()) + }; + match flag.as_str() { + "--url" => out.url = Some(take_value(&mut i, "--url")?), + "--image" => out.image = Some(take_value(&mut i, "--image")?), + "--command" => out.command = Some(take_value(&mut i, "--command")?), + "--arg" => out.stdio_args.push(take_value(&mut i, "--arg")?), + "--cmd" => out.mcp_server_cmd.push(take_value(&mut i, "--cmd")?), + "--header" => { + let raw = take_value(&mut i, "--header")?; + let (k, v) = raw + .split_once(':') + .or_else(|| raw.split_once('=')) + .ok_or_else(|| format!("--header `{raw}`: expected `Key: value` or `Key=value`"))?; + out.headers.push((k.trim().to_string(), v.trim().to_string())); + } + "--env" => { + let raw = take_value(&mut i, "--env")?; + let (k, v) = raw + .split_once('=') + .ok_or_else(|| format!("--env `{raw}`: expected `KEY=value`"))?; + out.stdio_env.push((k.trim().to_string(), v.trim().to_string())); + } + "--mount-workspace" => out.mount_workspace = true, + "--mount-rw" => out.mount_read_only = false, + "--append-roots" => out.append_workspace_roots = true, + "--direct-return" => out.direct_return = true, + other if other.starts_with('-') => return Err(format!("unknown flag `{other}`")), + // Positional: first non-flag token is the server name. + _ => { + if out.name.is_empty() { + out.name = tok.clone(); + } else { + return Err(format!("unexpected positional `{tok}`")); + } + } + } + i += 1; + } + if out.name.is_empty() { + return Err("name is required (first positional argument)".to_string()); + } + Ok(out) +} + +/// Minimal shell-style splitter that honours single + double quotes. Avoids a +/// new crate just for this; MCP argv values rarely need anything fancier. +fn shellish_split(input: &str) -> Result, String> { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_single = false; + let mut in_double = false; + let mut iter = input.chars().peekable(); + while let Some(c) = iter.next() { + match c { + '\'' if !in_double => in_single = !in_single, + '"' if !in_single => in_double = !in_double, + '\\' if !in_single => { + if let Some(next) = iter.next() { + cur.push(next); + } + } + ws if ws.is_whitespace() && !in_single && !in_double => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + } + other => cur.push(other), + } + } + if in_single || in_double { + return Err("unbalanced quote".to_string()); + } + if !cur.is_empty() { + out.push(cur); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shellish_split_handles_quotes() { + let v = shellish_split(r#"a "b c" 'd e' f"#).unwrap(); + assert_eq!(v, vec!["a", "b c", "d e", "f"]); + } + + #[test] + fn shellish_split_rejects_unbalanced_quote() { + assert!(shellish_split("\"oops").is_err()); + } + + #[test] + fn parse_add_url_with_header() { + let a = + parse_add_args("gh --url https://x.example/mcp --header \"Authorization: Bearer t\"") + .unwrap(); + assert_eq!(a.name, "gh"); + assert_eq!(a.url.as_deref(), Some("https://x.example/mcp")); + assert_eq!( + a.headers, + vec![("Authorization".into(), "Bearer t".into())] + ); + } + + #[test] + fn parse_add_image_with_flags() { + let a = parse_add_args( + "fs --image ghcr.io/example/server-fs:latest --mount-workspace --append-roots", + ) + .unwrap(); + assert_eq!(a.name, "fs"); + assert_eq!(a.image.as_deref(), Some("ghcr.io/example/server-fs:latest")); + assert!(a.mount_workspace); + assert!(a.append_workspace_roots); + } + + #[test] + fn parse_add_stdio_with_args_and_env() { + let a = parse_add_args( + "echo --command npx --arg -y --arg @scope/server --env FOO=bar --env BAZ=qux", + ) + .unwrap(); + assert_eq!(a.command.as_deref(), Some("npx")); + assert_eq!(a.stdio_args, vec!["-y", "@scope/server"]); + assert_eq!(a.stdio_env, vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into())]); + } + + #[test] + fn parse_add_rejects_no_name() { + let err = parse_add_args("--url https://x").unwrap_err(); + assert!(err.contains("name is required")); + } + + #[test] + fn parse_add_accepts_inline_eq() { + let a = parse_add_args("gh --url=https://x.example/mcp").unwrap(); + assert_eq!(a.url.as_deref(), Some("https://x.example/mcp")); + } +} diff --git a/src-tauri/src/modules/cli/mentions.rs b/src-tauri/src/modules/cli/mentions.rs new file mode 100644 index 0000000..7df4f4d --- /dev/null +++ b/src-tauri/src/modules/cli/mentions.rs @@ -0,0 +1,212 @@ +//! `@path` file mentions in agent prompts. +//! +//! When the user message contains `@` tokens, replace them with an +//! inlined block of the file's contents (capped at 64 KB per file). The +//! original `@path` token stays in the message for traceability; the inlined +//! content is appended below. + +use std::path::{Path, PathBuf}; + +const MAX_INLINE_BYTES: usize = 64 * 1024; +const MAX_FILES_PER_MESSAGE: usize = 8; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InlineFile { + pub mention: String, + pub resolved_path: PathBuf, + pub content: String, + pub truncated: bool, +} + +#[derive(Debug, Clone)] +pub struct MentionExpansion { + pub message: String, + pub inlined: Vec, + pub errors: Vec, +} + +/// Detect `@` tokens in `message`. A mention starts at `@` preceded by +/// start-of-string or whitespace, and runs until the next whitespace. +/// Returns the original mentions in order of appearance (deduped by path). +pub fn extract_mentions(message: &str) -> Vec { + let mut out: Vec = Vec::new(); + let bytes = message.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i] as char; + let at_word_start = i == 0 || bytes[i - 1].is_ascii_whitespace(); + if c == '@' && at_word_start { + let start = i + 1; + let mut end = start; + while end < bytes.len() && !(bytes[end] as char).is_whitespace() { + end += 1; + } + if end > start { + let path = &message[start..end]; + let trimmed = path.trim_end_matches(|c: char| { + matches!(c, '.' | ',' | ':' | ';' | '!' | '?' | ')' | ']' | '}') + }); + if !trimmed.is_empty() && !out.iter().any(|m| m == trimmed) { + out.push(trimmed.to_string()); + } + } + i = end; + } else { + i += 1; + } + } + out +} + +/// Resolve and inline `@path` mentions. Paths are resolved against `cwd` if +/// relative. Files outside `allowed_roots` (when non-empty) are rejected. +pub fn expand_mentions(message: &str, cwd: &Path, allowed_roots: &[PathBuf]) -> MentionExpansion { + let mentions = extract_mentions(message); + let mut inlined: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + for (idx, m) in mentions.iter().enumerate() { + if idx >= MAX_FILES_PER_MESSAGE { + errors.push(format!( + "@-mention cap reached ({MAX_FILES_PER_MESSAGE}); skipped: @{m}" + )); + break; + } + let raw = Path::new(m); + let resolved = if raw.is_absolute() { + raw.to_path_buf() + } else { + cwd.join(raw) + }; + let canonical = match std::fs::canonicalize(&resolved) { + Ok(p) => p, + Err(e) => { + errors.push(format!("@{m}: {e}")); + continue; + } + }; + if !allowed_roots.is_empty() && !is_under_any_root(&canonical, allowed_roots) { + errors.push(format!( + "@{m}: outside allowed roots (use `pengine fs add ` first)" + )); + continue; + } + match read_capped(&canonical, MAX_INLINE_BYTES) { + Ok((content, truncated)) => inlined.push(InlineFile { + mention: m.clone(), + resolved_path: canonical, + content, + truncated, + }), + Err(e) => errors.push(format!("@{m}: {e}")), + } + } + + let mut message = message.to_string(); + if !inlined.is_empty() { + message.push_str("\n\n## Mentioned files\n"); + for f in &inlined { + let path = f.resolved_path.display(); + let trunc_note = if f.truncated { + " (truncated)" + } else { + "" + }; + message.push_str(&format!("\n--- @{} → {path}{trunc_note} ---\n", f.mention)); + message.push_str(&f.content); + if !f.content.ends_with('\n') { + message.push('\n'); + } + } + } + MentionExpansion { + message, + inlined, + errors, + } +} + +fn is_under_any_root(p: &Path, roots: &[PathBuf]) -> bool { + roots.iter().any(|r| { + std::fs::canonicalize(r) + .map(|c| p.starts_with(c)) + .unwrap_or(false) + }) +} + +fn read_capped(path: &Path, cap: usize) -> Result<(String, bool), String> { + use std::io::Read; + let mut f = std::fs::File::open(path).map_err(|e| format!("open: {e}"))?; + let mut buf = Vec::with_capacity(cap.min(8192)); + let mut chunk = [0u8; 8192]; + let mut total = 0usize; + let mut truncated = false; + loop { + let n = f.read(&mut chunk).map_err(|e| format!("read: {e}"))?; + if n == 0 { + break; + } + let take = n.min(cap.saturating_sub(total)); + buf.extend_from_slice(&chunk[..take]); + total = total.saturating_add(take); + if total >= cap { + truncated = true; + break; + } + } + let text = String::from_utf8_lossy(&buf).into_owned(); + Ok((text, truncated)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::tempdir; + + #[test] + fn extract_basic_mentions() { + let m = extract_mentions("look at @foo.rs and @bar/baz.txt please"); + assert_eq!(m, vec!["foo.rs".to_string(), "bar/baz.txt".to_string()]); + } + + #[test] + fn extract_strips_trailing_punctuation() { + let m = extract_mentions("see @foo.rs, @bar.rs."); + assert_eq!(m, vec!["foo.rs".to_string(), "bar.rs".to_string()]); + } + + #[test] + fn extract_skips_email_like_at() { + // @ not at word boundary should be skipped (email-ish) + let m = extract_mentions("ping me at user@example.com"); + assert!(m.is_empty(), "got: {m:?}"); + } + + #[test] + fn expand_inlines_file_content() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hi.txt"); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(b"hello world").unwrap(); + let exp = expand_mentions(&format!("show @{}", path.display()), dir.path(), &[]); + assert!(exp.message.contains("hello world")); + assert_eq!(exp.inlined.len(), 1); + assert!(exp.errors.is_empty()); + } + + #[test] + fn expand_rejects_outside_root() { + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let path = outside.path().join("o.txt"); + std::fs::write(&path, "x").unwrap(); + let exp = expand_mentions( + &format!("@{}", path.display()), + dir.path(), + &[dir.path().to_path_buf()], + ); + assert!(exp.inlined.is_empty()); + assert_eq!(exp.errors.len(), 1); + assert!(exp.errors[0].contains("outside allowed roots")); + } +} diff --git a/src-tauri/src/modules/cli/mod.rs b/src-tauri/src/modules/cli/mod.rs index fad2272..167368a 100644 --- a/src-tauri/src/modules/cli/mod.rs +++ b/src-tauri/src/modules/cli/mod.rs @@ -17,10 +17,15 @@ pub mod banner; pub mod bootstrap; pub mod commands; pub mod dispatch; +pub mod doctor; +pub mod folder_trust; pub mod handlers; +pub mod mcp_cmd; +pub mod mentions; pub mod output; pub mod repl; pub mod router; +pub mod session; pub mod shim; pub mod syntax_highlight; pub mod telegram_bridge; diff --git a/src-tauri/src/modules/cli/repl.rs b/src-tauri/src/modules/cli/repl.rs index 9feb89d..14ad017 100644 --- a/src-tauri/src/modules/cli/repl.rs +++ b/src-tauri/src/modules/cli/repl.rs @@ -6,6 +6,7 @@ use super::banner::CLI_WELCOME; use super::dispatch::{dispatch_line, format_repl_line_for_audit, DispatchContext}; +use super::folder_trust::{self, PromptOutcome}; use super::output::{render_reply, CliReply, OutputSink, RenderStyle, TerminalSink}; use crate::modules::mcp::service as mcp_service; use crate::shared::state::AppState; @@ -14,6 +15,16 @@ use rustyline::history::FileHistory; use rustyline::{Config, Editor}; use std::io::IsTerminal; use std::path::PathBuf; +use std::time::{Duration, Instant}; + +/// Window for "press Ctrl+C twice to exit". A second interrupt within this +/// duration breaks the REPL loop instead of just clearing the line. +const DOUBLE_INTERRUPT_WINDOW: Duration = Duration::from_secs(2); + +/// Continuation prompt shown for additional lines while a backslash-escaped +/// multi-line edit is in progress. +const PROMPT_CONT_TTY: &str = "\x1b[2;36m·\x1b[0m "; +const PROMPT_CONT_PLAIN: &str = ". "; /// Styled prompt when stdout is a TTY (cyan-bold `❯`). Falls back to plain /// `>` when piped, so history grepping stays readable. @@ -31,6 +42,35 @@ store: {}", state.store_path.display() ))); + // First-run trust prompt: when starting in a folder not yet decided, ask + // whether to add the cwd as an MCP filesystem root. Skipped when stdin is + // not a TTY, when the folder is already covered, or when the user has + // previously decided. Must run *before* MCP warmup so a "yes" is included + // in the registry rebuild. + if let Ok(cwd) = std::env::current_dir() { + match folder_trust::maybe_prompt_for_cwd(state, &cwd).await { + Ok(PromptOutcome::Added) => { + sink.render(&CliReply::text(format!( + " ⎿ added {} to MCP filesystem roots", + cwd.display() + ))); + state + .emit_log("cli", &format!("trust: added {} to mcp fs roots", cwd.display())) + .await; + } + Ok(PromptOutcome::Declined) => { + sink.render(&CliReply::text( + " ⎿ folder not added (saved; will not ask again for this path)", + )); + state + .emit_log("cli", &format!("trust: declined {}", cwd.display())) + .await; + } + Ok(_) => {} + Err(e) => sink.render(&CliReply::error(format!("trust prompt: {e}"))), + } + } + // Best-effort MCP warmup so /tools and free-text /ask land with tools // available. Failure is reported but non-fatal — some REPL commands don't // need MCP (e.g. /config, /status). @@ -45,32 +85,36 @@ store: {}", }; let _ = rl.load_history(&history_path); - let prompt = if std::io::stdout().is_terminal() { - PROMPT_TTY + let tty = std::io::stdout().is_terminal(); + let (prompt, cont_prompt) = if tty { + (PROMPT_TTY, PROMPT_CONT_TTY) } else { - PROMPT_PLAIN + (PROMPT_PLAIN, PROMPT_CONT_PLAIN) }; + let mut last_interrupt: Option = None; + loop { - match rl.readline(prompt) { - Ok(line) => { - let line = line.trim_end_matches('\n').to_string(); - if line.trim().is_empty() { - continue; - } - let _ = rl.add_history_entry(line.as_str()); - if is_exit(&line) { + let first = match rl.readline(prompt) { + Ok(l) => { + last_interrupt = None; + l + } + Err(ReadlineError::Interrupted) => { + if last_interrupt + .map(|t| t.elapsed() < DOUBLE_INTERRUPT_WINDOW) + .unwrap_or(false) + { + sink.render(&CliReply::text("(interrupted twice — exiting)")); break; } - let audit = format_repl_line_for_audit(&line); - if !audit.is_empty() { - state.emit_log("cli", &format!("repl {audit}")).await; + last_interrupt = Some(Instant::now()); + if tty { + sink.render(&CliReply::text("(press Ctrl+C again to exit, or type /exit)")); } - let reply = dispatch_line(state, &line, DispatchContext::default()).await; - render_reply(&sink, &reply, RenderStyle::ReplIndent); + continue; } - Err(ReadlineError::Interrupted) => continue, // ^C clears the line - Err(ReadlineError::Eof) => break, // ^D exits + Err(ReadlineError::Eof) => break, Err(e) => { render_reply( &sink, @@ -79,13 +123,76 @@ store: {}", ); break; } + }; + + let mut line = first.trim_end_matches('\n').to_string(); + // Backslash-newline continuation — read additional lines until the + // edit ends without a trailing `\`. Empty continuation lines stay + // in the joined message so paste of multi-paragraph prose survives. + while line.ends_with('\\') { + line.pop(); + line.push('\n'); + match rl.readline(cont_prompt) { + Ok(more) => line.push_str(more.trim_end_matches('\n')), + Err(ReadlineError::Interrupted) => { + sink.render(&CliReply::text("(multi-line edit cancelled)")); + line.clear(); + break; + } + Err(ReadlineError::Eof) => break, + Err(e) => { + render_reply( + &sink, + &CliReply::error(format!("repl: {e}")), + RenderStyle::ReplIndent, + ); + line.clear(); + break; + } + } + } + + let line = line; + if line.trim().is_empty() { + continue; + } + let _ = rl.add_history_entry(line.as_str()); + if is_exit(&line) { + break; + } + if is_clear_command(&line) { + clear_screen(tty); + continue; + } + let audit = format_repl_line_for_audit(&line); + if !audit.is_empty() { + state.emit_log("cli", &format!("repl {audit}")).await; } + let reply = dispatch_line(state, &line, DispatchContext::default()).await; + render_reply(&sink, &reply, RenderStyle::ReplIndent); } let _ = rl.save_history(&history_path); CliReply::text("bye.") } +fn is_clear_command(line: &str) -> bool { + let t = line.trim(); + matches!(t, "/clear" | "clear") +} + +fn clear_screen(tty: bool) { + if !tty { + println!(); + return; + } + use std::io::Write; + // ESC[2J clears screen, ESC[H moves cursor to home. + let mut out = std::io::stdout().lock(); + let _ = out.write_all(b"\x1b[2J\x1b[H"); + let _ = out.flush(); +} + fn build_editor() -> Result, String> { let cfg = Config::builder().auto_add_history(false).build(); Editor::with_config(cfg).map_err(|e| e.to_string()) diff --git a/src-tauri/src/modules/cli/session.rs b/src-tauri/src/modules/cli/session.rs new file mode 100644 index 0000000..9585007 --- /dev/null +++ b/src-tauri/src/modules/cli/session.rs @@ -0,0 +1,199 @@ +//! CLI/REPL session — minimal turn history + persistence for `/compact`, +//! `/resume`, `/cost`, and `--continue`. +//! +//! Pengine's `agent::run_turn` is single-shot. To give the REPL a +//! Claude-Code-like continuity we keep a session here and prepend prior +//! context to each new user message before handing it to the agent. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +const SESSIONS_DIRNAME: &str = "cli_sessions"; +const LAST_POINTER: &str = "cli_session_last.json"; + +/// Cap applied when building the context prefix for a new turn. +/// Keeps the prompt size predictable across long sessions. +const HISTORY_TURN_BUDGET: usize = 6; +const HISTORY_BYTES_BUDGET: usize = 12_000; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionTurn { + pub at: DateTime, + pub user: String, + pub assistant: String, + pub prompt_tokens: u64, + pub eval_tokens: u64, + pub model: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CliSession { + pub id: String, + pub started_at: DateTime, + pub turns: Vec, + /// Set by `/compact`. When present, replaces the older turns when + /// building the context prefix. + pub summary: Option, + pub prompt_tokens_total: u64, + pub eval_tokens_total: u64, +} + +impl CliSession { + pub fn fresh() -> Self { + let now = Utc::now(); + Self { + id: now.format("%Y%m%dT%H%M%S").to_string(), + started_at: now, + turns: Vec::new(), + summary: None, + prompt_tokens_total: 0, + eval_tokens_total: 0, + } + } + + pub fn record_turn( + &mut self, + user: &str, + assistant: &str, + prompt_tokens: u64, + eval_tokens: u64, + model: &str, + ) { + self.turns.push(SessionTurn { + at: Utc::now(), + user: user.to_string(), + assistant: assistant.to_string(), + prompt_tokens, + eval_tokens, + model: model.to_string(), + }); + self.prompt_tokens_total = self.prompt_tokens_total.saturating_add(prompt_tokens); + self.eval_tokens_total = self.eval_tokens_total.saturating_add(eval_tokens); + } + + /// Build the prior-context prefix that gets prepended to a fresh user + /// message. Empty when the session is empty. + pub fn context_prefix(&self) -> String { + let mut out = String::new(); + if let Some(s) = self.summary.as_deref() { + if !s.trim().is_empty() { + out.push_str("## Prior session summary\n"); + out.push_str(s.trim()); + out.push_str("\n\n"); + } + } + let take_from = self.turns.len().saturating_sub(HISTORY_TURN_BUDGET); + let mut bytes_used = 0usize; + let mut pieces: Vec = Vec::new(); + for t in &self.turns[take_from..] { + let piece = format!("[user] {}\n[assistant] {}\n", t.user.trim(), t.assistant.trim()); + bytes_used = bytes_used.saturating_add(piece.len()); + if bytes_used > HISTORY_BYTES_BUDGET && !pieces.is_empty() { + break; + } + pieces.push(piece); + } + if !pieces.is_empty() { + out.push_str("## Prior turns (most recent last)\n"); + for p in &pieces { + out.push_str(p); + } + out.push('\n'); + } + out + } +} + +fn sessions_dir(store_path: &Path) -> PathBuf { + store_path + .parent() + .map(|p| p.join(SESSIONS_DIRNAME)) + .unwrap_or_else(|| PathBuf::from(SESSIONS_DIRNAME)) +} + +fn last_pointer(store_path: &Path) -> PathBuf { + store_path + .parent() + .map(|p| p.join(LAST_POINTER)) + .unwrap_or_else(|| PathBuf::from(LAST_POINTER)) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LastPointer { + last_session_id: String, +} + +pub fn save(store_path: &Path, session: &CliSession) -> Result<(), String> { + let dir = sessions_dir(store_path); + fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + let path = dir.join(format!("{}.json", session.id)); + let body = serde_json::to_string_pretty(session).map_err(|e| format!("encode: {e}"))?; + fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?; + let pointer = LastPointer { + last_session_id: session.id.clone(), + }; + let pointer_body = + serde_json::to_string_pretty(&pointer).map_err(|e| format!("encode pointer: {e}"))?; + fs::write(last_pointer(store_path), pointer_body) + .map_err(|e| format!("write pointer: {e}"))?; + Ok(()) +} + +pub fn load_last(store_path: &Path) -> Result, String> { + let pointer_path = last_pointer(store_path); + let body = match fs::read_to_string(&pointer_path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("read pointer: {e}")), + }; + let p: LastPointer = serde_json::from_str(&body).map_err(|e| format!("parse pointer: {e}"))?; + let dir = sessions_dir(store_path); + let path = dir.join(format!("{}.json", p.last_session_id)); + let body = match fs::read_to_string(&path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("read {}: {e}", path.display())), + }; + let s: CliSession = serde_json::from_str(&body).map_err(|e| format!("parse session: {e}"))?; + Ok(Some(s)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn round_trip_save_and_load_last() { + let dir = tempdir().unwrap(); + let store = dir.path().join("connection.json"); + fs::write(&store, "{}").unwrap(); + let mut s = CliSession::fresh(); + s.record_turn("hi", "hello", 10, 5, "qwen3:0.5b"); + save(&store, &s).unwrap(); + let loaded = load_last(&store).unwrap().unwrap(); + assert_eq!(loaded.id, s.id); + assert_eq!(loaded.turns.len(), 1); + assert_eq!(loaded.prompt_tokens_total, 10); + assert_eq!(loaded.eval_tokens_total, 5); + } + + #[test] + fn context_prefix_includes_summary_and_turns() { + let mut s = CliSession::fresh(); + s.summary = Some("we discussed cats".into()); + s.record_turn("hello", "hi there", 5, 3, "m"); + let prefix = s.context_prefix(); + assert!(prefix.contains("we discussed cats")); + assert!(prefix.contains("[user] hello")); + assert!(prefix.contains("[assistant] hi there")); + } + + #[test] + fn context_prefix_empty_for_fresh_session() { + let s = CliSession::fresh(); + assert!(s.context_prefix().is_empty()); + } +} diff --git a/src-tauri/src/modules/mcp/client.rs b/src-tauri/src/modules/mcp/client.rs index dc05657..a386e54 100644 --- a/src-tauri/src/modules/mcp/client.rs +++ b/src-tauri/src/modules/mcp/client.rs @@ -1,3 +1,4 @@ +use super::http_transport::HttpTransport; use super::transport::StdioTransport; use super::types::ToolDef; use serde_json::{json, Value}; @@ -8,13 +9,49 @@ use std::time::Duration; /// `podman run` + `npx -y` inside the container can exceed a minute on cold cache / slow networks. const MCP_CONNECT_CALL_TIMEOUT: Duration = Duration::from_secs(300); +/// Underlying wire to one MCP server. Variants share the same `call`/`notify` +/// surface so [`McpClient`] doesn't care which one connected. +pub enum Transport { + Stdio(StdioTransport), + Http(HttpTransport), +} + +impl Transport { + pub async fn call(&self, method: &str, params: Option) -> Result { + match self { + Transport::Stdio(t) => t.call(method, params).await, + Transport::Http(t) => t.call(method, params).await, + } + } + + pub async fn call_with_timeout( + &self, + method: &str, + params: Option, + timeout: Duration, + ) -> Result { + match self { + Transport::Stdio(t) => t.call_with_timeout(method, params, timeout).await, + Transport::Http(t) => t.call_with_timeout(method, params, timeout).await, + } + } + + pub async fn notify(&self, method: &str, params: Option) -> Result<(), String> { + match self { + Transport::Stdio(t) => t.notify(method, params).await, + Transport::Http(t) => t.notify(method, params).await, + } + } +} + pub struct McpClient { pub server_name: String, - transport: StdioTransport, + transport: Transport, tool_defs: RwLock>, } impl McpClient { + /// Connect over a child-process stdio MCP server. pub async fn connect( server_name: String, command: String, @@ -22,8 +59,26 @@ impl McpClient { env: HashMap, direct_return: bool, ) -> Result { - let transport = StdioTransport::spawn(&command, &args, &env).await?; + let transport = Transport::Stdio(StdioTransport::spawn(&command, &args, &env).await?); + Self::initialize(server_name, transport, direct_return).await + } + + /// Connect over an HTTP MCP server (Claude Code `"type": "http"` shape). + pub async fn connect_http( + server_name: String, + url: String, + headers: HashMap, + direct_return: bool, + ) -> Result { + let transport = Transport::Http(HttpTransport::new(url, headers)?); + Self::initialize(server_name, transport, direct_return).await + } + async fn initialize( + server_name: String, + transport: Transport, + direct_return: bool, + ) -> Result { let init_params = json!({ "protocolVersion": "2024-11-05", "capabilities": {}, @@ -60,7 +115,7 @@ impl McpClient { .clone() } - /// Update the `direct_return` flag on every tool for this server without reconnecting stdio. + /// Update the `direct_return` flag on every tool for this server without reconnecting. pub fn set_all_direct_return(&self, direct_return: bool) { let mut tools = self.tool_defs.write().expect("tool_defs lock poisoned"); for t in tools.iter_mut() { diff --git a/src-tauri/src/modules/mcp/http_transport.rs b/src-tauri/src/modules/mcp/http_transport.rs new file mode 100644 index 0000000..582d56a --- /dev/null +++ b/src-tauri/src/modules/mcp/http_transport.rs @@ -0,0 +1,198 @@ +//! HTTP transport for MCP. Speaks JSON-RPC over POST against a single URL. +//! +//! Supports two response shapes: +//! - `Content-Type: application/json` — body is the JSON-RPC response. +//! - `Content-Type: text/event-stream` — first `data:` line carries the +//! JSON-RPC response (basic SSE, sufficient for MCP "Streamable HTTP" +//! replies that don't multiplex notifications back). +//! +//! Compatible with Claude Code's `"type": "http"` server entries. + +use super::protocol::JsonRpcResponse; +use reqwest::Client; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +pub struct HttpTransport { + client: Client, + url: String, + headers: HashMap, + next_id: AtomicU64, +} + +impl HttpTransport { + pub fn new(url: String, headers: HashMap) -> Result { + let client = Client::builder() + .timeout(Self::default_call_timeout()) + .build() + .map_err(|e| format!("reqwest client: {e}"))?; + Ok(Self { + client, + url, + headers, + next_id: AtomicU64::new(1), + }) + } + + pub fn default_call_timeout() -> Duration { + Duration::from_secs(120) + } + + pub async fn call(&self, method: &str, params: Option) -> Result { + self.call_with_timeout(method, params, Self::default_call_timeout()) + .await + } + + pub async fn call_with_timeout( + &self, + method: &str, + params: Option, + timeout: Duration, + ) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let mut req = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + }); + if let Some(p) = params { + req["params"] = p; + } + + let mut request = self + .client + .post(&self.url) + .timeout(timeout) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .json(&req); + for (k, v) in &self.headers { + request = request.header(k, v); + } + + let resp = request + .send() + .await + .map_err(|e| format!("http {method} {}: {e}", self.url))?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = resp + .text() + .await + .map_err(|e| format!("read body: {e}"))?; + + if !status.is_success() { + return Err(format!( + "http {method} {}: HTTP {status} {}", + self.url, + truncate_for_error(&body, 256) + )); + } + + let parsed = parse_response(&content_type, &body)?; + if let Some(err) = parsed.error { + return Err(format!("mcp error: {}", err.message)); + } + Ok(parsed.result.unwrap_or(Value::Null)) + } + + pub async fn notify(&self, method: &str, params: Option) -> Result<(), String> { + let mut req = json!({ "jsonrpc": "2.0", "method": method }); + if let Some(p) = params { + req["params"] = p; + } + let mut request = self + .client + .post(&self.url) + .header("Content-Type", "application/json") + .json(&req); + for (k, v) in &self.headers { + request = request.header(k, v); + } + request + .send() + .await + .map_err(|e| format!("http notify: {e}"))?; + Ok(()) + } +} + +fn parse_response(content_type: &str, body: &str) -> Result { + if content_type.contains("event-stream") { + let data = body + .lines() + .filter_map(|l| l.strip_prefix("data:").map(str::trim_start)) + .next() + .ok_or_else(|| "sse response had no `data:` line".to_string())?; + serde_json::from_str(data).map_err(|e| format!("parse sse json: {e}: {data}")) + } else { + serde_json::from_str(body).map_err(|e| { + format!( + "parse json response: {e}: {}", + truncate_for_error(body, 256) + ) + }) + } +} + +fn truncate_for_error(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let head: String = s.chars().take(max).collect(); + format!("{head}…") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_response_handles_plain_json() { + let r = parse_response( + "application/json", + r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#, + ) + .unwrap(); + assert_eq!(r.result.unwrap()["ok"], json!(true)); + } + + #[test] + fn parse_response_handles_sse_data_line() { + let body = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n"; + let r = parse_response("text/event-stream", body).unwrap(); + assert_eq!(r.result.unwrap()["ok"], json!(true)); + } + + #[test] + fn parse_response_propagates_jsonrpc_error_through_caller() { + let r = parse_response( + "application/json", + r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"unknown method"}}"#, + ) + .unwrap(); + assert_eq!(r.error.unwrap().message, "unknown method"); + } + + #[test] + fn parse_response_rejects_sse_without_data_line() { + let err = parse_response("text/event-stream", "event: ping\n\n").unwrap_err(); + assert!(err.contains("data")); + } + + #[test] + fn truncate_for_error_caps_long_strings() { + let s = "x".repeat(500); + let out = truncate_for_error(&s, 100); + assert!(out.ends_with('…')); + assert_eq!(out.chars().count(), 101); + } +} diff --git a/src-tauri/src/modules/mcp/mod.rs b/src-tauri/src/modules/mcp/mod.rs index 343e363..76be1be 100644 --- a/src-tauri/src/modules/mcp/mod.rs +++ b/src-tauri/src/modules/mcp/mod.rs @@ -1,4 +1,5 @@ pub mod client; +pub mod http_transport; pub mod native; pub mod protocol; pub mod registry; diff --git a/src-tauri/src/modules/mcp/service.rs b/src-tauri/src/modules/mcp/service.rs index 8c748de..def81dd 100644 --- a/src-tauri/src/modules/mcp/service.rs +++ b/src-tauri/src/modules/mcp/service.rs @@ -54,6 +54,19 @@ pub fn read_config(path: &Path) -> Result { ) })?; + // Claude Code config shape compat: when the file uses Anthropic's + // `mcpServers: { name: { command|url, args, env, type } }` shape (no + // `servers` key), translate to pengine's internal layout. Lets users + // drop a Claude-style `mcp.json` straight in. + if value.get("servers").is_none() && value.get("mcpServers").is_some() { + let servers = parse_claude_mcp_servers(&value)?; + return Ok(McpConfig { + workspace_roots: Vec::new(), + servers, + custom_tools: Vec::new(), + }); + } + // Must run before serde deserialises into `ServerEntry::Stdio` — the old field name // (`catalog_passthrough`) no longer exists on the struct, so a plain `from_value` would // silently drop any pre-migration secrets that are still sitting in `mcp.json`. @@ -349,6 +362,106 @@ pub fn load_or_init_config(path: &Path) -> Result { /// Connect one server from config (native or stdio). Shared by tests and incremental rebuilds. /// `app_state` is needed for stateful native tools (e.g. `tool_manager`); pass `None` in tests. +/// Translate Claude Code's `{ "mcpServers": {...} }` shape into pengine's +/// internal `BTreeMap`. Each entry is interpreted as: +/// - `type == "http"` (or `"sse"`, or no type with a `url`) → [`ServerEntry::Http`] +/// - `type == "stdio"` (or no type with a `command`) → [`ServerEntry::Stdio`] +/// - anything else → an error tagged with the offending server name +pub fn parse_claude_mcp_servers( + raw: &serde_json::Value, +) -> Result, String> { + let map = raw + .get("mcpServers") + .and_then(|v| v.as_object()) + .ok_or_else(|| "no `mcpServers` key".to_string())?; + let mut out = std::collections::BTreeMap::new(); + for (name, server) in map { + let entry = + parse_claude_one_server(server).map_err(|e| format!("server `{name}`: {e}"))?; + out.insert(name.clone(), entry); + } + Ok(out) +} + +fn parse_claude_one_server(s: &serde_json::Value) -> Result { + let kind = s.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let has_url = s.get("url").is_some(); + let has_command = s.get("command").is_some(); + + if kind == "http" || kind == "sse" || (kind.is_empty() && has_url && !has_command) { + let url = s + .get("url") + .and_then(|v| v.as_str()) + .ok_or("http server requires `url`")? + .to_string(); + let headers = string_string_map(s.get("headers")); + return Ok(ServerEntry::Http { + url, + headers, + direct_return: s + .get("direct_return") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + }); + } + if kind == "stdio" || kind.is_empty() { + let command = s + .get("command") + .and_then(|v| v.as_str()) + .ok_or("stdio server requires `command`")? + .to_string(); + let args = s + .get("args") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + let env = string_string_map(s.get("env")); + return Ok(ServerEntry::Stdio { + command, + args, + env, + direct_return: s + .get("direct_return") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + private_host_path: None, + catalog_passthrough_keys: Vec::new(), + }); + } + Err(format!("unsupported `type`: `{kind}`")) +} + +fn string_string_map(v: Option<&serde_json::Value>) -> std::collections::HashMap { + v.and_then(|v| v.as_object()) + .map(|m| { + m.iter() + .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default() +} + +/// Read a project-local `.mcp.json` (Claude Code shape) at `cwd`, if present. +/// Returns `None` if the file is absent or unparseable. Project servers are +/// merged into the live registry only — they are never persisted to the +/// global `mcp.json`. +pub fn load_project_mcp_servers( + cwd: &Path, +) -> Option<(PathBuf, std::collections::BTreeMap)> { + let path = cwd.join(".mcp.json"); + if !path.is_file() { + return None; + } + let raw = std::fs::read_to_string(&path).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + let servers = parse_claude_mcp_servers(&value).ok()?; + Some((path, servers)) +} + pub async fn connect_one_server( server_key: &str, entry: &ServerEntry, @@ -397,6 +510,27 @@ pub async fn connect_one_server( Err(e) => (None, format!("{server_key} stdio failed: {e}")), } } + ServerEntry::Http { + url, + headers, + direct_return, + } => match McpClient::connect_http( + server_key.to_string(), + url.clone(), + headers.clone(), + *direct_return, + ) + .await + { + Ok(client) => { + let n = client.tools().len(); + let cmd_word = if n == 1 { "command" } else { "commands" }; + let dr = if *direct_return { " direct_return" } else { "" }; + let msg = format!("{server_key} http ({n} {cmd_word}{dr})"); + (Some(Provider::Mcp(Arc::new(client))), msg) + } + Err(e) => (None, format!("{server_key} http failed: {e}")), + }, } } @@ -473,7 +607,7 @@ pub async fn rebuild_registry_into_state( ) -> Result<(), String> { let _rebuild = state.mcp_rebuild_mutex.lock().await; let catalog_result = crate::modules::tool_engine::service::load_catalog().await; - let cfg = { + let mut cfg = { let _cfg_guard = state.mcp_config_mutex.lock().await; let mut cfg = match load_or_init_config(&state.mcp_config_path) { Ok(c) => c, @@ -559,6 +693,28 @@ pub async fn rebuild_registry_into_state( *state.cached_filesystem_paths.write().await = filesystem_allowed_paths(&cfg); + // Project-local `.mcp.json` (Claude Code shape) overlays the global config + // for this rebuild only — it is never persisted. Project keys win on + // collision so a workspace can override a globally-configured server. + if let Ok(cwd) = std::env::current_dir() { + if let Some((proj_path, proj_servers)) = load_project_mcp_servers(&cwd) { + let n = proj_servers.len(); + for (k, v) in proj_servers { + cfg.servers.insert(k, v); + } + state + .emit_log( + "mcp", + &format!( + "loaded project `.mcp.json` ({} server(s)) from {}", + n, + proj_path.display() + ), + ) + .await; + } + } + // Publish the registry after each *successful* connect so native tools (e.g. dice) are usable // while slow stdio servers (Podman-backed Tool Engine, npx, …) are still connecting. Failed // connects only emit a log line — no need to rebuild the registry. @@ -699,4 +855,82 @@ mod tests { assert_eq!(tool_id_from_catalog_server_key("te_custom_my-tool"), None); assert_eq!(tool_id_from_catalog_server_key("dice"), None); } + + #[test] + fn parse_claude_servers_translates_stdio_and_http() { + let raw: serde_json::Value = serde_json::from_str( + r#"{ + "mcpServers": { + "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/x"] }, + "gh": { "type": "http", "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer t" } }, + "sse": { "type": "sse", "url": "https://example.com/sse" } + } + }"#, + ) + .unwrap(); + let parsed = parse_claude_mcp_servers(&raw).unwrap(); + match parsed.get("fs").unwrap() { + ServerEntry::Stdio { command, args, .. } => { + assert_eq!(command, "npx"); + assert_eq!(args.len(), 3); + } + _ => panic!("expected stdio for fs"), + } + match parsed.get("gh").unwrap() { + ServerEntry::Http { url, headers, .. } => { + assert_eq!(url, "https://example.com/mcp"); + assert_eq!(headers.get("Authorization").unwrap(), "Bearer t"); + } + _ => panic!("expected http for gh"), + } + match parsed.get("sse").unwrap() { + ServerEntry::Http { url, .. } => assert_eq!(url, "https://example.com/sse"), + _ => panic!("expected http for sse"), + } + } + + #[test] + fn parse_claude_servers_rejects_unknown_type() { + let raw: serde_json::Value = + serde_json::from_str(r#"{"mcpServers":{"x":{"type":"weird"}}}"#).unwrap(); + let err = parse_claude_mcp_servers(&raw).unwrap_err(); + assert!(err.contains("server `x`")); + } + + #[test] + fn read_config_accepts_claude_shape() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("claude.json"); + std::fs::write( + &path, + r#"{"mcpServers":{"echo":{"command":"true","args":["a","b"]}}}"#, + ) + .unwrap(); + let cfg = read_config(&path).unwrap(); + assert_eq!(cfg.servers.len(), 1); + assert!(matches!( + cfg.servers.get("echo").unwrap(), + ServerEntry::Stdio { .. } + )); + } + + #[test] + fn load_project_mcp_servers_returns_none_when_missing() { + let dir = tempfile::tempdir().unwrap(); + assert!(load_project_mcp_servers(dir.path()).is_none()); + } + + #[test] + fn load_project_mcp_servers_parses_present_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".mcp.json"), + r#"{"mcpServers":{"x":{"type":"http","url":"https://x.example"}}}"#, + ) + .unwrap(); + let (path, servers) = load_project_mcp_servers(dir.path()).unwrap(); + assert!(path.ends_with(".mcp.json")); + assert_eq!(servers.len(), 1); + assert!(matches!(servers.get("x").unwrap(), ServerEntry::Http { .. })); + } } diff --git a/src-tauri/src/modules/mcp/types.rs b/src-tauri/src/modules/mcp/types.rs index 0a636bc..656c026 100644 --- a/src-tauri/src/modules/mcp/types.rs +++ b/src-tauri/src/modules/mcp/types.rs @@ -46,6 +46,19 @@ pub enum ServerEntry { #[serde(default, skip_serializing_if = "Vec::is_empty")] catalog_passthrough_keys: Vec, }, + /// Remote MCP server reached over HTTP. JSON-RPC requests are POSTed to + /// `url`; responses can be either `application/json` or an SSE stream + /// (`text/event-stream`). Headers (e.g. `Authorization: Bearer …`) are + /// stored in plain text — for secrets, use a wrapper script today and a + /// keychain-backed mechanism later. Compatible with Claude Code's + /// `{ "type": "http", "url": …, "headers": {…} }` shape. + Http { + url: String, + #[serde(default)] + headers: HashMap, + #[serde(default)] + direct_return: bool, + }, } /// A developer-added custom Docker image registered as an MCP tool. diff --git a/src-tauri/src/shared/state.rs b/src-tauri/src/shared/state.rs index 0139281..ba88caf 100644 --- a/src-tauri/src/shared/state.rs +++ b/src-tauri/src/shared/state.rs @@ -110,6 +110,12 @@ pub struct AppState { pub cron_save_mutex: Arc>, /// Bounded queue to the background audit NDJSON writer (`audit_log::run_audit_writer`). pub audit_tx: tokio::sync::mpsc::Sender, + /// Plan mode: when true the agent receives a planning system prompt and + /// write-style tools are stripped from the catalog. Toggled via `/plan`. + pub plan_mode: Arc>, + /// Active CLI/REPL session (turn history, summary, token totals). + /// `None` outside the REPL or before the first ask. + pub cli_session: Arc>>, } impl AppState { @@ -154,6 +160,8 @@ impl AppState { cron_no_chat_warned: Arc::new(AtomicBool::new(false)), cron_save_mutex: Arc::new(Mutex::new(())), audit_tx, + plan_mode: Arc::new(RwLock::new(false)), + cli_session: Arc::new(RwLock::new(None)), }; (this, audit_rx) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c0330ab..16bce85 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -25,6 +25,21 @@ { "name": "shell", "description": "Terminal-only: start the REPL with no args, or exit if stdin is not a TTY (never opens the GUI)" + }, + { + "name": "print", + "short": "p", + "description": "Non-interactive: run the agent on the given prompt and exit", + "takesValue": true + }, + { + "name": "output-format", + "description": "With --print: text (default), json, or stream-json", + "takesValue": true + }, + { + "name": "continue", + "description": "Resume the most recent saved REPL session" } ], "subcommands": { @@ -37,6 +52,32 @@ "status": { "description": "Show bot, Ollama, and MCP status" }, + "doctor": { + "description": "Run environment diagnostics (Ollama, MCP, keychain, store, network)" + }, + "cost": { + "description": "Show token usage + estimated cost for the current session" + }, + "resume": { + "description": "Resume the most recent saved REPL session" + }, + "compact": { + "description": "Summarize the current session and reset turn history" + }, + "clear": { + "description": "Clear the REPL screen (REPL-only; errors elsewhere)" + }, + "plan": { + "description": "Toggle plan mode (read-only agent + planning system prompt)", + "args": [ + { + "name": "action", + "description": "on | off | toggle (default toggle)", + "takesValue": true, + "index": 1 + } + ] + }, "config": { "description": "Show or set user settings (e.g. skills_hint_max_bytes=12000)", "args": [ @@ -125,6 +166,24 @@ } ] }, + "mcp": { + "description": "List, add, remove, or import MCP servers", + "args": [ + { + "name": "action", + "description": "list | add | remove | import (defaults to list)", + "takesValue": true, + "index": 1 + }, + { + "name": "rest", + "description": "Action arguments (forwarded to the parser; quote multi-word values)", + "takesValue": true, + "multiple": true, + "index": 2 + } + ] + }, "logs": { "description": "Stream log events", "args": [ @@ -134,7 +193,10 @@ }, "ask": { "description": "Send a message to the agent (AI path)", - "args": [{ "name": "text", "description": "The prompt", "takesValue": true, "index": 1 }] + "args": [ + { "name": "text", "description": "The prompt", "takesValue": true, "index": 1 }, + { "name": "continue", "description": "Resume the most recent saved REPL session" } + ] } } } diff --git a/vite/pengine-logger.ts b/vite/pengine-logger.ts index 5e37a2d..175e4c9 100644 --- a/vite/pengine-logger.ts +++ b/vite/pengine-logger.ts @@ -67,9 +67,9 @@ export function createPengineViteLogger( const env = opts.environment ? `${opts.environment} ` : ""; const lvl = type.toUpperCase(); if (opts.timestamp) { - return `${timeFmt.format(new Date())} [pengine:dev] [${lvl}] ${env}${msg}`; + return `${timeFmt.format(new Date())} (pengine:dev) [${lvl}] ${env}${msg}`; } - return `[pengine:dev] [${lvl}] ${env}${msg}`; + return `(pengine:dev) [${lvl}] ${env}${msg}`; } function output(type: LogType, msg: string, opts: LogOptions = {}) { From 5e98d3a4c54dc148ee3c0197f565f54133e13dee Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Mon, 27 Apr 2026 22:22:33 +0200 Subject: [PATCH 06/23] refactor: improve code readability and formatting in CLI modules - Streamlined formatting logic in various functions for enhanced clarity. - Removed unnecessary line breaks and improved structure in session handling and command dispatching. - Consolidated formatting for better maintainability across the CLI codebase. --- src-tauri/src/modules/agent/mod.rs | 3 +- src-tauri/src/modules/cli/bootstrap.rs | 22 ++++---- src-tauri/src/modules/cli/dispatch.rs | 4 +- src-tauri/src/modules/cli/doctor.rs | 7 ++- src-tauri/src/modules/cli/handlers.rs | 5 +- src-tauri/src/modules/cli/mcp_cmd.rs | 57 +++++++++++---------- src-tauri/src/modules/cli/mentions.rs | 6 +-- src-tauri/src/modules/cli/repl.rs | 9 +++- src-tauri/src/modules/cli/session.rs | 9 ++-- src-tauri/src/modules/mcp/http_transport.rs | 8 ++- src-tauri/src/modules/mcp/service.rs | 8 +-- 11 files changed, 72 insertions(+), 66 deletions(-) diff --git a/src-tauri/src/modules/agent/mod.rs b/src-tauri/src/modules/agent/mod.rs index 4197058..d5c2ce1 100644 --- a/src-tauri/src/modules/agent/mod.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -1211,8 +1211,7 @@ fn contains_write_token(name: &str, tokens: &[&str]) -> bool { if &bytes[i..i + needle.len()] == needle { let left_ok = i == 0 || matches!(bytes[i - 1], b'_' | b'.' | b'-'); let right = i + needle.len(); - let right_ok = - right == bytes.len() || matches!(bytes[right], b'_' | b'.' | b'-'); + let right_ok = right == bytes.len() || matches!(bytes[right], b'_' | b'.' | b'-'); if left_ok && right_ok { return true; } diff --git a/src-tauri/src/modules/cli/bootstrap.rs b/src-tauri/src/modules/cli/bootstrap.rs index 15f1c4c..470d109 100644 --- a/src-tauri/src/modules/cli/bootstrap.rs +++ b/src-tauri/src/modules/cli/bootstrap.rs @@ -67,7 +67,13 @@ pub fn handle_cli_or_continue(app: &tauri::App) { let json = flag_true(&matches.args, "json"); let output_format = single_string(&matches.args, "output-format") .map(|s| s.to_ascii_lowercase()) - .unwrap_or_else(|| if json { "json".to_string() } else { "text".to_string() }); + .unwrap_or_else(|| { + if json { + "json".to_string() + } else { + "text".to_string() + } + }); let sink: Box = match output_format.as_str() { "json" | "stream-json" => Box::new(JsonSink), _ => Box::new(TerminalSink::new()), @@ -94,9 +100,7 @@ pub fn handle_cli_or_continue(app: &tauri::App) { } }; if flag_true(&matches.args, "continue") { - if let Ok(Some(s)) = - crate::modules::cli::session::load_last(&state.store_path) - { + if let Ok(Some(s)) = crate::modules::cli::session::load_last(&state.store_path) { tauri::async_runtime::block_on(async { *state.cli_session.write().await = Some(s); }); @@ -106,12 +110,7 @@ pub fn handle_cli_or_continue(app: &tauri::App) { if let Err(e) = mcp_service::rebuild_registry_into_state(&state).await { return CliReply::error(format!("mcp warmup failed: {e}")); } - handlers::ask_in_session( - &state, - &prompt, - flag_true(&matches.args, "continue"), - ) - .await + handlers::ask_in_session(&state, &prompt, flag_true(&matches.args, "continue")).await }); let is_error = matches!(reply.kind, crate::modules::cli::output::ReplyKind::Error); sink.render(&reply); @@ -153,8 +152,7 @@ pub fn handle_cli_or_continue(app: &tauri::App) { } }; if flag_true(&matches.args, "continue") { - if let Ok(Some(s)) = - crate::modules::cli::session::load_last(&state.store_path) + if let Ok(Some(s)) = crate::modules::cli::session::load_last(&state.store_path) { tauri::async_runtime::block_on(async { *state.cli_session.write().await = Some(s); diff --git a/src-tauri/src/modules/cli/dispatch.rs b/src-tauri/src/modules/cli/dispatch.rs index 3b7ca99..9fc3d03 100644 --- a/src-tauri/src/modules/cli/dispatch.rs +++ b/src-tauri/src/modules/cli/dispatch.rs @@ -54,7 +54,9 @@ pub async fn dispatch_line(state: &AppState, line: &str, ctx: DispatchContext) - RouterOutcome::Unknown(name) => { CliReply::error(format!("unknown command: /{name} (try /help)",)) } - RouterOutcome::Agent(text) => handlers::ask_in_session(state, text, !ctx.telegram_surface).await, + RouterOutcome::Agent(text) => { + handlers::ask_in_session(state, text, !ctx.telegram_surface).await + } RouterOutcome::Native { name, rest } => dispatch_native(state, name, rest, ctx).await, } } diff --git a/src-tauri/src/modules/cli/doctor.rs b/src-tauri/src/modules/cli/doctor.rs index d841ad9..5e1f45d 100644 --- a/src-tauri/src/modules/cli/doctor.rs +++ b/src-tauri/src/modules/cli/doctor.rs @@ -149,7 +149,12 @@ async fn check_mcp(state: &AppState) -> Check { } async fn check_keychain(state: &AppState) -> Check { - let bot_id = state.connection.lock().await.as_ref().map(|c| c.bot_id.clone()); + let bot_id = state + .connection + .lock() + .await + .as_ref() + .map(|c| c.bot_id.clone()); let Some(id) = bot_id else { return Check { name: "keychain", diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index 4ca21a5..45487b5 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -795,10 +795,7 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) let prompt_for_agent = if context_prefix.is_empty() { expanded.message.clone() } else { - format!( - "{context_prefix}## New user message\n{}", - expanded.message - ) + format!("{context_prefix}## New user message\n{}", expanded.message) }; let progress = Progress::start("Thinking"); diff --git a/src-tauri/src/modules/cli/mcp_cmd.rs b/src-tauri/src/modules/cli/mcp_cmd.rs index 4950a5d..a8ae8bc 100644 --- a/src-tauri/src/modules/cli/mcp_cmd.rs +++ b/src-tauri/src/modules/cli/mcp_cmd.rs @@ -163,7 +163,10 @@ pub async fn import(state: &AppState, path: &str) -> CliReply { } if added.is_empty() && overwritten.is_empty() { - return CliReply::code("bash", "import: nothing to add (file contained zero servers)"); + return CliReply::code( + "bash", + "import: nothing to add (file contained zero servers)", + ); } if let Err(e) = mcp_service::save_config(&state.mcp_config_path, &cfg) { @@ -203,12 +206,7 @@ async fn add_http( ) } -async fn add_stdio( - state: &AppState, - name: &str, - command: String, - args: &AddArgs, -) -> CliReply { +async fn add_stdio(state: &AppState, name: &str, command: String, args: &AddArgs) -> CliReply { let entry = ServerEntry::Stdio { command: command.clone(), args: args.stdio_args.clone(), @@ -230,12 +228,7 @@ async fn add_stdio( ) } -async fn add_docker( - state: &AppState, - name: &str, - image: String, - args: &AddArgs, -) -> CliReply { +async fn add_docker(state: &AppState, name: &str, image: String, args: &AddArgs) -> CliReply { let runtime = match detect_runtime().await { Some(r) => r, None => { @@ -306,7 +299,11 @@ fn describe_entry(entry: &ServerEntry) -> (&'static str, String) { } else { format!("{command} {}", args.join(" ")) }; - let dr = if *direct_return { " [direct_return]" } else { "" }; + let dr = if *direct_return { + " [direct_return]" + } else { + "" + }; ("stdio", format!("{argv}{dr}")) } ServerEntry::Http { @@ -314,17 +311,17 @@ fn describe_entry(entry: &ServerEntry) -> (&'static str, String) { headers, direct_return, } => { - let dr = if *direct_return { " [direct_return]" } else { "" }; + let dr = if *direct_return { + " [direct_return]" + } else { + "" + }; let h = if headers.is_empty() { String::new() } else { format!( " headers=[{}]", - headers - .keys() - .cloned() - .collect::>() - .join(",") + headers.keys().cloned().collect::>().join(",") ) }; ("http", format!("{url}{h}{dr}")) @@ -396,15 +393,19 @@ pub fn parse_add_args(rest: &str) -> Result { let (k, v) = raw .split_once(':') .or_else(|| raw.split_once('=')) - .ok_or_else(|| format!("--header `{raw}`: expected `Key: value` or `Key=value`"))?; - out.headers.push((k.trim().to_string(), v.trim().to_string())); + .ok_or_else(|| { + format!("--header `{raw}`: expected `Key: value` or `Key=value`") + })?; + out.headers + .push((k.trim().to_string(), v.trim().to_string())); } "--env" => { let raw = take_value(&mut i, "--env")?; let (k, v) = raw .split_once('=') .ok_or_else(|| format!("--env `{raw}`: expected `KEY=value`"))?; - out.stdio_env.push((k.trim().to_string(), v.trim().to_string())); + out.stdio_env + .push((k.trim().to_string(), v.trim().to_string())); } "--mount-workspace" => out.mount_workspace = true, "--mount-rw" => out.mount_read_only = false, @@ -484,10 +485,7 @@ mod tests { .unwrap(); assert_eq!(a.name, "gh"); assert_eq!(a.url.as_deref(), Some("https://x.example/mcp")); - assert_eq!( - a.headers, - vec![("Authorization".into(), "Bearer t".into())] - ); + assert_eq!(a.headers, vec![("Authorization".into(), "Bearer t".into())]); } #[test] @@ -510,7 +508,10 @@ mod tests { .unwrap(); assert_eq!(a.command.as_deref(), Some("npx")); assert_eq!(a.stdio_args, vec!["-y", "@scope/server"]); - assert_eq!(a.stdio_env, vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into())]); + assert_eq!( + a.stdio_env, + vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into())] + ); } #[test] diff --git a/src-tauri/src/modules/cli/mentions.rs b/src-tauri/src/modules/cli/mentions.rs index 7df4f4d..0ca7630 100644 --- a/src-tauri/src/modules/cli/mentions.rs +++ b/src-tauri/src/modules/cli/mentions.rs @@ -106,11 +106,7 @@ pub fn expand_mentions(message: &str, cwd: &Path, allowed_roots: &[PathBuf]) -> message.push_str("\n\n## Mentioned files\n"); for f in &inlined { let path = f.resolved_path.display(); - let trunc_note = if f.truncated { - " (truncated)" - } else { - "" - }; + let trunc_note = if f.truncated { " (truncated)" } else { "" }; message.push_str(&format!("\n--- @{} → {path}{trunc_note} ---\n", f.mention)); message.push_str(&f.content); if !f.content.ends_with('\n') { diff --git a/src-tauri/src/modules/cli/repl.rs b/src-tauri/src/modules/cli/repl.rs index 14ad017..84f9ae0 100644 --- a/src-tauri/src/modules/cli/repl.rs +++ b/src-tauri/src/modules/cli/repl.rs @@ -55,7 +55,10 @@ store: {}", cwd.display() ))); state - .emit_log("cli", &format!("trust: added {} to mcp fs roots", cwd.display())) + .emit_log( + "cli", + &format!("trust: added {} to mcp fs roots", cwd.display()), + ) .await; } Ok(PromptOutcome::Declined) => { @@ -110,7 +113,9 @@ store: {}", } last_interrupt = Some(Instant::now()); if tty { - sink.render(&CliReply::text("(press Ctrl+C again to exit, or type /exit)")); + sink.render(&CliReply::text( + "(press Ctrl+C again to exit, or type /exit)", + )); } continue; } diff --git a/src-tauri/src/modules/cli/session.rs b/src-tauri/src/modules/cli/session.rs index 9585007..030a0b6 100644 --- a/src-tauri/src/modules/cli/session.rs +++ b/src-tauri/src/modules/cli/session.rs @@ -88,7 +88,11 @@ impl CliSession { let mut bytes_used = 0usize; let mut pieces: Vec = Vec::new(); for t in &self.turns[take_from..] { - let piece = format!("[user] {}\n[assistant] {}\n", t.user.trim(), t.assistant.trim()); + let piece = format!( + "[user] {}\n[assistant] {}\n", + t.user.trim(), + t.assistant.trim() + ); bytes_used = bytes_used.saturating_add(piece.len()); if bytes_used > HISTORY_BYTES_BUDGET && !pieces.is_empty() { break; @@ -136,8 +140,7 @@ pub fn save(store_path: &Path, session: &CliSession) -> Result<(), String> { }; let pointer_body = serde_json::to_string_pretty(&pointer).map_err(|e| format!("encode pointer: {e}"))?; - fs::write(last_pointer(store_path), pointer_body) - .map_err(|e| format!("write pointer: {e}"))?; + fs::write(last_pointer(store_path), pointer_body).map_err(|e| format!("write pointer: {e}"))?; Ok(()) } diff --git a/src-tauri/src/modules/mcp/http_transport.rs b/src-tauri/src/modules/mcp/http_transport.rs index 582d56a..2994b94 100644 --- a/src-tauri/src/modules/mcp/http_transport.rs +++ b/src-tauri/src/modules/mcp/http_transport.rs @@ -84,10 +84,7 @@ impl HttpTransport { .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_string(); - let body = resp - .text() - .await - .map_err(|e| format!("read body: {e}"))?; + let body = resp.text().await.map_err(|e| format!("read body: {e}"))?; if !status.is_success() { return Err(format!( @@ -167,7 +164,8 @@ mod tests { #[test] fn parse_response_handles_sse_data_line() { - let body = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n"; + let body = + "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n"; let r = parse_response("text/event-stream", body).unwrap(); assert_eq!(r.result.unwrap()["ok"], json!(true)); } diff --git a/src-tauri/src/modules/mcp/service.rs b/src-tauri/src/modules/mcp/service.rs index def81dd..cf00ac2 100644 --- a/src-tauri/src/modules/mcp/service.rs +++ b/src-tauri/src/modules/mcp/service.rs @@ -376,8 +376,7 @@ pub fn parse_claude_mcp_servers( .ok_or_else(|| "no `mcpServers` key".to_string())?; let mut out = std::collections::BTreeMap::new(); for (name, server) in map { - let entry = - parse_claude_one_server(server).map_err(|e| format!("server `{name}`: {e}"))?; + let entry = parse_claude_one_server(server).map_err(|e| format!("server `{name}`: {e}"))?; out.insert(name.clone(), entry); } Ok(out) @@ -931,6 +930,9 @@ mod tests { let (path, servers) = load_project_mcp_servers(dir.path()).unwrap(); assert!(path.ends_with(".mcp.json")); assert_eq!(servers.len(), 1); - assert!(matches!(servers.get("x").unwrap(), ServerEntry::Http { .. })); + assert!(matches!( + servers.get("x").unwrap(), + ServerEntry::Http { .. } + )); } } From 8c3bbb83f8c4becc3836eafb78d29a4d5ced774b Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Wed, 29 Apr 2026 23:41:31 +0200 Subject: [PATCH 07/23] feat: implement task spawning and enhance MCP tool management - Introduced a new task spawning mechanism to allow recursive task execution with depth control. - Added support for managing scheduled tasks, including creation, deletion, and enabling/disabling jobs. - Enhanced MCP tool management by implementing a recovery mechanism for tool calls during transport failures. - Updated the CLI to support new commands for managing tasks and improved error handling in command execution. - Refactored existing code for better maintainability and clarity, including updates to JSON schemas for tools. --- .claude/scheduled_tasks.lock | 1 + src-tauri/src/modules/agent/mod.rs | 255 ++++++++++++++++++-- src-tauri/src/modules/cli/folder_trust.rs | 10 +- src-tauri/src/modules/cli/mcp_cmd.rs | 33 ++- src-tauri/src/modules/cli/repl.rs | 29 ++- src-tauri/src/modules/mcp/client.rs | 17 +- src-tauri/src/modules/mcp/http_transport.rs | 3 +- src-tauri/src/modules/mcp/native.rs | 248 ++++++++++++++++++- src-tauri/src/modules/mcp/service.rs | 6 +- src-tauri/src/modules/mcp/tool_metadata.rs | 57 +++++ src-tauri/src/modules/mcp/transport.rs | 6 + src-tauri/src/shared/state.rs | 7 +- tools/lsp/Dockerfile | 68 ++++++ tools/lsp/cclsp.json | 41 ++++ tools/mcp-tools.json | 190 ++++++++++++++- tools/notebook/Dockerfile | 13 + tools/ripgrep/Dockerfile | 15 ++ tools/shell/Dockerfile | 24 ++ 18 files changed, 961 insertions(+), 62 deletions(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 tools/lsp/Dockerfile create mode 100644 tools/lsp/cclsp.json create mode 100644 tools/notebook/Dockerfile create mode 100644 tools/ripgrep/Dockerfile create mode 100644 tools/shell/Dockerfile diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..14d1bb9 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"06e98f4f-8383-4ed2-8da9-273b6ec21218","pid":44456,"procStart":"Wed Apr 29 19:41:09 2026","acquiredAt":1777494257939} \ No newline at end of file diff --git a/src-tauri/src/modules/agent/mod.rs b/src-tauri/src/modules/agent/mod.rs index d5c2ce1..23bda9f 100644 --- a/src-tauri/src/modules/agent/mod.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -13,6 +13,7 @@ use crate::shared::text::{ use chrono::Utc; use serde_json::json; use std::collections::HashSet; +use std::path::Path; use std::time::{Duration, Instant}; /// Tool rounds + at least one completion-only step. Research flows (sitemap + several @@ -196,6 +197,115 @@ fn fetch_url_dedup_key(url: &str) -> String { no_frag.to_lowercase() } +/// Stdio MCP child exited or closed stdin — the existing [`crate::modules::mcp::client::McpClient`] is dead. +fn mcp_stdio_recoverable(err: &str) -> bool { + let e = err.to_ascii_lowercase(); + e.contains("broken pipe") + || e.contains("os error 32") + || e.contains("connection reset") +} + +/// Run a `task_spawn` tool call inline (not via [`crate::modules::mcp::registry::Provider::call_tool`]). +/// +/// The recursive call into [`run_system_turn`] makes this future `!Send`, so it cannot be inserted +/// into the parallel `tokio::spawn` pool. The dispatcher detects task-spawner provider invocations +/// and routes them through this function instead. +async fn run_task_spawn_inline(state: &AppState, args: &serde_json::Value) -> Result { + use std::sync::atomic::Ordering; + + let description = args + .get("description") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or("missing 'description'")?; + let prompt = args + .get("prompt") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or("missing 'prompt'")? + .to_string(); + + let depth_before = state.task_spawn_depth.load(Ordering::Acquire); + if depth_before >= crate::modules::mcp::native::TASK_SPAWN_MAX_DEPTH { + return Err(format!( + "task_spawn refused: recursion depth {depth_before} >= cap {}", + crate::modules::mcp::native::TASK_SPAWN_MAX_DEPTH + )); + } + state.task_spawn_depth.fetch_add(1, Ordering::AcqRel); + state + .emit_log( + "task", + &format!("spawn[{}]: {description}", depth_before + 1), + ) + .await; + + let started = std::time::Instant::now(); + // `Box::pin` breaks the cycle [run_model_turn → run_task_spawn_inline → run_system_turn → run_model_turn] + // that would otherwise produce an infinitely-sized future. + let result = Box::pin(run_system_turn(state, &prompt, None)).await; + state.task_spawn_depth.fetch_sub(1, Ordering::AcqRel); + + match result { + Ok(turn) => { + state + .emit_log( + "task", + &format!( + "done[{}]: {description} ({} ms, {} chars)", + depth_before + 1, + started.elapsed().as_millis(), + turn.text.chars().count() + ), + ) + .await; + Ok(turn.text) + } + Err(e) => { + state + .emit_log( + "task", + &format!("failed[{}]: {description}: {e}", depth_before + 1), + ) + .await; + Err(format!("sub-agent failed: {e}")) + } + } +} + +/// One attempt to reconnect all MCP servers and retry the same tool after a transport failure. +async fn call_tool_with_mcp_recovery( + state: &AppState, + model_tool_name: &str, + provider: crate::modules::mcp::registry::Provider, + tool_name: String, + args: serde_json::Value, +) -> Result { + match provider.call_tool(&tool_name, args.clone()).await { + Ok(t) => Ok(t), + Err(e) if mcp_stdio_recoverable(&e) => { + state + .emit_log( + "mcp", + &format!( + "tool `{model_tool_name}` transport error ({e}); rebuilding MCP registry" + ), + ) + .await; + crate::modules::mcp::service::rebuild_registry_into_state(state).await?; + let (p2, tn2, _, a2) = { + let reg = state.mcp.read().await; + reg.prepare_tool_invocation(model_tool_name, args) + } + .map_err(|prep| format!("mcp reconnected but invocation failed: {prep}"))?; + p2.call_tool(&tn2, a2).await + } + Err(e) => Err(e), + } +} + /// After `brave_web_search`, prefetch this many distinct result URLs (one search per message; extra bandwidth here is `fetch` only). const AUTO_FETCH_TOP_URLS: usize = search_followup::DEFAULT_AUTO_FETCH_CAP; @@ -346,6 +456,68 @@ fn tool_call_arguments(call: &serde_json::Value) -> serde_json::Value { } } +/// MCP git tools (`git_branch`, `git_status`, …) require `repo_path` inside the container. +/// Models often omit it; derive the mount root from workspace allow-list + host cwd (same rule as Tool Engine `/app/]… # add plain stdio server\n \ - pengine mcp remove # remove a server (and its custom_tool entry, if any)\n \ - pengine mcp import # merge a Claude Code mcpServers config\n\n\ - Common flags:\n \ - --header \"Key: value\" / --header Key=value # for HTTP servers\n \ - --env KEY=value # for stdio servers\n \ - --mount-workspace / --mount-rw / --append-roots # for Docker images\n \ - --direct-return # send tool output straight to the user (no model summarisation)", - }, NativeCommand { name: "skills", summary: "List, enable, or disable skills.", @@ -119,30 +87,6 @@ pub const COMMANDS: &[NativeCommand] = &[ summary: "Clear the REPL screen (REPL-only).", details: "Usage: /clear (REPL-only; same as Ctrl+L on most terminals)", }, - NativeCommand { - name: "compact", - summary: "Summarize the current REPL session and reset history (REPL-only).", - details: - "Usage: /compact\n\nGenerates a one-shot summary of the current session and seeds a fresh\nsession with the summary as context. Use when the conversation gets\ntoo long for the model's context window.", - }, - NativeCommand { - name: "resume", - summary: "Resume the most recent saved REPL session (REPL-only).", - details: - "Usage: /resume # in REPL\n pengine --continue # one-shot equivalent", - }, - NativeCommand { - name: "cost", - summary: "Show token usage and estimated cost for the current session.", - details: - "Usage: /cost\n\nShows prompt + completion tokens for the current REPL session, plus a\nrough cost estimate when running a cloud Ollama model.", - }, - NativeCommand { - name: "plan", - summary: "Toggle plan mode (read-only; agent produces plans, doesn't execute writes).", - details: - "Usage: /plan # toggle\n /plan on # force on\n /plan off # force off\n\nIn plan mode, the agent receives a planning system prompt and write tools\n(memory writes, fs writes) are removed from the tool catalog.", - }, NativeCommand { name: "exit", summary: "Exit the REPL.", diff --git a/src-tauri/src/modules/cli/dispatch.rs b/src-tauri/src/modules/cli/dispatch.rs index 9fc3d03..2110bae 100644 --- a/src-tauri/src/modules/cli/dispatch.rs +++ b/src-tauri/src/modules/cli/dispatch.rs @@ -74,14 +74,6 @@ async fn dispatch_native( } "version" => handlers::version(), "status" => handlers::status(state).await, - "doctor" => handlers::doctor(state).await, - "plan" => { - let action = rest.split_whitespace().next(); - handlers::plan(state, action).await - } - "cost" => handlers::cost(state).await, - "resume" => handlers::resume(state).await, - "compact" => handlers::compact(state).await, "clear" => handlers::clear(), "config" => { let kvs: Vec = rest.split_whitespace().map(str::to_string).collect(); @@ -109,10 +101,6 @@ async fn dispatch_native( let search = (!trimmed.is_empty()).then_some(trimmed); handlers::tools(state, search).await } - "mcp" => { - let (action, tail) = split_first(rest); - super::mcp_cmd::run_from_args(state, action, tail).await - } "skills" => { let (action, tail) = split_first(rest); let slug_tok = tail.trim(); diff --git a/src-tauri/src/modules/cli/doctor.rs b/src-tauri/src/modules/cli/doctor.rs deleted file mode 100644 index 5e1f45d..0000000 --- a/src-tauri/src/modules/cli/doctor.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! `pengine doctor` — probes each subsystem and prints a checklist. -//! -//! Adapter only: every check delegates to existing services. The handler in -//! [`super::handlers::doctor`] formats the report. - -use crate::modules::mcp::service as mcp_service; -use crate::modules::ollama::service as ollama; -use crate::modules::secure_store; -use crate::shared::state::AppState; -use std::time::Duration; - -#[derive(Debug, Clone)] -pub enum Status { - Ok, - Warn, - Fail, -} - -impl Status { - fn tag(&self) -> &'static str { - match self { - Status::Ok => "[ok]", - Status::Warn => "[warn]", - Status::Fail => "[fail]", - } - } -} - -#[derive(Debug, Clone)] -pub struct Check { - pub name: &'static str, - pub status: Status, - pub detail: String, -} - -pub async fn run(state: &AppState) -> Vec { - let mut out = Vec::new(); - out.push(check_store_writable(state).await); - out.push(check_ollama_reachable().await); - out.push(check_active_model().await); - out.push(check_mcp(state).await); - out.push(check_keychain(state).await); - out.push(check_network().await); - out -} - -async fn check_store_writable(state: &AppState) -> Check { - let parent = state.store_path.parent(); - let Some(p) = parent else { - return Check { - name: "store", - status: Status::Fail, - detail: "no parent directory".into(), - }; - }; - let probe = p.join(".pengine_doctor_probe"); - match std::fs::write(&probe, b"ok") { - Ok(()) => { - let _ = std::fs::remove_file(&probe); - Check { - name: "store", - status: Status::Ok, - detail: p.display().to_string(), - } - } - Err(e) => Check { - name: "store", - status: Status::Fail, - detail: format!("{}: {e}", p.display()), - }, - } -} - -async fn check_ollama_reachable() -> Check { - match tokio::time::timeout(Duration::from_millis(2000), ollama::active_model()).await { - Ok(Ok(m)) => Check { - name: "ollama", - status: Status::Ok, - detail: format!("daemon up; active={m}"), - }, - Ok(Err(e)) => Check { - name: "ollama", - status: Status::Fail, - detail: format!("{e} — is `ollama serve` running?"), - }, - Err(_) => Check { - name: "ollama", - status: Status::Fail, - detail: "timed out after 2s".into(), - }, - } -} - -async fn check_active_model() -> Check { - match tokio::time::timeout(Duration::from_millis(3000), ollama::model_catalog(2500)).await { - Ok(Ok(c)) => { - let n = c.models.len(); - if n == 0 { - Check { - name: "models", - status: Status::Warn, - detail: "no models installed (pull one via `ollama pull `)".into(), - } - } else { - Check { - name: "models", - status: Status::Ok, - detail: format!("{n} model(s) available"), - } - } - } - Ok(Err(e)) => Check { - name: "models", - status: Status::Warn, - detail: format!("could not list catalog: {e}"), - }, - Err(_) => Check { - name: "models", - status: Status::Warn, - detail: "model catalog timed out".into(), - }, - } -} - -async fn check_mcp(state: &AppState) -> Check { - match mcp_service::rebuild_registry_into_state(state).await { - Ok(()) => { - let n = state.mcp.read().await.tool_names().len(); - if n == 0 { - Check { - name: "mcp", - status: Status::Warn, - detail: "no tools registered (Dashboard → MCP Tools)".into(), - } - } else { - Check { - name: "mcp", - status: Status::Ok, - detail: format!("{n} tool(s) connected"), - } - } - } - Err(e) => Check { - name: "mcp", - status: Status::Fail, - detail: format!("registry rebuild failed: {e}"), - }, - } -} - -async fn check_keychain(state: &AppState) -> Check { - let bot_id = state - .connection - .lock() - .await - .as_ref() - .map(|c| c.bot_id.clone()); - let Some(id) = bot_id else { - return Check { - name: "keychain", - status: Status::Ok, - detail: "no bot connected (skipped)".into(), - }; - }; - match secure_store::load_token(&id) { - Ok(t) if !t.is_empty() => Check { - name: "keychain", - status: Status::Ok, - detail: format!("token present for bot {id}"), - }, - Ok(_) => Check { - name: "keychain", - status: Status::Warn, - detail: format!("entry empty for bot {id} — reconnect with `pengine bot connect`"), - }, - Err(e) => Check { - name: "keychain", - status: Status::Fail, - detail: format!("{e}"), - }, - } -} - -async fn check_network() -> Check { - let client = match reqwest::Client::builder() - .timeout(Duration::from_millis(2500)) - .build() - { - Ok(c) => c, - Err(e) => { - return Check { - name: "network", - status: Status::Warn, - detail: format!("reqwest: {e}"), - } - } - }; - // Generic outbound HTTPS probe — Cloudflare is widely reachable and - // returns quickly. We don't probe ollama.com here because that would - // conflate "no internet" with "Ollama Cloud product unreachable". - match client.head("https://1.1.1.1/").send().await { - Ok(_) => Check { - name: "network", - status: Status::Ok, - detail: "outbound https reachable".into(), - }, - Err(e) if e.is_timeout() => Check { - name: "network", - status: Status::Warn, - detail: "outbound https timeout (offline?)".into(), - }, - Err(e) => Check { - name: "network", - status: Status::Warn, - detail: format!("outbound https: {e}"), - }, - } -} - -pub fn format_report(checks: &[Check]) -> String { - let name_w = checks.iter().map(|c| c.name.len()).max().unwrap_or(6); - let mut out = String::new(); - for c in checks { - out.push_str(&format!( - " {:<6} {: u128 { diff --git a/src-tauri/src/modules/cli/folder_trust.rs b/src-tauri/src/modules/cli/folder_trust.rs index 64d7630..a009ced 100644 --- a/src-tauri/src/modules/cli/folder_trust.rs +++ b/src-tauri/src/modules/cli/folder_trust.rs @@ -1,15 +1,3 @@ -//! First-run "trust this folder" prompt for REPL launch. -//! -//! When `pengine` starts an interactive REPL inside a directory that is not -//! already covered by an MCP filesystem root, prompt the user to add it. The -//! decision is persisted to `$STORE/folder_trust.json` so we don't re-ask on -//! every launch. -//! -//! Skipped when: -//! - stdin is not a TTY (one-shot, scripted, or piped runs), -//! - the cwd is already under a trusted entry or an MCP fs root, -//! - the cwd is already on the deny list. - use crate::modules::mcp::service as mcp_service; use crate::shared::state::AppState; use serde::{Deserialize, Serialize}; @@ -28,14 +16,10 @@ pub struct FolderTrust { } impl FolderTrust { - /// True when the path is in `trusted` or `denied` (exact match — used to - /// avoid re-prompting after an explicit user decision). pub fn is_decided(&self, path: &Path) -> bool { self.trusted.iter().any(|p| p == path) || self.denied.iter().any(|p| p == path) } - /// True when the path lives under any previously-trusted entry. Lets a - /// single "yes" cover the whole subtree. pub fn is_under_trusted(&self, path: &Path) -> bool { self.trusted.iter().any(|t| path.starts_with(t)) } @@ -67,8 +51,6 @@ pub fn save(store_path: &Path, trust: &FolderTrust) -> Result<(), String> { pub enum PromptDecision { Yes, No, - /// User skipped (empty input, ambiguous answer, or non-TTY). The decision - /// is *not* persisted, so the next launch re-asks. Skip, } @@ -81,8 +63,6 @@ pub enum PromptOutcome { NotPrompted, } -/// Default prompt + answer reader. Writes the prompt to stdout so it is -/// visible in interactive terminals, then reads one line from stdin. fn ask(prompt: &str) -> PromptDecision { if !std::io::stdin().is_terminal() { return PromptDecision::Skip; @@ -107,8 +87,6 @@ fn parse_answer(line: &str) -> PromptDecision { } } -/// Run the prompt for `cwd`. Returns the outcome so the caller can render a -/// confirmation line in the same style as the rest of the REPL boot output. pub async fn maybe_prompt_for_cwd(state: &AppState, cwd: &Path) -> Result { let cwd = match fs::canonicalize(cwd) { Ok(p) => p, diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index a4b5bbb..7d24131 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -1,12 +1,4 @@ -//! PR 1 handlers — one function per native command. -//! -//! Rules: -//! - Each handler returns a [`CliReply`]; sinks render it. -//! - Handlers reuse existing module services (bot, mcp, ollama, skills, -//! user_settings). No duplicated business logic. - use super::commands::{self, NativeCommand}; -use super::doctor; use super::flavor; use super::mentions; use super::output::{fmt_elapsed, CliReply, Progress, ProgressStatus}; @@ -17,14 +9,13 @@ use crate::infrastructure::bot_lifecycle; use crate::modules::agent; use crate::modules::bot::{repository as bot_repo, token_verify}; use crate::modules::mcp::service as mcp_service; -use crate::modules::ollama::service::{self as ollama, ChatOptions, ModelInfo, ModelKind}; +use crate::modules::ollama::service::{self as ollama, ModelInfo}; use crate::modules::secure_store; use crate::modules::skills::service as skills_service; use crate::shared::state::{AppState, ConnectionData, ConnectionMetadata, LogEntry}; use crate::shared::user_settings; use chrono::Utc; use serde::Deserialize; -use serde_json::json; use std::io::{IsTerminal, Write}; use std::path::PathBuf; @@ -55,9 +46,7 @@ pub fn help(topic: Option<&str>) -> CliReply { -p, --print Non-interactive: run agent on and exit\n \ --output-format With -p: text (default), json, stream-json\n \ --continue Resume the most recent saved REPL session\n \ - -V, --version Print version and exit\n \ - --no-terminal Reserved for future sink routing\n \ - --no-telegram Reserved for future sink routing\n\n\ + -V, --version Print version and exit\n\n\ Run `pengine help ` (or `/help ` in the REPL) for command-specific usage.", ); CliReply::text(out.trim_end()) @@ -80,200 +69,10 @@ fn help_for_topic(topic: &str) -> CliReply { } } -/// `/clear` outside a REPL is a no-op error. The REPL itself intercepts the -/// command before dispatch, so this handler only fires from the Telegram -/// bridge or one-shot execution. pub fn clear() -> CliReply { CliReply::error("clear: only available inside the interactive REPL") } -pub async fn doctor(state: &AppState) -> CliReply { - let checks = doctor::run(state).await; - let any_fail = checks - .iter() - .any(|c| matches!(c.status, doctor::Status::Fail)); - let body = doctor::format_report(&checks); - if any_fail { - CliReply::error(format!("pengine doctor — issues found:\n\n{body}")) - } else { - CliReply::code("bash", format!("pengine doctor — all good\n\n{body}")) - } -} - -/// `/plan [on|off|toggle]` — toggles plan mode on the AppState. -pub async fn plan(state: &AppState, action: Option<&str>) -> CliReply { - let action = action.map(str::trim).unwrap_or("toggle"); - let mut guard = state.plan_mode.write().await; - let new_value = match action { - "on" | "enable" | "true" | "1" => true, - "off" | "disable" | "false" | "0" => false, - "toggle" | "" => !*guard, - other => { - return CliReply::error(format!( - "plan: unknown action `{other}` (use on | off | toggle)" - )) - } - }; - *guard = new_value; - if new_value { - CliReply::code( - "bash", - "plan mode: ON\n · agent will produce a markdown plan\n · write tools (memory writes, fs writes, edits) are stripped from the catalog", - ) - } else { - CliReply::code("bash", "plan mode: OFF") - } -} - -/// `/cost` — show token usage + rough cost estimate for the current session. -pub async fn cost(state: &AppState) -> CliReply { - let session = state.cli_session.read().await.clone(); - let Some(s) = session else { - return CliReply::code( - "bash", - "no active session — token totals available after the first /ask", - ); - }; - let model = state - .preferred_ollama_model - .read() - .await - .clone() - .unwrap_or_else(|| "".to_string()); - let kind = ollama::classify_model(&model); - let cost_line = match kind { - ModelKind::Local => " est_cost: $0.00 (local model)".to_string(), - ModelKind::Cloud => { - // Conservative blended estimate: $1 / 1M prompt + $3 / 1M completion. - // Pengine doesn't have per-model pricing; this is an upper-bound hint. - let in_cost = (s.prompt_tokens_total as f64) * 1.0e-6; - let out_cost = (s.eval_tokens_total as f64) * 3.0e-6; - format!( - " est_cost: ~${:.4} (cloud, rough $1/$3 per M in/out)", - in_cost + out_cost - ) - } - }; - let body = format!( - "session: {}\n turns: {}\n tokens_in: {}\n tokens_out: {}\n model: {}\n{}", - s.id, - s.turns.len(), - s.prompt_tokens_total, - s.eval_tokens_total, - model, - cost_line - ); - CliReply::code("bash", body) -} - -/// `/resume` — load the most recent saved session into AppState. -pub async fn resume(state: &AppState) -> CliReply { - match session::load_last(&state.store_path) { - Ok(Some(s)) => { - let summary_line = if s.summary.is_some() { - " summary: present (set by /compact)\n" - } else { - "" - }; - let body = format!( - "resumed session: {}\n started: {}\n turns: {}\n{} tokens_in: {}\n tokens_out: {}", - s.id, s.started_at, s.turns.len(), summary_line, s.prompt_tokens_total, s.eval_tokens_total - ); - *state.cli_session.write().await = Some(s); - CliReply::code("bash", body) - } - Ok(None) => CliReply::code("bash", "no saved session to resume"), - Err(e) => CliReply::error(format!("resume: {e}")), - } -} - -/// `/compact` — summarize the current session and reset turns. The summary is -/// kept on the session and prefixed to future user messages. -pub async fn compact(state: &AppState) -> CliReply { - let snapshot = state.cli_session.read().await.clone(); - let Some(mut s) = snapshot else { - return CliReply::code("bash", "no active session to compact"); - }; - if s.turns.is_empty() && s.summary.is_none() { - return CliReply::code("bash", "session has no turns yet — nothing to compact"); - } - - let mut transcript = String::new(); - if let Some(prev) = s.summary.as_deref() { - transcript.push_str("Prior summary:\n"); - transcript.push_str(prev); - transcript.push_str("\n\n"); - } - for t in &s.turns { - transcript.push_str(&format!( - "[user] {}\n[assistant] {}\n", - t.user.trim(), - t.assistant.trim() - )); - } - - let mut model = state - .preferred_ollama_model - .read() - .await - .clone() - .unwrap_or_else(String::new); - if model.is_empty() { - model = match ollama::active_model().await { - Ok(m) => m, - Err(e) => return CliReply::error(format!("compact: ollama: {e}")), - }; - } - - let messages = json!([ - { - "role": "system", - "content": "You compress a chat transcript. Output a tight markdown summary covering: (1) topics, (2) decisions, (3) outstanding tasks. Max 250 words. No chain-of-thought." - }, - { - "role": "user", - "content": format!("Compress this transcript:\n\n{transcript}") - } - ]); - let opts = ChatOptions { - think: Some(false), - num_predict: Some(512), - temperature: Some(0.3), - ..ChatOptions::default() - }; - let result = match ollama::chat_with_tools(&model, &messages, &json!([]), &opts).await { - Ok(r) => r, - Err(e) => return CliReply::error(format!("compact: model: {e}")), - }; - let summary_text = result - .message - .get("content") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if summary_text.is_empty() { - return CliReply::error("compact: model returned empty summary"); - } - - let prior_turn_count = s.turns.len(); - let summary_chars = summary_text.chars().count(); - s.summary = Some(summary_text); - s.turns.clear(); - if let Err(e) = session::save(&state.store_path, &s) { - return CliReply::error(format!("compact: save: {e}")); - } - let id = s.id.clone(); - *state.cli_session.write().await = Some(s); - - CliReply::code( - "bash", - format!( - "compacted: {prior_turn_count} turn(s) → summary ({summary_chars} chars), session `{id}` saved" - ), - ) -} - pub fn version() -> CliReply { CliReply::text(format!( "pengine {} ({})", @@ -303,11 +102,24 @@ pub async fn status(state: &AppState) -> CliReply { let mcp_tools = state.mcp.read().await.tool_names().len(); let skills_cap = *state.skills_hint_max_bytes.read().await; + let session_line = { + let snap = state.cli_session.read().await.clone(); + match snap { + Some(s) => format!( + "session: turns={} tokens_in={} tokens_out={}", + s.turns.len(), + s.prompt_tokens_total, + s.eval_tokens_total + ), + None => "session: no active CLI session".to_string(), + } + }; let body = format!( "{bot_line}\n\ ollama: active={active} preferred={preferred}\n\ mcp: {mcp_tools} tool(s) connected\n\ + {session_line}\n\ settings: skills_hint_max_bytes={skills_cap}\n\ store: {}", state.store_path.display(), @@ -470,9 +282,6 @@ pub async fn model(state: &AppState, name: Option<&str>, clear: bool) -> CliRepl reply } -/// `bot connect ` — verify, persist, save keychain. Does NOT spawn the -/// bot (the CLI one-shot process would exit). The running desktop app or a -/// REPL session picks up the stored metadata + keychain token. pub async fn bot_connect(state: &AppState, token: &str) -> CliReply { let token = token.trim(); if token.is_empty() { @@ -530,8 +339,6 @@ pub async fn bot_disconnect(state: &AppState) -> CliReply { CliReply::code("bash", "disconnected and cleared store") } -/// `tools [search]` — list MCP tools from the live registry. -/// The registry is assumed warmed by the caller (bootstrap / REPL startup). pub async fn tools(state: &AppState, search: Option<&str>) -> CliReply { let reg = state.mcp.read().await; let mut rows: Vec<(String, String, String)> = reg @@ -674,11 +481,6 @@ pub async fn fs(state: &AppState, action: Option<&str>, path: Option<&str>) -> C } } -/// `logs` — three modes: -/// - `--follow` (live) subscribes to the in-memory broadcast and prints each event. -/// - `--tail N` reads the newest N lines from the audit NDJSON files on disk, -/// walking back day-by-day until N is reached or no older file remains. -/// - default (no flag) tails the last 50 lines — the common "what just happened" case. pub async fn logs(state: &AppState, tail: Option, follow: bool) -> CliReply { if follow { return follow_logs_from_broadcast(state).await; @@ -774,6 +576,14 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) } let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + if persist_session { + let mut guard = state.cli_session.write().await; + if guard.is_none() { + *guard = Some(CliSession::fresh_with_project( + session::detect_project_context(&cwd), + )); + } + } let allowed_roots: Vec = state .cached_filesystem_paths .read() @@ -816,7 +626,12 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) } if persist_session { let mut guard = state.cli_session.write().await; - let session = guard.get_or_insert_with(CliSession::fresh); + let session = guard.get_or_insert_with(|| { + CliSession::fresh_with_project(session::detect_project_context(&cwd)) + }); + if session.project.is_none() { + session.project = Some(session::detect_project_context(&cwd)); + } session.record_turn( &expanded.message, &turn.text, diff --git a/src-tauri/src/modules/cli/mcp_cmd.rs b/src-tauri/src/modules/cli/mcp_cmd.rs deleted file mode 100644 index c6144bd..0000000 --- a/src-tauri/src/modules/cli/mcp_cmd.rs +++ /dev/null @@ -1,525 +0,0 @@ -//! `pengine mcp` CLI — list/add/remove/import MCP servers from the terminal. -//! -//! Three install paths: -//! - **Docker image** (`add --image `): registers a `CustomToolEntry` and -//! pulls the image via the existing Tool Engine flow (podman/docker). The -//! image is run as a stdio MCP server inside the container. -//! - **HTTP** (`add --url [--header K=V]`): adds an [`ServerEntry::Http`] -//! for remote streamable-HTTP servers (Claude Code's `"type": "http"` shape). -//! - **stdio** (`add --command [--arg ]…`): plain child-process MCP -//! server, no container. Use this for Node `npx` servers when you don't want -//! the Docker wrap. -//! -//! `import ` reads a Claude Code-style `mcp.json` (`mcpServers: {…}`) -//! and merges its servers into pengine's global `mcp.json`. - -use super::output::CliReply; -use crate::modules::mcp::service as mcp_service; -use crate::modules::mcp::types::{CustomToolEntry, ServerEntry}; -use crate::modules::tool_engine::runtime::detect_runtime; -use crate::modules::tool_engine::service as tool_engine; -use crate::shared::state::AppState; -use std::collections::HashMap; -use std::path::Path; - -/// Args parsed from the CLI surface; identical regardless of whether the call -/// came from `pengine mcp add …`, `/mcp add …` in REPL, or the Telegram bridge. -#[derive(Debug, Default)] -pub struct AddArgs { - pub name: String, - pub url: Option, - pub headers: Vec<(String, String)>, - pub image: Option, - pub mcp_server_cmd: Vec, - pub mount_workspace: bool, - pub mount_read_only: bool, - pub append_workspace_roots: bool, - pub command: Option, - pub stdio_args: Vec, - pub stdio_env: Vec<(String, String)>, - pub direct_return: bool, -} - -pub async fn list(state: &AppState) -> CliReply { - let cfg = match mcp_service::load_or_init_config(&state.mcp_config_path) { - Ok(c) => c, - Err(e) => return CliReply::error(format!("mcp list: {e}")), - }; - if cfg.servers.is_empty() { - return CliReply::code("bash", "(no MCP servers configured)"); - } - let name_w = cfg.servers.keys().map(String::len).max().unwrap_or(0); - let mut out = String::new(); - for (name, entry) in &cfg.servers { - let (kind, detail) = describe_entry(entry); - out.push_str(&format!( - " {kind:<6} {name: CliReply { - if args.name.trim().is_empty() { - return CliReply::error("mcp add: name is required"); - } - - let modes_set = u8::from(args.url.is_some()) - + u8::from(args.image.is_some()) - + u8::from(args.command.is_some()); - if modes_set != 1 { - return CliReply::error( - "mcp add: pick exactly one of --url , --image , or --command ", - ); - } - - if let Some(url) = args.url.clone() { - add_http(state, &args.name, url, args.headers, args.direct_return).await - } else if let Some(image) = args.image.clone() { - add_docker(state, &args.name, image, &args).await - } else if let Some(command) = args.command.clone() { - add_stdio(state, &args.name, command, &args).await - } else { - // Unreachable: `modes_set == 1` guarantees exactly one branch above - // matches. Keep a structured error rather than `unreachable!()` so a - // future bug in the count check still surfaces a CliReply. - CliReply::error("mcp add: internal error (no install path matched)") - } -} - -pub async fn remove(state: &AppState, name: &str) -> CliReply { - let name = name.trim(); - if name.is_empty() { - return CliReply::error("mcp remove: name is required"); - } - let _guard = state.mcp_config_mutex.lock().await; - let mut cfg = match mcp_service::load_or_init_config(&state.mcp_config_path) { - Ok(c) => c, - Err(e) => return CliReply::error(format!("mcp remove: {e}")), - }; - - // Custom Docker tools live under both `custom_tools[]` and `servers[te_custom_]`. - let custom_idx = cfg.custom_tools.iter().position(|t| t.key == name); - let direct_key_present = cfg.servers.contains_key(name); - let custom_server_key = format!("te_custom_{name}"); - let custom_present = cfg.servers.contains_key(&custom_server_key); - - if !direct_key_present && custom_idx.is_none() && !custom_present { - return CliReply::error(format!("mcp remove: `{name}` not found")); - } - - if let Some(i) = custom_idx { - cfg.custom_tools.remove(i); - } - cfg.servers.remove(name); - cfg.servers.remove(&custom_server_key); - - if let Err(e) = mcp_service::save_config(&state.mcp_config_path, &cfg) { - return CliReply::error(format!("mcp remove: save: {e}")); - } - CliReply::code("bash", format!("removed `{name}` (mcp.json updated)")) -} - -pub async fn import(state: &AppState, path: &str) -> CliReply { - let path = Path::new(path.trim()); - if !path.exists() { - return CliReply::error(format!("mcp import: file not found: {}", path.display())); - } - let raw = match std::fs::read_to_string(path) { - Ok(r) => r, - Err(e) => return CliReply::error(format!("mcp import: read: {e}")), - }; - let value: serde_json::Value = match serde_json::from_str(&raw) { - Ok(v) => v, - Err(e) => return CliReply::error(format!("mcp import: parse: {e}")), - }; - let new_servers = match mcp_service::parse_claude_mcp_servers(&value) { - Ok(s) => s, - Err(e) => return CliReply::error(format!("mcp import: {e}")), - }; - - let _guard = state.mcp_config_mutex.lock().await; - let mut cfg = match mcp_service::load_or_init_config(&state.mcp_config_path) { - Ok(c) => c, - Err(e) => return CliReply::error(format!("mcp import: load: {e}")), - }; - - let mut added: Vec = Vec::new(); - let mut overwritten: Vec = Vec::new(); - for (name, entry) in new_servers { - if cfg.servers.contains_key(&name) { - overwritten.push(name.clone()); - } else { - added.push(name.clone()); - } - cfg.servers.insert(name, entry); - } - - if added.is_empty() && overwritten.is_empty() { - return CliReply::code( - "bash", - "import: nothing to add (file contained zero servers)", - ); - } - - if let Err(e) = mcp_service::save_config(&state.mcp_config_path, &cfg) { - return CliReply::error(format!("mcp import: save: {e}")); - } - - let mut body = String::new(); - if !added.is_empty() { - body.push_str(&format!("added: {}\n", added.join(", "))); - } - if !overwritten.is_empty() { - body.push_str(&format!("overwritten: {}\n", overwritten.join(", "))); - } - body.push_str("\nRun `pengine status` after MCP warmup to see new tools."); - CliReply::code("bash", body.trim().to_string()) -} - -async fn add_http( - state: &AppState, - name: &str, - url: String, - headers: Vec<(String, String)>, - direct_return: bool, -) -> CliReply { - let header_map: HashMap = headers.into_iter().collect(); - let entry = ServerEntry::Http { - url: url.clone(), - headers: header_map, - direct_return, - }; - if let Err(e) = upsert_and_save(state, name.to_string(), entry).await { - return CliReply::error(format!("mcp add: {e}")); - } - CliReply::code( - "bash", - format!("added http server `{name}` → {url}\n Run `/tools` (or restart the REPL) to refresh the live registry."), - ) -} - -async fn add_stdio(state: &AppState, name: &str, command: String, args: &AddArgs) -> CliReply { - let entry = ServerEntry::Stdio { - command: command.clone(), - args: args.stdio_args.clone(), - env: args.stdio_env.clone().into_iter().collect(), - direct_return: args.direct_return, - private_host_path: None, - catalog_passthrough_keys: Vec::new(), - }; - if let Err(e) = upsert_and_save(state, name.to_string(), entry).await { - return CliReply::error(format!("mcp add: {e}")); - } - let argv = std::iter::once(command.as_str()) - .chain(args.stdio_args.iter().map(String::as_str)) - .collect::>() - .join(" "); - CliReply::code( - "bash", - format!("added stdio server `{name}` → {argv}\n Run `/tools` (or restart the REPL) to refresh the live registry."), - ) -} - -async fn add_docker(state: &AppState, name: &str, image: String, args: &AddArgs) -> CliReply { - let runtime = match detect_runtime().await { - Some(r) => r, - None => { - return CliReply::error( - "mcp add --image: no container runtime found (install podman or docker)", - ) - } - }; - let entry = CustomToolEntry { - key: name.to_string(), - name: name.to_string(), - image: image.clone(), - mcp_server_cmd: args.mcp_server_cmd.clone(), - mount_workspace: args.mount_workspace, - mount_read_only: args.mount_read_only, - append_workspace_roots: args.append_workspace_roots, - direct_return: args.direct_return, - }; - let log: tool_engine::LogFn = { - let state = state.clone(); - Box::new(move |line| { - let state = state.clone(); - let line = line.to_string(); - // emit_log is async; spawn a fire-and-forget so the install thread - // can keep streaming pull progress. - tokio::spawn(async move { - state.emit_log("mcp", &line).await; - }); - }) - }; - match tool_engine::add_custom_tool( - entry, - &runtime, - &state.mcp_config_path, - &state.mcp_config_mutex, - &log, - ) - .await - { - Ok(()) => CliReply::code( - "bash", - format!( - "installed Docker MCP server `{name}` → {image}\n Run `/tools` (or restart the REPL) to refresh the live registry." - ), - ), - Err(e) => CliReply::error(format!("mcp add --image: {e}")), - } -} - -async fn upsert_and_save(state: &AppState, name: String, entry: ServerEntry) -> Result<(), String> { - let _guard = state.mcp_config_mutex.lock().await; - let mut cfg = mcp_service::load_or_init_config(&state.mcp_config_path)?; - cfg.servers.insert(name, entry); - mcp_service::save_config(&state.mcp_config_path, &cfg) -} - -fn describe_entry(entry: &ServerEntry) -> (&'static str, String) { - match entry { - ServerEntry::Native { id } => ("native", format!("id={id}")), - ServerEntry::Stdio { - command, - args, - direct_return, - .. - } => { - let argv = if args.is_empty() { - command.clone() - } else { - format!("{command} {}", args.join(" ")) - }; - let dr = if *direct_return { - " [direct_return]" - } else { - "" - }; - ("stdio", format!("{argv}{dr}")) - } - ServerEntry::Http { - url, - headers, - direct_return, - } => { - let dr = if *direct_return { - " [direct_return]" - } else { - "" - }; - let h = if headers.is_empty() { - String::new() - } else { - format!( - " headers=[{}]", - headers.keys().cloned().collect::>().join(",") - ) - }; - ("http", format!("{url}{h}{dr}")) - } - } -} - -/// Slash/native dispatch entry point. Parses `rest` (whitespace-tokenized) into -/// a sub-action + AddArgs and runs the right handler. Kept here so REPL, -/// Telegram bridge, and one-shot CLI all share the same parser. -pub async fn run_from_args(state: &AppState, action: &str, rest: &str) -> CliReply { - match action { - "" | "list" => list(state).await, - "add" => match parse_add_args(rest) { - Ok(args) => add(state, args).await, - Err(e) => CliReply::error(format!("mcp add: {e}")), - }, - "remove" | "rm" => { - let name = rest.split_whitespace().next().unwrap_or(""); - remove(state, name).await - } - "import" => { - let path = rest.trim(); - if path.is_empty() { - return CliReply::error("mcp import: path required"); - } - import(state, path).await - } - other => CliReply::error(format!( - "mcp: unknown action `{other}` (use list | add | remove | import)" - )), - } -} - -/// Tiny flag parser for `add` — kept here so we never reach for `clap` from a -/// hot dispatch path. Accepts `--flag value`, `--flag=value`, and repeating -/// `--arg`/`--header` for argv/header lists. -pub fn parse_add_args(rest: &str) -> Result { - let mut out = AddArgs { - mount_read_only: true, - ..AddArgs::default() - }; - let tokens: Vec = shellish_split(rest)?; - let mut i = 0; - while i < tokens.len() { - let tok = &tokens[i]; - let (flag, inline_val) = match tok.split_once('=') { - Some((k, v)) if k.starts_with("--") => (k.to_string(), Some(v.to_string())), - _ => (tok.clone(), None), - }; - let take_value = |out_idx: &mut usize, label: &str| -> Result { - if let Some(v) = inline_val.clone() { - return Ok(v); - } - *out_idx += 1; - if *out_idx >= tokens.len() { - return Err(format!("{label} requires a value")); - } - Ok(tokens[*out_idx].clone()) - }; - match flag.as_str() { - "--url" => out.url = Some(take_value(&mut i, "--url")?), - "--image" => out.image = Some(take_value(&mut i, "--image")?), - "--command" => out.command = Some(take_value(&mut i, "--command")?), - "--arg" => out.stdio_args.push(take_value(&mut i, "--arg")?), - "--cmd" => out.mcp_server_cmd.push(take_value(&mut i, "--cmd")?), - "--header" => { - let raw = take_value(&mut i, "--header")?; - let (k, v) = raw - .split_once(':') - .or_else(|| raw.split_once('=')) - .ok_or_else(|| { - format!("--header `{raw}`: expected `Key: value` or `Key=value`") - })?; - out.headers - .push((k.trim().to_string(), v.trim().to_string())); - } - "--env" => { - let raw = take_value(&mut i, "--env")?; - let (k, v) = raw - .split_once('=') - .ok_or_else(|| format!("--env `{raw}`: expected `KEY=value`"))?; - out.stdio_env - .push((k.trim().to_string(), v.trim().to_string())); - } - "--mount-workspace" => out.mount_workspace = true, - "--mount-rw" => out.mount_read_only = false, - "--append-roots" => out.append_workspace_roots = true, - "--direct-return" => out.direct_return = true, - other if other.starts_with('-') => return Err(format!("unknown flag `{other}`")), - // Positional: first non-flag token is the server name. - _ => { - if out.name.is_empty() { - out.name = tok.clone(); - } else { - return Err(format!("unexpected positional `{tok}`")); - } - } - } - i += 1; - } - if out.name.is_empty() { - return Err("name is required (first positional argument)".to_string()); - } - Ok(out) -} - -/// Minimal shell-style splitter that honours single + double quotes. Avoids a -/// new crate just for this; MCP argv values rarely need anything fancier. -fn shellish_split(input: &str) -> Result, String> { - let mut out = Vec::new(); - let mut cur = String::new(); - let mut in_single = false; - let mut in_double = false; - let mut iter = input.chars().peekable(); - while let Some(c) = iter.next() { - match c { - '\'' if !in_double => in_single = !in_single, - '"' if !in_single => in_double = !in_double, - '\\' if !in_single => { - if let Some(next) = iter.next() { - cur.push(next); - } - } - ws if ws.is_whitespace() && !in_single && !in_double => { - if !cur.is_empty() { - out.push(std::mem::take(&mut cur)); - } - } - other => cur.push(other), - } - } - if in_single || in_double { - return Err("unbalanced quote".to_string()); - } - if !cur.is_empty() { - out.push(cur); - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn shellish_split_handles_quotes() { - let v = shellish_split(r#"a "b c" 'd e' f"#).unwrap(); - assert_eq!(v, vec!["a", "b c", "d e", "f"]); - } - - #[test] - fn shellish_split_rejects_unbalanced_quote() { - assert!(shellish_split("\"oops").is_err()); - } - - #[test] - fn parse_add_url_with_header() { - let a = - parse_add_args("gh --url https://x.example/mcp --header \"Authorization: Bearer t\"") - .unwrap(); - assert_eq!(a.name, "gh"); - assert_eq!(a.url.as_deref(), Some("https://x.example/mcp")); - assert_eq!(a.headers, vec![("Authorization".into(), "Bearer t".into())]); - } - - #[test] - fn parse_add_image_with_flags() { - let a = parse_add_args( - "fs --image ghcr.io/example/server-fs:latest --mount-workspace --append-roots", - ) - .unwrap(); - assert_eq!(a.name, "fs"); - assert_eq!(a.image.as_deref(), Some("ghcr.io/example/server-fs:latest")); - assert!(a.mount_workspace); - assert!(a.append_workspace_roots); - } - - #[test] - fn parse_add_stdio_with_args_and_env() { - let a = parse_add_args( - "echo --command npx --arg -y --arg @scope/server --env FOO=bar --env BAZ=qux", - ) - .unwrap(); - assert_eq!(a.command.as_deref(), Some("npx")); - assert_eq!(a.stdio_args, vec!["-y", "@scope/server"]); - assert_eq!( - a.stdio_env, - vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into())] - ); - } - - #[test] - fn parse_add_rejects_no_name() { - let err = parse_add_args("--url https://x").unwrap_err(); - assert!(err.contains("name is required")); - } - - #[test] - fn parse_add_accepts_inline_eq() { - let a = parse_add_args("gh --url=https://x.example/mcp").unwrap(); - assert_eq!(a.url.as_deref(), Some("https://x.example/mcp")); - } -} diff --git a/src-tauri/src/modules/cli/mod.rs b/src-tauri/src/modules/cli/mod.rs index 16aa45f..fa24cbb 100644 --- a/src-tauri/src/modules/cli/mod.rs +++ b/src-tauri/src/modules/cli/mod.rs @@ -17,16 +17,13 @@ pub mod banner; pub mod bootstrap; pub mod commands; pub mod dispatch; -pub mod doctor; pub mod flavor; pub mod folder_trust; pub mod handlers; -pub mod mcp_cmd; pub mod mentions; pub mod output; pub mod repl; pub mod router; pub mod session; pub mod shim; -pub mod syntax_highlight; pub mod telegram_bridge; diff --git a/src-tauri/src/modules/cli/output.rs b/src-tauri/src/modules/cli/output.rs index 73f5f12..14241b5 100644 --- a/src-tauri/src/modules/cli/output.rs +++ b/src-tauri/src/modules/cli/output.rs @@ -84,6 +84,12 @@ pub struct JsonEnvelope<'a> { /// Rendering target. Implementers must be thread-safe for later FanOut usage. pub trait OutputSink: Send + Sync { fn render(&self, reply: &CliReply); + + /// When true, [`render_with_prefix`] may print unified diffs line-by-line with ANSI + /// (so `+`/`-` detection still works alongside REPL indentation). JSON sinks keep `false`. + fn prefer_line_oriented_diff(&self) -> bool { + false + } } /// Default: ANSI colors on TTYs, plain text otherwise. Prompt lines ("user@pengine:~$") @@ -112,6 +118,10 @@ impl Default for TerminalSink { } impl OutputSink for TerminalSink { + fn prefer_line_oriented_diff(&self) -> bool { + self.color + } + fn render(&self, reply: &CliReply) { match &reply.kind { ReplyKind::Text => println!("{}", reply.body), @@ -129,19 +139,8 @@ impl OutputSink for TerminalSink { } } ReplyKind::CodeBlock { lang } => { - if self.color { - if let Some(lines) = - super::syntax_highlight::highlight_fence_body(lang, &reply.body) - { - for line in &lines { - println!("{line}\x1b[0m"); - } - } else { - println!("{}", reply.body); - } - } else { - println!("{}", reply.body); - } + let _ = lang; + println!("{}", reply.body); } ReplyKind::Log => { println!("{}", reply.body); @@ -170,17 +169,51 @@ impl OutputSink for JsonSink { } } +/// Dark-terminal palette (truecolor + bold). Background strips improve scanability on +/// charcoal backgrounds without relying on low-contrast default 16-color green/red. +const DIFF_RESET: &str = "\x1b[0m"; +const DIFF_CTX: &str = "\x1b[38;2;139;148;158m"; // muted slate (context) +const DIFF_META: &str = "\x1b[38;2;180;190;200m"; // diff --git, index, rename… +const DIFF_FILE_HDR: &str = "\x1b[1;38;2;121;192;255m"; // +++ / --- +const DIFF_HUNK: &str = "\x1b[1;38;2;242;204;96m"; // @@ hunk headers +const DIFF_ADD_FG: &str = "\x1b[38;2;80;250;123m"; +const DIFF_ADD_BG: &str = "\x1b[48;2;20;45;28m"; +const DIFF_DEL_FG: &str = "\x1b[38;2;255;123;123m"; +const DIFF_DEL_BG: &str = "\x1b[48;2;45;22;24m"; + +fn style_diff_line(line: &str) -> String { + if line.starts_with("+++") || line.starts_with("---") { + return format!("{DIFF_FILE_HDR}{line}{DIFF_RESET}"); + } + if line.starts_with("@@") { + return format!("{DIFF_HUNK}{line}{DIFF_RESET}"); + } + if line.starts_with('+') { + return format!("{DIFF_ADD_BG}{DIFF_ADD_FG}{line}{DIFF_RESET}"); + } + if line.starts_with('-') { + return format!("{DIFF_DEL_BG}{DIFF_DEL_FG}{line}{DIFF_RESET}"); + } + if line.starts_with("diff --git ") + || line.starts_with("index ") + || line.starts_with("new file mode ") + || line.starts_with("deleted file mode ") + || line.starts_with("similarity index ") + || line.starts_with("rename from ") + || line.starts_with("rename to ") + || line.starts_with("Binary files ") + { + return format!("{DIFF_META}{line}{DIFF_RESET}"); + } + if line.is_empty() { + return String::new(); + } + format!("{DIFF_CTX}{line}{DIFF_RESET}") +} + fn print_diff_with_ansi(body: &str) { for line in body.lines() { - if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") { - println!("\x1b[1;36m{line}\x1b[0m"); // cyan, bold for headers - } else if line.starts_with('+') { - println!("\x1b[32m{line}\x1b[0m"); // green - } else if line.starts_with('-') { - println!("\x1b[31m{line}\x1b[0m"); // red - } else { - println!("{line}"); - } + println!("{}", style_diff_line(line)); } } @@ -282,54 +315,14 @@ fn render_reply_indented(sink: &dyn OutputSink, reply: &CliReply) { } else { FirstPrefix::None }; - match &part.kind { - ReplyKind::CodeBlock { .. } => { - try_render_highlighted_code_block(sink, part, prefix); - } - _ => render_with_prefix(sink, part, prefix), - } + render_with_prefix(sink, part, prefix); } } - ReplyKind::CodeBlock { .. } => { - try_render_highlighted_code_block(sink, reply, FirstPrefix::Repl); - } + ReplyKind::CodeBlock { .. } => render_with_prefix(sink, reply, FirstPrefix::Repl), _ => render_with_prefix(sink, reply, FirstPrefix::Repl), } } -/// When stdout is a TTY, paint fenced code with a dark theme; otherwise indent as plain text. -fn try_render_highlighted_code_block(sink: &dyn OutputSink, reply: &CliReply, first: FirstPrefix) { - let ReplyKind::CodeBlock { lang } = &reply.kind else { - render_with_prefix(sink, reply, first); - return; - }; - if is_terminal_stdout() { - if let Some(lines) = super::syntax_highlight::highlight_fence_body(lang, &reply.body) { - print_highlighted_lines_prefixed(&lines, first); - return; - } - } - render_with_prefix(sink, reply, first); -} - -fn print_highlighted_lines_prefixed(lines: &[String], first: FirstPrefix) { - let color = is_terminal_stdout(); - let (first_p, cont_p) = match first { - FirstPrefix::Repl => { - if color { - (REPL_FIRST_PREFIX, REPL_CONT_PREFIX) - } else { - (REPL_FIRST_PREFIX_PLAIN, REPL_CONT_PREFIX) - } - } - FirstPrefix::None => (REPL_CONT_PREFIX, REPL_CONT_PREFIX), - }; - for (i, line) in lines.iter().enumerate() { - let p = if i == 0 { first_p } else { cont_p }; - println!("{p}{line}\x1b[0m"); - } -} - #[derive(Clone, Copy)] enum FirstPrefix { /// Indent the first line with ` ⎿ ` (or plain equivalent if no TTY). @@ -339,6 +332,11 @@ enum FirstPrefix { } fn render_with_prefix(sink: &dyn OutputSink, reply: &CliReply, first: FirstPrefix) { + if matches!(reply.kind, ReplyKind::Diff) && sink.prefer_line_oriented_diff() { + render_diff_with_repl_prefix(reply.body.as_str(), first); + return; + } + let color = is_terminal_stdout(); let (first_prefix, cont_prefix) = match first { FirstPrefix::Repl => { @@ -379,6 +377,26 @@ fn indent_body(body: &str, first_prefix: &str, cont_prefix: &str) -> String { out } +/// REPL-style prefixes on each line, with per-line diff ANSI (adds/removes stay highlighted). +fn render_diff_with_repl_prefix(body: &str, first: FirstPrefix) { + let color = is_terminal_stdout(); + let (first_prefix, cont_prefix) = match first { + FirstPrefix::Repl => { + if color { + (REPL_FIRST_PREFIX, REPL_CONT_PREFIX) + } else { + (REPL_FIRST_PREFIX_PLAIN, REPL_CONT_PREFIX) + } + } + FirstPrefix::None => (REPL_CONT_PREFIX, REPL_CONT_PREFIX), + }; + for (i, line) in body.lines().enumerate() { + let p = if i == 0 { first_prefix } else { cont_prefix }; + let body_line = style_diff_line(line); + println!("{p}{body_line}"); + } +} + static MD_FENCE_RE: OnceLock = OnceLock::new(); fn md_fence_regex() -> &'static Regex { @@ -691,4 +709,27 @@ mod tests { ))); assert!(repl_reply_use_section_chrome(&CliReply::diff("+x"))); } + + #[test] + fn style_diff_line_tags_add_remove_and_hunk() { + let add = super::style_diff_line("+foo"); + assert!(add.contains("+foo")); + assert!(add.contains("\x1b[48;2;20;45;28m"), "add line uses dark green bg"); + let del = super::style_diff_line("-bar"); + assert!(del.contains("-bar")); + assert!(del.contains("\x1b[48;2;45;22;24m"), "del line uses dark red bg"); + let hunk = super::style_diff_line("@@ -1,2 +1,3 @@"); + assert!(hunk.contains("@@")); + assert!(hunk.contains("\x1b[1;38;2;242;204;96m")); + let ctx = super::style_diff_line(" context"); + assert!(ctx.contains("context")); + assert!(ctx.contains("\x1b[38;2;139;148;158m")); + } + + #[test] + fn style_diff_line_file_headers_not_confused_with_plus() { + let hdr = super::style_diff_line("+++ b/src/main.rs"); + assert!(hdr.contains("+++")); + assert!(!hdr.contains("\x1b[48;2;20;45;28m"), "+++ must not use add-line bg"); + } } diff --git a/src-tauri/src/modules/cli/repl.rs b/src-tauri/src/modules/cli/repl.rs index 6970544..070c03e 100644 --- a/src-tauri/src/modules/cli/repl.rs +++ b/src-tauri/src/modules/cli/repl.rs @@ -9,6 +9,7 @@ use super::dispatch::{dispatch_line, format_repl_line_for_audit, DispatchContext use super::flavor; use super::folder_trust::{self, PromptOutcome}; use super::output::{render_reply, CliReply, OutputSink, RenderStyle, TerminalSink}; +use super::session::{self, CliSession}; use crate::modules::mcp::service as mcp_service; use crate::shared::state::AppState; use rustyline::error::ReadlineError; @@ -34,13 +35,38 @@ const PROMPT_PLAIN: &str = "> "; pub async fn run(state: &AppState) -> CliReply { let sink = TerminalSink::new(); + + // Capture project context (cwd + git root + branch) and preset the + // session so banners, persistence, and per-folder `--continue` all + // use the same identity. If `--continue` already loaded a session + // for this folder, keep it; otherwise create a fresh one. + let project = std::env::current_dir() + .ok() + .map(|cwd| session::detect_project_context(&cwd)); + { + let mut guard = state.cli_session.write().await; + match guard.as_mut() { + Some(existing) if existing.project.is_none() => { + existing.project = project.clone(); + } + Some(_) => {} + None => { + *guard = Some(match project.clone() { + Some(p) => CliSession::fresh_with_project(p), + None => CliSession::fresh(), + }); + } + } + } + sink.render(&CliReply::text(format!( "{}\ \n\ Pengine REPL — slash commands + free text; /exit or Ctrl+D to quit.\n\ -store: {}", +store: {}{}", CLI_WELCOME.trim_start_matches('\n'), - state.store_path.display() + state.store_path.display(), + format_project_banner_lines(project.as_ref()), ))); if std::io::stdout().is_terminal() { sink.render(&CliReply::text(format!( @@ -250,3 +276,63 @@ fn is_exit(line: &str) -> bool { let t = line.trim(); matches!(t, "/exit" | "/quit" | "exit" | "quit") } + +/// Render the `project:` and `branch:` banner lines, abbreviating `$HOME` to +/// `~` so the start screen stays readable on long paths. Returns an empty +/// string when there is no project context (no cwd available). +fn format_project_banner_lines(project: Option<&session::ProjectContext>) -> String { + let Some(project) = project else { + return String::new(); + }; + let display_root = project.git_root.as_deref().unwrap_or(project.cwd.as_path()); + let project_str = abbreviate_home(display_root); + let mut out = format!("\nproject: {project_str}"); + if let Some(branch) = project.git_branch.as_deref() { + out.push_str(&format!("\nbranch: {branch}")); + } + out +} + +fn abbreviate_home(p: &std::path::Path) -> String { + let raw = p.to_string_lossy(); + if let Ok(home) = std::env::var("HOME") { + if let Some(rest) = raw.strip_prefix(&home) { + return format!("~{rest}"); + } + } + raw.into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn banner_empty_for_no_project() { + assert_eq!(format_project_banner_lines(None), ""); + } + + #[test] + fn banner_shows_project_and_branch() { + let p = session::ProjectContext { + cwd: std::path::PathBuf::from("/tmp/repo/sub"), + git_root: Some(std::path::PathBuf::from("/tmp/repo")), + git_branch: Some("main".into()), + }; + let out = format_project_banner_lines(Some(&p)); + assert!(out.contains("project: /tmp/repo")); + assert!(out.contains("branch: main")); + } + + #[test] + fn banner_omits_branch_outside_repo() { + let p = session::ProjectContext { + cwd: std::path::PathBuf::from("/tmp/loose"), + git_root: None, + git_branch: None, + }; + let out = format_project_banner_lines(Some(&p)); + assert!(out.contains("project: /tmp/loose")); + assert!(!out.contains("branch:")); + } +} diff --git a/src-tauri/src/modules/cli/session.rs b/src-tauri/src/modules/cli/session.rs index 030a0b6..69bb0ed 100644 --- a/src-tauri/src/modules/cli/session.rs +++ b/src-tauri/src/modules/cli/session.rs @@ -7,11 +7,13 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; const SESSIONS_DIRNAME: &str = "cli_sessions"; const LAST_POINTER: &str = "cli_session_last.json"; +const BY_PATH_POINTER: &str = "cli_session_by_path.json"; /// Cap applied when building the context prefix for a new turn. /// Keeps the prompt size predictable across long sessions. @@ -28,6 +30,28 @@ pub struct SessionTurn { pub model: String, } +/// Project context captured when a session first turns or the REPL starts. +/// Used for the REPL banner and for matching `--continue` to the right session +/// when the user invokes pengine from a different folder than last time. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProjectContext { + /// Working directory pengine was started in (absolute when possible). + pub cwd: PathBuf, + /// Git toplevel containing `cwd`, if any. Used as the per-folder match key + /// so `pengine` from `repo/` and `repo/src/` resume the same session. + pub git_root: Option, + /// Branch name (`refs/heads/<…>`) or 7-char SHA prefix when detached. + pub git_branch: Option, +} + +impl ProjectContext { + /// Stable per-folder key for the by-path pointer index. Prefers the git + /// toplevel so subdirectory invocations resolve to the same session. + pub fn match_key(&self) -> &Path { + self.git_root.as_deref().unwrap_or(self.cwd.as_path()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CliSession { pub id: String, @@ -38,6 +62,10 @@ pub struct CliSession { pub summary: Option, pub prompt_tokens_total: u64, pub eval_tokens_total: u64, + /// Where pengine was started. `None` for legacy sessions saved before + /// the project field was introduced. + #[serde(default)] + pub project: Option, } impl CliSession { @@ -50,9 +78,16 @@ impl CliSession { summary: None, prompt_tokens_total: 0, eval_tokens_total: 0, + project: None, } } + pub fn fresh_with_project(project: ProjectContext) -> Self { + let mut s = Self::fresh(); + s.project = Some(project); + s + } + pub fn record_turn( &mut self, user: &str, @@ -124,11 +159,48 @@ fn last_pointer(store_path: &Path) -> PathBuf { .unwrap_or_else(|| PathBuf::from(LAST_POINTER)) } +fn by_path_pointer(store_path: &Path) -> PathBuf { + store_path + .parent() + .map(|p| p.join(BY_PATH_POINTER)) + .unwrap_or_else(|| PathBuf::from(BY_PATH_POINTER)) +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct LastPointer { last_session_id: String, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ByPathPointer { + /// Map of project match key (canonicalized when possible) → most recent session id. + #[serde(default)] + paths: HashMap, +} + +fn read_by_path(store_path: &Path) -> ByPathPointer { + let path = by_path_pointer(store_path); + let Ok(body) = fs::read_to_string(&path) else { + return ByPathPointer::default(); + }; + serde_json::from_str(&body).unwrap_or_default() +} + +fn write_by_path(store_path: &Path, p: &ByPathPointer) -> Result<(), String> { + let body = serde_json::to_string_pretty(p).map_err(|e| format!("encode by_path: {e}"))?; + fs::write(by_path_pointer(store_path), body).map_err(|e| format!("write by_path: {e}")) +} + +/// Stable string form of a project match key. Canonicalize when possible so +/// `repo/` and `./repo/` collapse to the same entry, but keep the raw path as +/// fallback if canonicalization fails (e.g. directory was deleted since). +fn project_key_string(key: &Path) -> String { + fs::canonicalize(key) + .unwrap_or_else(|_| key.to_path_buf()) + .to_string_lossy() + .into_owned() +} + pub fn save(store_path: &Path, session: &CliSession) -> Result<(), String> { let dir = sessions_dir(store_path); fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?; @@ -141,6 +213,18 @@ pub fn save(store_path: &Path, session: &CliSession) -> Result<(), String> { let pointer_body = serde_json::to_string_pretty(&pointer).map_err(|e| format!("encode pointer: {e}"))?; fs::write(last_pointer(store_path), pointer_body).map_err(|e| format!("write pointer: {e}"))?; + + // Per-folder pointer so `--continue` from the same project resumes its + // own session even when other projects' sessions are more recent. + if let Some(project) = session.project.as_ref() { + let mut by_path = read_by_path(store_path); + by_path + .paths + .insert(project_key_string(project.match_key()), session.id.clone()); + if let Err(e) = write_by_path(store_path, &by_path) { + log::warn!("session: by_path pointer write failed: {e}"); + } + } Ok(()) } @@ -152,8 +236,24 @@ pub fn load_last(store_path: &Path) -> Result, String> { Err(e) => return Err(format!("read pointer: {e}")), }; let p: LastPointer = serde_json::from_str(&body).map_err(|e| format!("parse pointer: {e}"))?; + load_by_id(store_path, &p.last_session_id) +} + +/// Resume the most recent session whose project matches `key` (typically the +/// current cwd or its git toplevel). Returns `Ok(None)` when there is no +/// matching session — callers fall back to [`load_last`] for cross-folder +/// continuity. +pub fn load_last_for_path(store_path: &Path, key: &Path) -> Result, String> { + let by_path = read_by_path(store_path); + let Some(id) = by_path.paths.get(&project_key_string(key)) else { + return Ok(None); + }; + load_by_id(store_path, id) +} + +fn load_by_id(store_path: &Path, id: &str) -> Result, String> { let dir = sessions_dir(store_path); - let path = dir.join(format!("{}.json", p.last_session_id)); + let path = dir.join(format!("{id}.json")); let body = match fs::read_to_string(&path) { Ok(b) => b, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), @@ -163,6 +263,60 @@ pub fn load_last(store_path: &Path) -> Result, String> { Ok(Some(s)) } +/// Detect the project context for `cwd`: walks up looking for a `.git` +/// directory (or `.git` file for worktrees) and parses the branch from +/// `HEAD`. Falls back to a 7-char SHA prefix on detached HEAD; returns +/// `git_root: None` when `cwd` is outside any repo. +pub fn detect_project_context(cwd: &Path) -> ProjectContext { + let cwd_owned = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); + let (git_root, git_branch) = detect_git(&cwd_owned); + ProjectContext { + cwd: cwd_owned, + git_root, + git_branch, + } +} + +fn detect_git(start: &Path) -> (Option, Option) { + let mut here = start.to_path_buf(); + loop { + let dot_git = here.join(".git"); + if let Ok(meta) = fs::metadata(&dot_git) { + let head_path = if meta.is_dir() { + Some(dot_git.join("HEAD")) + } else if meta.is_file() { + // Worktree: `.git` is a file `gitdir: ` pointing to + // the real git dir under `/.git/worktrees/`. + fs::read_to_string(&dot_git).ok().and_then(|s| { + s.trim() + .strip_prefix("gitdir: ") + .map(|p| PathBuf::from(p).join("HEAD")) + }) + } else { + None + }; + let branch = head_path.and_then(parse_head_ref); + return (Some(here), branch); + } + if !here.pop() { + return (None, None); + } + } +} + +fn parse_head_ref(head_path: PathBuf) -> Option { + let raw = fs::read_to_string(&head_path).ok()?; + let trimmed = raw.trim(); + if let Some(rest) = trimmed.strip_prefix("ref: refs/heads/") { + return Some(rest.to_string()); + } + // Detached HEAD: keep the short SHA so the banner stays informative. + if trimmed.len() >= 7 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(trimmed.chars().take(7).collect()); + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -199,4 +353,80 @@ mod tests { let s = CliSession::fresh(); assert!(s.context_prefix().is_empty()); } + + #[test] + fn save_records_per_path_pointer_and_load_for_path_returns_it() { + let dir = tempdir().unwrap(); + let store = dir.path().join("connection.json"); + fs::write(&store, "{}").unwrap(); + let project_root = dir.path().join("repo-a"); + fs::create_dir_all(&project_root).unwrap(); + + let project = ProjectContext { + cwd: project_root.clone(), + git_root: Some(project_root.clone()), + git_branch: Some("feature-x".into()), + }; + let mut s = CliSession::fresh_with_project(project.clone()); + s.record_turn("ping", "pong", 1, 1, "m"); + save(&store, &s).unwrap(); + + let loaded = load_last_for_path(&store, project.match_key()) + .unwrap() + .expect("session for project"); + assert_eq!(loaded.id, s.id); + assert_eq!( + loaded.project.as_ref().unwrap().git_branch.as_deref(), + Some("feature-x") + ); + } + + #[test] + fn load_for_path_returns_none_for_unknown_folder() { + let dir = tempdir().unwrap(); + let store = dir.path().join("connection.json"); + fs::write(&store, "{}").unwrap(); + let other = dir.path().join("other"); + fs::create_dir_all(&other).unwrap(); + assert!(load_last_for_path(&store, &other).unwrap().is_none()); + } + + #[test] + fn detect_project_context_reads_branch_from_head() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("repo"); + let dot_git = repo.join(".git"); + fs::create_dir_all(&dot_git).unwrap(); + fs::write(dot_git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + let sub = repo.join("src"); + fs::create_dir_all(&sub).unwrap(); + + let ctx = detect_project_context(&sub); + assert_eq!(ctx.git_branch.as_deref(), Some("main")); + assert_eq!( + fs::canonicalize(ctx.git_root.unwrap()).unwrap(), + fs::canonicalize(&repo).unwrap() + ); + } + + #[test] + fn detect_project_context_returns_short_sha_for_detached_head() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("repo"); + let dot_git = repo.join(".git"); + fs::create_dir_all(&dot_git).unwrap(); + fs::write(dot_git.join("HEAD"), "deadbeefcafebabe1234567890abcdef\n").unwrap(); + let ctx = detect_project_context(&repo); + assert_eq!(ctx.git_branch.as_deref(), Some("deadbee")); + } + + #[test] + fn detect_project_context_outside_repo_returns_none_root() { + let dir = tempdir().unwrap(); + let outside = dir.path().join("loose"); + fs::create_dir_all(&outside).unwrap(); + let ctx = detect_project_context(&outside); + assert!(ctx.git_root.is_none()); + assert!(ctx.git_branch.is_none()); + } } diff --git a/src-tauri/src/modules/cli/syntax_highlight.rs b/src-tauri/src/modules/cli/syntax_highlight.rs deleted file mode 100644 index 8ab421f..0000000 --- a/src-tauri/src/modules/cli/syntax_highlight.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Optional 24-bit ANSI highlighting for CLI / REPL fenced code (dark theme). - -use std::sync::OnceLock; -use syntect::easy::HighlightLines; -use syntect::highlighting::ThemeSet; -use syntect::parsing::{SyntaxReference, SyntaxSet}; -use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings}; - -struct HighlightEngine { - syntax_set: SyntaxSet, - theme_set: ThemeSet, -} - -fn engine() -> &'static HighlightEngine { - static E: OnceLock = OnceLock::new(); - E.get_or_init(|| HighlightEngine { - syntax_set: SyntaxSet::load_defaults_newlines(), - theme_set: ThemeSet::load_defaults(), - }) -} - -fn dark_theme(ts: &ThemeSet) -> &syntect::highlighting::Theme { - const PREFERRED: &[&str] = &[ - "base16-ocean.dark", - "base16-mocha.dark", - "Solarized (dark)", - "InspiredGitHub", - ]; - for key in PREFERRED { - if let Some(t) = ts.themes.get(*key) { - return t; - } - } - ts.themes - .values() - .next() - .expect("syntect embeds default themes") -} - -fn resolve_syntax<'a>(ss: &'a SyntaxSet, lang: &str) -> &'a SyntaxReference { - let l = lang.trim(); - if l.is_empty() { - return ss.find_syntax_plain_text(); - } - ss.find_syntax_by_extension(l) - .or_else(|| ss.find_syntax_by_token(l)) - .unwrap_or_else(|| ss.find_syntax_plain_text()) -} - -/// One element per source line (no embedded `\n`), with 24-bit ANSI sequences. -/// Returns `None` if highlighting fails so callers can fall back to plain text. -pub fn highlight_fence_body(lang: &str, code: &str) -> Option> { - let eng = engine(); - let syntax = resolve_syntax(&eng.syntax_set, lang); - let theme = dark_theme(&eng.theme_set); - let mut h = HighlightLines::new(syntax, theme); - let mut lines = Vec::new(); - for line in LinesWithEndings::from(code) { - let regions = h.highlight_line(line, &eng.syntax_set).ok()?; - let escaped = as_24_bit_terminal_escaped(®ions[..], true); - lines.push(trim_line_ending(&escaped)); - } - Some(lines) -} - -fn trim_line_ending(s: &str) -> String { - s.trim_end_matches(['\n', '\r']).to_string() -} - -#[cfg(test)] -mod tests { - use super::highlight_fence_body; - - #[test] - fn highlight_rust_emits_ansi() { - let lines = highlight_fence_body("rust", "fn main() {}\n").expect("highlight"); - let joined = lines.join("\n"); - assert!( - joined.contains('\x1b'), - "expected 24-bit ansi escapes: {joined:?}" - ); - } -} diff --git a/src-tauri/src/modules/mcp/client.rs b/src-tauri/src/modules/mcp/client.rs index 0f15ad4..b854c86 100644 --- a/src-tauri/src/modules/mcp/client.rs +++ b/src-tauri/src/modules/mcp/client.rs @@ -12,8 +12,10 @@ const MCP_CONNECT_CALL_TIMEOUT: Duration = Duration::from_secs(120); /// Default JSON-RPC deadline for most `tools/call` traffic (stdio/http transport defaults match). const MCP_TOOLS_CALL_TIMEOUT_DEFAULT: Duration = Duration::from_secs(60); -/// Recursive tree / glob search can be slow; keep a hard cap so the agent does not sit 10+ minutes. -const MCP_TOOLS_CALL_TIMEOUT_SEARCH: Duration = Duration::from_secs(90); +/// Recursive glob search. Default excludes (`node_modules`, `target`, `.git`, …) are now merged by +/// the agent (see `merge_filesystem_mcp_path_args`); this ceiling is kept generous for large repos +/// where the filtered set is still many thousands of files. +const MCP_TOOLS_CALL_TIMEOUT_SEARCH: Duration = Duration::from_secs(180); /// Full-repo trees are cheaper once excludes trim `node_modules` / `target` / `.git` (see agent merge), /// but source-heavy repos still need headroom below multi‑minute stalls. diff --git a/src-tauri/src/modules/mcp/registry.rs b/src-tauri/src/modules/mcp/registry.rs index 7457da7..63703a3 100644 --- a/src-tauri/src/modules/mcp/registry.rs +++ b/src-tauri/src/modules/mcp/registry.rs @@ -722,9 +722,57 @@ fn score_tool_combined( if tool.name.eq_ignore_ascii_case("fetch") && message_suggests_url_fetch(user_message) { s += 22; } + if message_suggests_code_review_refactor(user_message) { + let short = tool + .name + .rsplit_once('.') + .map(|(_, t)| t) + .unwrap_or(tool.name.as_str()); + s += match short { + "git_status" | "git_diff" | "git_diff_unstaged" | "git_diff_staged" | "git_log" => 36, + "search_files" + | "read_text_file" + | "read_multiple_files" + | "list_directory" + | "list_directory_with_sizes" + | "directory_tree" => 30, + "edit_file" | "write_file" | "create_directory" | "move_file" => 26, + _ => 0, + }; + } s } +fn message_suggests_code_review_refactor(msg: &str) -> bool { + const HINTS: &[&str] = &[ + "code review", + "review this code", + "review my code", + "refactor", + "cleanup", + "clean up", + "improve this code", + "fix this code", + "bugfix", + "tech debt", + "pull request", + "pr review", + "git diff", + "pre-commit", + "precommit", + "lint-staged", + "rustfmt", + "clippy", + "eslint", + "prettier", + "ci failure", + "fix pre-commit", + ]; + HINTS + .iter() + .any(|h| crate::modules::skills::service::user_message_needle_match(msg, h)) +} + /// Weight recent invocations: newest names in the deque score highest. /// `recent` is in insertion order (oldest first, newest last — see /// `state::note_tools_used`), so the index `i` is also the "age rank" — a @@ -1212,4 +1260,56 @@ mod tests { super::ToolRoutePlan::FullCatalog => panic!("expected ranked subset"), } } + + #[test] + fn routing_code_review_refactor_prefers_git_and_edit_stack() { + let mut tools: Vec = (0..20) + .map(|i| ToolDef { + server_name: "misc".into(), + name: format!("misc_tool_{i}"), + description: Some("unrelated helper".into()), + input_schema: json!({}), + direct_return: false, + category: None, + risk: ToolRisk::Low, + }) + .collect(); + for name in [ + "git_status", + "git_diff", + "git_log", + "search_files", + "read_text_file", + "edit_file", + "write_file", + "fetch", + "time", + ] { + tools.push(ToolDef { + server_name: "core".into(), + name: name.into(), + description: Some("code operations".into()), + input_schema: json!({}), + direct_return: false, + category: None, + risk: ToolRisk::Low, + }); + } + let plan = super::route_tools( + &tools, + "please do a code review and refactor this module", + &[], + None, + false, + ); + match plan { + super::ToolRoutePlan::Subset { tools: sel, .. } => { + assert!(sel.iter().any(|t| t.name == "git_diff"), "{sel:?}"); + assert!(sel.iter().any(|t| t.name == "search_files"), "{sel:?}"); + assert!(sel.iter().any(|t| t.name == "read_text_file"), "{sel:?}"); + assert!(sel.iter().any(|t| t.name == "edit_file"), "{sel:?}"); + } + super::ToolRoutePlan::FullCatalog => panic!("expected ranked subset"), + } + } } diff --git a/src-tauri/src/prelaunch.rs b/src-tauri/src/prelaunch.rs new file mode 100644 index 0000000..a3e14e0 --- /dev/null +++ b/src-tauri/src/prelaunch.rs @@ -0,0 +1,87 @@ +//! Pre-Tauri activation-policy hook (macOS). +//! +//! The bundled `.app` declares `LSUIElement=true` in `Info.plist`, which +//! starts every launch as `NSApplicationActivationPolicyAccessory` — no +//! Dock icon, no menu bar. That covers production users. +//! +//! Dev builds (`cargo run`, `cargo tauri dev`, `target/debug/pengine`) have +//! no `Info.plist` applied, so they would otherwise show a brief Dock-icon +//! flash before [`crate::modules::cli::bootstrap::handle_cli_or_continue`] +//! runs from inside Tauri's `setup` callback. We close that gap by reading +//! `argv` / `env` ourselves at the top of `lib::run()` and calling +//! `[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]` +//! before `tauri::Builder::default()` initializes anything. +//! +//! This mirrors the CLI/GUI detection in `bootstrap::handle_cli_or_continue` +//! — but it can't import that code because it must run before `tauri::App` +//! exists. Keep the two in sync when CLI subcommands or env markers change. + +use std::env; +use std::io::IsTerminal; + +use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; +use objc2_foundation::MainThreadMarker; + +/// `true` when this process should not register a Dock icon — i.e. any CLI +/// invocation that exits without opening a window. +pub fn is_cli_invocation() -> bool { + if env::var("PENGINE_OPEN_GUI") + .map(|v| v == "1") + .unwrap_or(false) + { + return false; + } + if env::var("PENGINE_LAUNCH_MODE") + .map(|v| v == "cli") + .unwrap_or(false) + { + return true; + } + + let mut args = env::args().skip(1).filter(|a| { + let t = a.trim(); + !t.is_empty() && !t.starts_with("-psn_") + }); + let Some(first) = args.next() else { + // No arguments: REPL when stdin is a TTY; otherwise treat as a GUI + // launch (Finder / Dock / `open -a pengine`). + return std::io::stdin().is_terminal(); + }; + + matches!( + first.as_str(), + "--help" + | "-h" + | "help" + | "--version" + | "-V" + | "version" + | "--json" + | "--continue" + | "-p" + | "--print" + | "--output-format" + | "--shell" + | "status" + | "clear" + | "config" + | "model" + | "bot" + | "tools" + | "skills" + | "fs" + | "logs" + | "ask" + | "app" + ) +} + +/// Promote NSApp to `Accessory` before Tauri's run loop starts. No-op when +/// not on the main thread (defensive — `lib::run()` is always main). +pub fn hide_dock_icon() { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let app = NSApplication::sharedApplication(mtm); + app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 16bce85..0d5d4aa 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,8 +19,6 @@ "cli": { "description": "Pengine — control Ollama, Telegram bot, and MCP tools from the terminal.", "args": [ - { "name": "no-terminal", "description": "Disable terminal output" }, - { "name": "no-telegram", "description": "Disable Telegram output" }, { "name": "json", "description": "Emit CliReply as versioned JSON" }, { "name": "shell", @@ -52,32 +50,9 @@ "status": { "description": "Show bot, Ollama, and MCP status" }, - "doctor": { - "description": "Run environment diagnostics (Ollama, MCP, keychain, store, network)" - }, - "cost": { - "description": "Show token usage + estimated cost for the current session" - }, - "resume": { - "description": "Resume the most recent saved REPL session" - }, - "compact": { - "description": "Summarize the current session and reset turn history" - }, "clear": { "description": "Clear the REPL screen (REPL-only; errors elsewhere)" }, - "plan": { - "description": "Toggle plan mode (read-only agent + planning system prompt)", - "args": [ - { - "name": "action", - "description": "on | off | toggle (default toggle)", - "takesValue": true, - "index": 1 - } - ] - }, "config": { "description": "Show or set user settings (e.g. skills_hint_max_bytes=12000)", "args": [ @@ -166,24 +141,6 @@ } ] }, - "mcp": { - "description": "List, add, remove, or import MCP servers", - "args": [ - { - "name": "action", - "description": "list | add | remove | import (defaults to list)", - "takesValue": true, - "index": 1 - }, - { - "name": "rest", - "description": "Action arguments (forwarded to the parser; quote multi-word values)", - "takesValue": true, - "multiple": true, - "index": 2 - } - ] - }, "logs": { "description": "Stream log events", "args": [ From 16e0889a78f41ad6ba9e11bdd19664c207b8f2b6 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Fri, 1 May 2026 00:37:12 +0200 Subject: [PATCH 13/23] refactor: improve code formatting and readability in agent and CLI test modules - Streamlined function signatures and assertions for better clarity in the agent module. - Enhanced formatting of test assertions in the CLI output module for improved readability. - Consolidated line breaks and structured code for better maintainability across test cases. --- src-tauri/src/modules/agent/mod.rs | 18 +++++++++--------- src-tauri/src/modules/cli/output.rs | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/modules/agent/mod.rs b/src-tauri/src/modules/agent/mod.rs index 0007482..6931439 100644 --- a/src-tauri/src/modules/agent/mod.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -572,13 +572,8 @@ fn tool_output_implies_unresolved_failure(body: &str) -> bool { /// Apply-fix turn is unfinished if we never wrote, or the **latest** tool payload still /// looks like a failing check (so we do not keep looping after a successful `edit_file` /// just because an older clippy blob remains earlier in `tool_results`). -fn apply_fix_turn_unfinished( - tool_results: &[(String, String)], - all_invoked: &[String], -) -> bool { - let wrote = all_invoked - .iter() - .any(|n| tool_invocation_writes_files(n)); +fn apply_fix_turn_unfinished(tool_results: &[(String, String)], all_invoked: &[String]) -> bool { + let wrote = all_invoked.iter().any(|n| tool_invocation_writes_files(n)); let last_failed = tool_results .last() .is_some_and(|(_, body)| tool_output_implies_unresolved_failure(body)); @@ -1985,7 +1980,9 @@ mod tests { assert!(tool_output_implies_unresolved_failure( "husky - pre-commit script failed (code 101)" )); - assert!(!tool_output_implies_unresolved_failure("All checks passed.")); + assert!(!tool_output_implies_unresolved_failure( + "All checks passed." + )); } #[test] @@ -1994,7 +1991,10 @@ mod tests { "run_terminal_cmd".into(), "error: could not compile `pengine` (lib)".into(), ); - assert!(apply_fix_turn_unfinished(std::slice::from_ref(&clippy), &[])); + assert!(apply_fix_turn_unfinished( + std::slice::from_ref(&clippy), + &[] + )); assert!(!apply_fix_turn_unfinished( &[clippy.clone(), ("edit_file".into(), "ok".into())], &["edit_file".into()] diff --git a/src-tauri/src/modules/cli/output.rs b/src-tauri/src/modules/cli/output.rs index 14241b5..58fe8d5 100644 --- a/src-tauri/src/modules/cli/output.rs +++ b/src-tauri/src/modules/cli/output.rs @@ -714,10 +714,16 @@ mod tests { fn style_diff_line_tags_add_remove_and_hunk() { let add = super::style_diff_line("+foo"); assert!(add.contains("+foo")); - assert!(add.contains("\x1b[48;2;20;45;28m"), "add line uses dark green bg"); + assert!( + add.contains("\x1b[48;2;20;45;28m"), + "add line uses dark green bg" + ); let del = super::style_diff_line("-bar"); assert!(del.contains("-bar")); - assert!(del.contains("\x1b[48;2;45;22;24m"), "del line uses dark red bg"); + assert!( + del.contains("\x1b[48;2;45;22;24m"), + "del line uses dark red bg" + ); let hunk = super::style_diff_line("@@ -1,2 +1,3 @@"); assert!(hunk.contains("@@")); assert!(hunk.contains("\x1b[1;38;2;242;204;96m")); @@ -730,6 +736,9 @@ mod tests { fn style_diff_line_file_headers_not_confused_with_plus() { let hdr = super::style_diff_line("+++ b/src/main.rs"); assert!(hdr.contains("+++")); - assert!(!hdr.contains("\x1b[48;2;20;45;28m"), "+++ must not use add-line bg"); + assert!( + !hdr.contains("\x1b[48;2;20;45;28m"), + "+++ must not use add-line bg" + ); } } From 88cc7b67d48d7cbeefe8038e809a388aa8dc1687 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Fri, 1 May 2026 01:38:32 +0200 Subject: [PATCH 14/23] feat: add .pengine file support and enhance project context handling - Introduced a new .pengine file to store session metadata, including session ID and project directory. - Updated the agent module to handle project context more effectively by reading from the .pengine file. - Enhanced CLI session management to incorporate project context from the .pengine file, improving user experience during interactions. - Implemented functions to read and manage .pengine file data, ensuring proper handling of project metadata. --- .pengine | 4 + src-tauri/src/modules/agent/mod.rs | 80 +++++++++++----- src-tauri/src/modules/cli/handlers.rs | 33 +++++-- src-tauri/src/modules/cli/session.rs | 118 +++++++++++++++++++++++ src-tauri/src/modules/mcp/registry.rs | 131 +++++++++++++++++++++++++- 5 files changed, 333 insertions(+), 33 deletions(-) create mode 100644 .pengine diff --git a/.pengine b/.pengine new file mode 100644 index 0000000..aa49a0b --- /dev/null +++ b/.pengine @@ -0,0 +1,4 @@ +# .pengine +session_id: auto-generated +project_dir: /app/pengine +last_action: created by user request \ No newline at end of file diff --git a/src-tauri/src/modules/agent/mod.rs b/src-tauri/src/modules/agent/mod.rs index 6931439..9c5284c 100644 --- a/src-tauri/src/modules/agent/mod.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -61,12 +61,12 @@ const SUMMARY_SYSTEM_PROMPT: &str = "You synthesize tool results for the user. R 5) Keep the body concise but do not drop **Quellen** to save space.\n\ 6) No chain-of-thought, planning, or English meta: write only text that should appear in the user's chat bubble."; -const APPLY_FIX_CONTINUE_AFTER_PROSE: &str = "CONTINUE (apply-fix): This turn is **not** finished. Either you have not called **`edit_file`** / **`write_file`** yet, or the **latest** tool output still shows a compile/clippy/lint/pre-commit failure. \ -Your next step must be **tool calls** that patch the repo (use absolute **`/app/...`** paths), then re-check with **`git_diff`** or the same command if needed. \ -Do not reply with meta-commentary about output formats, schemas, or \"no task\" — fix the files the log points at (e.g. clippy `file.rs:line`)."; +const REPO_WRITE_CONTINUE_AFTER_PROSE: &str = "CONTINUE (repo files): This turn is **not** finished. Either you have not called **`edit_file`** / **`write_file`** / **`create_directory`** yet, or the **latest** tool output still shows a compile/clippy/lint/pre-commit failure. \ +Your next step must be **tool calls** on the repo (absolute **`/app/...`** paths): create missing files/folders the user asked for, or patch what the log cites — then verify if appropriate. \ +Do not reply with meta-commentary about output formats or a generic \"ready to assist\" offer."; -const APPLY_FIX_CONTINUE_AFTER_EMPTY: &str = "CONTINUE (apply-fix): You returned no assistant text and no tool calls after tool results. \ -Use **`edit_file`** / **`write_file`** to fix the failures shown in the latest tool output, then verify."; +const REPO_WRITE_CONTINUE_AFTER_EMPTY: &str = "CONTINUE (repo files): You returned no assistant text and no tool calls after tool results. \ +Use **`edit_file`** / **`write_file`** / **`create_directory`** as needed for the user's request or the errors in the latest tool output."; /// When the MCP catalog is empty and the user did not enable `/think`, constrain the model to JSON /// `{\"reply\":...}` so the host can take a single user-visible field (same schema as the summarize pass). @@ -74,7 +74,7 @@ fn chat_options_for_agent_step( post_tool: bool, user_wants_think: bool, json_only_user_reply: bool, - apply_fix_flow: bool, + repo_write_followthrough: bool, ) -> ChatOptions { let format = (json_only_user_reply && !user_wants_think) .then_some(ollama::summarize_reply_json_schema()); @@ -87,7 +87,7 @@ fn chat_options_for_agent_step( ..ChatOptions::default() } } else { - let cap = if apply_fix_flow { + let cap = if repo_write_followthrough { POST_TOOL_NUM_PREDICT_APPLY_FIX } else { POST_TOOL_NUM_PREDICT @@ -546,6 +546,12 @@ fn message_implies_apply_repo_fix(msg: &str) -> bool { .any(|h| skills::user_message_needle_match(msg, h)) } +/// Lint/fix flows plus explicit create/write/scaffold requests (`.pengine`, new files, etc.). +fn user_expects_repo_write_followthrough(msg: &str) -> bool { + message_implies_apply_repo_fix(msg) + || crate::modules::mcp::registry::message_suggests_filesystem_mutation(msg) +} + fn tool_invocation_writes_files(model_tool_name: &str) -> bool { let b = mcp_tool_base_name(model_tool_name); b.eq_ignore_ascii_case("edit_file") @@ -1180,12 +1186,29 @@ async fn build_system_prompt( .await; } + let scaffold_hint = { + let has_edit = { + let reg = state.mcp.read().await; + reg.tool_names().iter().any(|n| { + let short = n.rsplit_once('.').map(|(_, t)| t).unwrap_or(n.as_str()); + short.eq_ignore_ascii_case("edit_file") || short.eq_ignore_ascii_case("write_file") + }) + }; + if has_edit + && crate::modules::mcp::registry::message_suggests_filesystem_mutation(user_message) + { + "\n**On-disk project metadata:** When the user asks for a **new file**, **folder**, or dot-directory (e.g. **`.pengine`**) for session or project context, **create it in this turn** with **`create_directory`** / **`write_file`** / **`edit_file`** under an absolute **`/app/…`** path. Do not answer with only a generic offer to help before those paths exist." + } else { + "" + } + }; + format!( "{PENGINE_OUTPUT_CONTRACT_LEAD}Assistant with tools. Use tools to fetch external data **or to act on the user's repository (read, edit, diff, commit)**; otherwise answer directly. \ After tool results, answer immediately. Be concise. \ `brave_web_search` is only in the tool list when the user asked to search the open web (e.g. \"search the internet\", \"suche im Internet\", \"suche nach ...\") or a skill's `requires` matches this turn — otherwise prefer **`fetch`** on any `http(s)` URL you have (including from the user). \ At most one `brave_web_search` per user message when it is available. \ - After an allowed search, the host may auto-`fetch` several top result URLs — use those excerpts and end with **Quellen** listing every source URL.{fs_hint}{code_edit_hint}{mem_hint}{weather_directive}{skills_hint}" + After an allowed search, the host may auto-`fetch` several top result URLs — use those excerpts and end with **Quellen** listing every source URL.{fs_hint}{code_edit_hint}{scaffold_hint}{mem_hint}{weather_directive}{skills_hint}" ) } @@ -1196,8 +1219,8 @@ async fn run_model_turn( skills_slug_filter: Option<&[String]>, ) -> Result { let plan_mode = *state.plan_mode.read().await; - let apply_fix_flow = message_implies_apply_repo_fix(user_message); - let max_steps = if apply_fix_flow { + let repo_write_followthrough = user_expects_repo_write_followthrough(user_message); + let max_steps = if repo_write_followthrough { MAX_STEPS_APPLY_FIX } else { MAX_STEPS @@ -1213,7 +1236,9 @@ async fn run_model_turn( let reg = state.mcp.read().await; reg.tool_names().iter().any(|n| { let short = mcp_tool_base_name(n); - short.eq_ignore_ascii_case("edit_file") || short.eq_ignore_ascii_case("write_file") + short.eq_ignore_ascii_case("edit_file") + || short.eq_ignore_ascii_case("write_file") + || short.eq_ignore_ascii_case("create_directory") }) }; @@ -1321,7 +1346,7 @@ async fn run_model_turn( let post_tool = tool_rounds > 0; let json_only_user_reply = !has_tools; let chat_opts = - chat_options_for_agent_step(post_tool, think, json_only_user_reply, apply_fix_flow); + chat_options_for_agent_step(post_tool, think, json_only_user_reply, repo_write_followthrough); let inject_post_tool = post_tool; if inject_post_tool { @@ -1391,7 +1416,7 @@ async fn run_model_turn( if tool_calls.is_empty() { if !content.is_empty() { - if apply_fix_flow + if repo_write_followthrough && !plan_mode && registry_has_write_tool && step + 1 < max_steps @@ -1400,13 +1425,13 @@ async fn run_model_turn( if let Some(arr) = messages.as_array_mut() { arr.push(json!({ "role": "system", - "content": APPLY_FIX_CONTINUE_AFTER_PROSE, + "content": REPO_WRITE_CONTINUE_AFTER_PROSE, })); } state .emit_log( "run", - "agent: apply-fix continuation (prose-only while fix incomplete)", + "agent: repo-write continuation (prose-only while incomplete)", ) .await; continue; @@ -1424,7 +1449,7 @@ async fn run_model_turn( r.model = model.clone(); return Ok(r); } - if apply_fix_flow + if repo_write_followthrough && !plan_mode && registry_has_write_tool && step + 1 < max_steps @@ -1433,13 +1458,13 @@ async fn run_model_turn( if let Some(arr) = messages.as_array_mut() { arr.push(json!({ "role": "system", - "content": APPLY_FIX_CONTINUE_AFTER_EMPTY, + "content": REPO_WRITE_CONTINUE_AFTER_EMPTY, })); } state .emit_log( "run", - "agent: apply-fix continuation (silent model step after tools)", + "agent: repo-write continuation (silent model step after tools)", ) .await; continue; @@ -1621,7 +1646,7 @@ async fn run_model_turn( .await; } - if apply_fix_flow + if repo_write_followthrough && !plan_mode && registry_has_write_tool && !fix_write_nudge_sent @@ -1634,17 +1659,16 @@ async fn run_model_turn( if let Some(arr) = messages.as_array_mut() { arr.push(json!({ "role": "system", - "content": "FIX REQUIRED: The user asked to apply fixes (pre-commit, lint, format, etc.). \ - You have not called **`edit_file`** or **`write_file`** yet. Call one of them now with the \ - correct `/app/…` path and the actual file content or patch. Do not finish with only \ - **`git_diff`** / **`git_status`** / read tools — mutate files first, then **`git_diff`**." + "content": "FIX REQUIRED: The user asked for **repository file or folder changes** (fixes, lint, pre-commit, **new files**, dot-directories like **`.pengine`**, etc.). \ + You have not called **`edit_file`**, **`write_file`**, or **`create_directory`** yet. Call one now with the \ + correct `/app/…` path. Do not finish with only **`git_diff`** / **`git_status`** / read or memory tools — create or patch paths on disk first." })); } fix_write_nudge_sent = true; state .emit_log( "run", - "agent: injected apply-fix write nudge (no write tool yet)", + "agent: injected repo-write nudge (no fs write tool yet)", ) .await; } @@ -1960,6 +1984,14 @@ mod tests { assert!(!message_implies_apply_repo_fix("what is the weather")); } + #[test] + fn user_expects_repo_write_followthrough_includes_scaffold_phrases() { + assert!(user_expects_repo_write_followthrough( + "create a hidden .pengine folder for session notes" + )); + assert!(!user_expects_repo_write_followthrough("what is 2+2")); + } + #[test] fn tool_invocation_writes_files_handles_qualified_names() { assert!(tool_invocation_writes_files("edit_file")); diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index 7d24131..1730b65 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -596,17 +596,36 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) state.emit_log("cli", &format!("mention: {err}")).await; } - let context_prefix = if persist_session { + let (context_prefix, project_for_dot) = if persist_session { let snap = state.cli_session.read().await.clone(); - snap.map(|s| s.context_prefix()).unwrap_or_default() + let pfx = snap + .as_ref() + .map(|s| s.context_prefix()) + .unwrap_or_default(); + let proj = snap + .as_ref() + .and_then(|s| s.project.clone()) + .unwrap_or_else(|| session::detect_project_context(&cwd)); + (pfx, proj) } else { - String::new() + (String::new(), session::detect_project_context(&cwd)) }; - let prompt_for_agent = if context_prefix.is_empty() { - expanded.message.clone() - } else { - format!("{context_prefix}## New user message\n{}", expanded.message) + let dot_prefix = session::dot_pengine_prompt_block(&project_for_dot); + + let prompt_for_agent = { + let mut head = String::new(); + if !dot_prefix.is_empty() { + head.push_str(&dot_prefix); + } + if !context_prefix.is_empty() { + head.push_str(&context_prefix); + } + if head.is_empty() { + expanded.message.clone() + } else { + format!("{head}## New user message\n{}", expanded.message) + } }; let progress = Progress::start(flavor::thinking_label().to_string()); diff --git a/src-tauri/src/modules/cli/session.rs b/src-tauri/src/modules/cli/session.rs index 69bb0ed..d4b0448 100644 --- a/src-tauri/src/modules/cli/session.rs +++ b/src-tauri/src/modules/cli/session.rs @@ -20,6 +20,9 @@ const BY_PATH_POINTER: &str = "cli_session_by_path.json"; const HISTORY_TURN_BUDGET: usize = 6; const HISTORY_BYTES_BUDGET: usize = 12_000; +/// Max bytes read from the hidden project file `.pengine` (file only, not a directory). +const DOT_PENGINE_MAX_BYTES: usize = 32_768; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionTurn { pub at: DateTime, @@ -277,6 +280,74 @@ pub fn detect_project_context(cwd: &Path) -> ProjectContext { } } +/// Directories to scan for a **file** named `.pengine`: **repo root first** +/// (when inside a git worktree), then cwd and parents toward that root so a +/// deeper `.pengine` file can override when present. +fn dot_pengine_scan_dirs(ctx: &ProjectContext) -> Vec { + let mut chain: Vec = Vec::new(); + let mut p = ctx.cwd.clone(); + loop { + chain.push(p.clone()); + if ctx.git_root.as_ref() == Some(&p) { + break; + } + if !p.pop() { + break; + } + } + chain.reverse(); + if let Some(gr) = ctx.git_root.clone() { + if !chain.iter().any(|d| d == &gr) { + chain.insert(0, gr); + } + } + chain +} + +fn read_limited_utf8(path: &Path) -> Option { + let raw = fs::read(path).ok()?; + let slice = if raw.len() > DOT_PENGINE_MAX_BYTES { + &raw[..DOT_PENGINE_MAX_BYTES] + } else { + raw.as_slice() + }; + let mut s = String::from_utf8_lossy(slice).into_owned(); + if raw.len() > DOT_PENGINE_MAX_BYTES { + s.push_str("\n…[truncated]\n"); + } + Some(s) +} + +fn read_dot_pengine_file_at(dir: &Path) -> Option { + let dot = dir.join(".pengine"); + let meta = fs::metadata(&dot).ok()?; + if !meta.is_file() { + return None; + } + read_limited_utf8(&dot) +} + +/// Load optional project metadata from the hidden file `.pengine` only (not a folder). +pub fn read_dot_pengine_context(ctx: &ProjectContext) -> Option { + for dir in dot_pengine_scan_dirs(ctx) { + if let Some(body) = read_dot_pengine_file_at(&dir) { + let trimmed = body.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +/// Markdown block prepended **before** session history so the agent sees stable +/// project context first. +pub fn dot_pengine_prompt_block(ctx: &ProjectContext) -> String { + read_dot_pengine_context(ctx).map_or_else(String::new, |body| { + format!("## Project context (.pengine)\n{body}\n\n") + }) +} + fn detect_git(start: &Path) -> (Option, Option) { let mut here = start.to_path_buf(); loop { @@ -429,4 +500,51 @@ mod tests { assert!(ctx.git_root.is_none()); assert!(ctx.git_branch.is_none()); } + + #[test] + fn read_dot_pengine_reads_file_at_git_root() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("repo"); + let dot_git = repo.join(".git"); + fs::create_dir_all(&dot_git).unwrap(); + fs::write(dot_git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + fs::write(repo.join(".pengine"), "Use bun for scripts.\n").unwrap(); + let sub = repo.join("pkg"); + fs::create_dir_all(&sub).unwrap(); + + let ctx = detect_project_context(&sub); + let body = read_dot_pengine_context(&ctx).expect(".pengine body"); + assert!(body.contains("bun")); + } + + #[test] + fn read_dot_pengine_ignores_dot_pengine_directory() { + let dir = tempdir().unwrap(); + let root = dir.path().join("proj"); + fs::create_dir_all(root.join(".pengine")).unwrap(); + fs::write(root.join(".pengine/README.md"), "only in folder\n").unwrap(); + + let ctx = ProjectContext { + cwd: root, + git_root: None, + git_branch: None, + }; + assert!(read_dot_pengine_context(&ctx).is_none()); + } + + #[test] + fn dot_pengine_prompt_block_wraps_section() { + let dir = tempdir().unwrap(); + let root = dir.path().join("r"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join(".pengine"), "x").unwrap(); + let ctx = ProjectContext { + cwd: root, + git_root: None, + git_branch: None, + }; + let b = dot_pengine_prompt_block(&ctx); + assert!(b.starts_with("## Project context (.pengine)")); + assert!(b.contains("x")); + } } diff --git a/src-tauri/src/modules/mcp/registry.rs b/src-tauri/src/modules/mcp/registry.rs index 63703a3..8769b37 100644 --- a/src-tauri/src/modules/mcp/registry.rs +++ b/src-tauri/src/modules/mcp/registry.rs @@ -142,7 +142,8 @@ pub struct ToolContextSelection { pub active_count: usize, pub used_subset: bool, /// Why this shape was chosen: `full` = entire registry, `ranked` = keyword/recent top-K, - /// `core_no_signal` = no scores (e.g. non-English) — always-on + memory only. + /// `core_no_signal` = no scores (e.g. non-English) — always-on + memory + filesystem + /// mutation tools when the user message asks to create/write project files. pub routing: &'static str, pub select_ms: u64, pub high_risk_active: usize, @@ -430,6 +431,70 @@ const ALWAYS_ON_TOOL_NAMES: &[&str] = &["fetch", "time"]; /// After ranked tools are chosen, skip appending `fetch`/`time` when the user /// message is short, at least one tool already matched, and nothing suggests /// URL fetch, weather, or memory — so trivial native calls stay a single tool. +/// User wants new or updated **repo files** (dot dirs, scaffolds) — not covered by code-review hints. +pub(crate) fn message_suggests_filesystem_mutation(msg: &str) -> bool { + const HINTS: &[&str] = &[ + ".pengine", + "hidden file", + "dotfile", + "dot file", + "dot-directory", + "dot directory", + "create a file", + "create file", + "new file", + "add a file", + "add file", + "write a file", + "write file", + "touch ", + "mkdir", + "make directory", + "create a directory", + "create directory", + "scaffold", + "try to invent", + "invent a", + "project metadata", + "metadata file", + ]; + HINTS + .iter() + .any(|h| crate::modules::skills::service::user_message_needle_match(msg, h)) +} + +fn push_filesystem_mutation_tools( + tools: &[ToolDef], + selected: &mut Vec, + seen: &mut HashSet, +) { + const NAMES: &[&str] = &[ + "write_file", + "edit_file", + "create_directory", + "move_file", + "read_text_file", + "read_multiple_files", + "list_directory", + "list_directory_with_sizes", + "search_files", + "git_status", + "git_diff", + "git_diff_unstaged", + "git_diff_staged", + ]; + for tool in tools { + let short = tool + .name + .rsplit_once('.') + .map(|(_, t)| t) + .unwrap_or(tool.name.as_str()); + if NAMES.iter().any(|n| short.eq_ignore_ascii_case(n)) && seen.insert(tool.name.clone()) { + selected.push(tool.clone()); + } + } +} + fn should_skip_always_on_tools( user_message: &str, recent_tool_names: &[String], @@ -604,6 +669,9 @@ fn route_tools( ) { push_memory_server_tools(tools, memory_server, &mut selected, &mut seen); } + if message_suggests_filesystem_mutation(user_message) { + push_filesystem_mutation_tools(tools, &mut selected, &mut seen); + } selected.sort_by(|a, b| a.name.cmp(&b.name)); return ToolRoutePlan::Subset { tools: selected, @@ -722,7 +790,9 @@ fn score_tool_combined( if tool.name.eq_ignore_ascii_case("fetch") && message_suggests_url_fetch(user_message) { s += 22; } - if message_suggests_code_review_refactor(user_message) { + if message_suggests_code_review_refactor(user_message) + || message_suggests_filesystem_mutation(user_message) + { let short = tool .name .rsplit_once('.') @@ -1147,6 +1217,63 @@ mod tests { } } + #[test] + fn routing_core_no_signal_includes_fs_writes_for_dot_project_metadata() { + let mut tools: Vec = (0..12) + .map(|i| ToolDef { + server_name: "srv".into(), + name: format!("misc_{i}"), + description: None, + input_schema: json!({}), + direct_return: false, + category: None, + risk: ToolRisk::Low, + }) + .collect(); + for name in [ + "fetch", + "time", + "write_file", + "edit_file", + "create_directory", + "read_text_file", + "list_directory", + ] { + tools.push(ToolDef { + server_name: "fm".into(), + name: name.into(), + description: None, + input_schema: json!({}), + direct_return: false, + category: None, + risk: ToolRisk::Low, + }); + } + // Obscure tokens: either `filesystem_mutation` scoring yields a ranked subset, or (if every + // tool still scored 0) `core_no_signal` adds the same fs tools — both must expose writes. + let plan = super::route_tools( + &tools, + ".pengine dotfile scaffold xyzqqq unusedtokens", + &[], + None, + false, + ); + match plan { + super::ToolRoutePlan::Subset { + tools: sel, + routing, + } => { + assert!( + routing == "core_no_signal" || routing == "ranked", + "unexpected routing {routing}: {sel:?}" + ); + assert!(sel.iter().any(|t| t.name == "write_file"), "{sel:?}"); + assert!(sel.iter().any(|t| t.name == "create_directory"), "{sel:?}"); + } + super::ToolRoutePlan::FullCatalog => panic!("expected core subset"), + } + } + #[test] fn routing_german_weather_does_not_auto_attach_memory_mcp() { let mut tools: Vec = (0..12) From 5f3a34f4b4f279521a154fd91ec45ccb60731c14 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Fri, 1 May 2026 01:39:01 +0200 Subject: [PATCH 15/23] feat: add .pengine file support and enhance project context handling - Introduced a new .pengine file to store session metadata, including session ID and project directory. - Updated the agent module to handle project context more effectively by reading from the .pengine file. - Enhanced CLI session management to incorporate project context from the .pengine file, improving user experience during interactions. - Implemented functions to read and manage .pengine file data, ensuring proper handling of project metadata. --- src-tauri/src/modules/agent/mod.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/modules/agent/mod.rs b/src-tauri/src/modules/agent/mod.rs index 9c5284c..3bf4f5d 100644 --- a/src-tauri/src/modules/agent/mod.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -1345,8 +1345,12 @@ async fn run_model_turn( }; let post_tool = tool_rounds > 0; let json_only_user_reply = !has_tools; - let chat_opts = - chat_options_for_agent_step(post_tool, think, json_only_user_reply, repo_write_followthrough); + let chat_opts = chat_options_for_agent_step( + post_tool, + think, + json_only_user_reply, + repo_write_followthrough, + ); let inject_post_tool = post_tool; if inject_post_tool { From 8484e21e60548edacab9c7c05bcb71151aaf565a Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Tue, 12 May 2026 19:35:40 +0200 Subject: [PATCH 16/23] feat: enhance CLI session management with new commands and macOS integration - Added new CLI commands: `/compact` to summarize old session turns and `/new` to start a fresh session, improving user interaction in the REPL. - Updated macOS activation policy to ensure CLI processes are completely hidden from UI surfaces, enhancing user experience during CLI invocations. - Refactored session handling to automatically compact sessions when exceeding a defined turn threshold, optimizing memory usage. - Improved project context handling by auto-resuming the most recent session for the current project, streamlining user workflow. --- src-tauri/src/app.rs | 4 +- src-tauri/src/lib.rs | 1 + src-tauri/src/modules/cli/bootstrap.rs | 29 +++++- src-tauri/src/modules/cli/commands.rs | 18 ++++ src-tauri/src/modules/cli/dispatch.rs | 12 +++ src-tauri/src/modules/cli/handlers.rs | 130 ++++++++++++++++++++++++- src-tauri/src/modules/cli/repl.rs | 55 ++++++++--- src-tauri/src/modules/cli/session.rs | 44 ++++++++- src-tauri/src/prelaunch.rs | 47 ++++++++- 9 files changed, 316 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index a07fd8d..9e2fa4b 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -40,7 +40,9 @@ pub fn run() { // CLI mode short-circuits UI startup (`process::exit`) or returns early // for a GUI child (`PENGINE_OPEN_GUI=1` from `pengine app`). Otherwise // setup continues and `open_main_window` runs at the end. - cli_bootstrap::handle_cli_or_continue(app); + if !cli_bootstrap::handle_cli_or_continue(app) { + return Ok(()); + } let path = store_path(app); let (mcp_path, mcp_src) = mcp_service::resolve_mcp_config_path(&path); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6a72bf7..45a0202 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod shared; pub fn run() { #[cfg(target_os = "macos")] if prelaunch::is_cli_invocation() { + prelaunch::rename_to_cli(); prelaunch::hide_dock_icon(); } app::run(); diff --git a/src-tauri/src/modules/cli/bootstrap.rs b/src-tauri/src/modules/cli/bootstrap.rs index 06c6eac..0b42a5b 100644 --- a/src-tauri/src/modules/cli/bootstrap.rs +++ b/src-tauri/src/modules/cli/bootstrap.rs @@ -12,12 +12,23 @@ use std::io::IsTerminal; use tauri::Manager; use tauri_plugin_cli::{ArgData, CliExt, Matches}; -pub fn handle_cli_or_continue(app: &tauri::App) { +/// Returns `true` when the caller should open the main GUI window. +/// +/// All CLI paths call [`std::process::exit`] and never return; `false` is +/// therefore unreachable in practice, but the explicit type makes `app.rs` +/// defensive: `open_main_window` is guarded behind `if handle_cli_or_continue(…)` +/// and will not run unless this function explicitly signals GUI mode. +pub fn handle_cli_or_continue(app: &tauri::App) -> bool { if consume_gui_spawn_env() { set_macos_activation_policy(app, tauri::ActivationPolicy::Regular); - return; + return true; } - set_macos_activation_policy(app, tauri::ActivationPolicy::Accessory); + // Keep the process completely invisible to all macOS UI surfaces + // (Dock, App Switcher, Stage Manager, Mission Control). + // Prohibited cannot be upgraded to Regular later, which is safe because + // every CLI path ends in `process::exit`. The GUI spawn path (above) + // already returned with Regular before reaching this line. + set_macos_activation_policy(app, tauri::ActivationPolicy::Prohibited); let matches = match app.cli().matches() { Ok(m) => m, @@ -97,7 +108,7 @@ pub fn handle_cli_or_continue(app: &tauri::App) { } if !tty { set_macos_activation_policy(app, tauri::ActivationPolicy::Regular); - return; + return true; } let sink = TerminalSink::new(); let state = match build_state(app) { @@ -477,7 +488,7 @@ where match first.as_str() { "--help" | "-h" | "help" => ArgvIntent::Help, "--version" | "-V" | "version" => ArgvIntent::Version, - "--json" | "--continue" | "-p" | "--print" | "--output-format" => ArgvIntent::CommandLike, + "--json" | "-p" | "--print" | "--output-format" => ArgvIntent::CommandLike, other if !other.starts_with('-') && commands::lookup(other).is_some() => { ArgvIntent::CommandLike } @@ -573,4 +584,12 @@ mod tests { fn argv_intent_none_for_shell_flag_alone() { assert_eq!(argv_intent_from(vec!["--shell"]), ArgvIntent::None); } + + #[test] + fn argv_intent_none_for_continue_alone() { + // `pengine --continue` alone should enter the REPL (resuming the last + // session). The ArgvIntent::None path reads the --continue flag from + // tauri-plugin-cli matches and calls resume_session before starting repl::run. + assert_eq!(argv_intent_from(vec!["--continue"]), ArgvIntent::None); + } } diff --git a/src-tauri/src/modules/cli/commands.rs b/src-tauri/src/modules/cli/commands.rs index b6180f6..1c96b3d 100644 --- a/src-tauri/src/modules/cli/commands.rs +++ b/src-tauri/src/modules/cli/commands.rs @@ -82,6 +82,24 @@ pub const COMMANDS: &[NativeCommand] = &[ details: "Usage: pengine ask \"\"\n\nRuns one agent turn. In REPL, free text without a leading `/` is the same\npath. Prefix with /think or /nothink to override reasoning mode.\n\nFile mentions: tokens like @path/to/file are inlined (capped at 64 KB)\nbefore the prompt is sent.", }, + NativeCommand { + name: "compact", + summary: "Summarize old session turns into a compact memory (REPL-only).", + details: "Usage: /compact\n\n\ + Calls the AI to summarize all turns beyond the recent-turn keep budget\n\ + (last 6 turns are preserved verbatim). The resulting summary is prepended\n\ + to future context so the AI retains key decisions without consuming the\n\ + full prompt window. Compaction also runs automatically in the background\n\ + when the session exceeds 12 turns.", + }, + NativeCommand { + name: "new", + summary: "Start a fresh session (clears in-memory history; disk copy is kept) (REPL-only).", + details: "Usage: /new\n\n\ + Creates an empty session for the current project. The previous session\n\ + is still saved on disk and can be resumed by restarting pengine\n\ + (sessions auto-resume per project).", + }, NativeCommand { name: "clear", summary: "Clear the REPL screen (REPL-only).", diff --git a/src-tauri/src/modules/cli/dispatch.rs b/src-tauri/src/modules/cli/dispatch.rs index 2110bae..a7ea69e 100644 --- a/src-tauri/src/modules/cli/dispatch.rs +++ b/src-tauri/src/modules/cli/dispatch.rs @@ -134,6 +134,18 @@ async fn dispatch_native( handlers::logs(state, tail, follow).await } "ask" => handlers::ask_in_session(state, rest, !ctx.telegram_surface).await, + "compact" => { + if ctx.telegram_surface { + return CliReply::error("compact: not supported over Telegram."); + } + handlers::compact_session(state).await + } + "new" => { + if ctx.telegram_surface { + return CliReply::error("new: not supported over Telegram."); + } + handlers::new_session(state).await + } "app" => { if ctx.telegram_surface { return CliReply::error("app: starting the GUI is not supported over Telegram."); diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index 1730b65..6d9457e 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -2,7 +2,7 @@ use super::commands::{self, NativeCommand}; use super::flavor; use super::mentions; use super::output::{fmt_elapsed, CliReply, Progress, ProgressStatus}; -use super::session::{self, CliSession}; +use super::session::{self, CliSession, HISTORY_TURN_BUDGET}; use crate::build_info; use crate::infrastructure::audit_log; use crate::infrastructure::bot_lifecycle; @@ -567,6 +567,133 @@ pub async fn ask(state: &AppState, text: &str) -> CliReply { ask_in_session(state, text, true).await } +/// `/new` — discard the in-memory session and start fresh for the current project. +/// The previous session is still on disk and will be auto-resumed on the next REPL start. +pub async fn new_session(state: &AppState) -> CliReply { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let project = session::detect_project_context(&cwd); + *state.cli_session.write().await = Some(CliSession::fresh_with_project(project)); + CliReply::text( + "started a new session — history cleared in memory. \ + Previous session is still on disk and will auto-resume on next restart.", + ) +} + +/// `/compact` — call the AI to summarize old turns, store as `session.summary`, +/// and keep only the last `HISTORY_TURN_BUDGET` turns verbatim. +pub async fn compact_session(state: &AppState) -> CliReply { + let (to_compact, prior_summary, _drop_upto) = { + let guard = state.cli_session.read().await; + let Some(sess) = guard.as_ref() else { + return CliReply::error("no active session — start a conversation first"); + }; + if sess.turns.is_empty() { + return CliReply::text("session is empty — nothing to compact"); + } + let keep = HISTORY_TURN_BUDGET.min(sess.turns.len()); + let drop_upto = sess.turns.len().saturating_sub(keep); + if drop_upto == 0 && sess.summary.is_none() { + return CliReply::text(format!( + "session has {} turn(s) — within the {HISTORY_TURN_BUDGET}-turn keep budget; nothing to compact", + sess.turns.len() + )); + } + // Compact ALL turns (not just the excess) so a re-compact also merges the prior summary. + (sess.turns.clone(), sess.summary.clone(), drop_upto) + }; + + let prompt = session::compact_prompt(prior_summary.as_deref(), &to_compact); + let progress = Progress::start("Compacting session…"); + let result = agent::run_system_turn(state, &prompt, None).await; + let elapsed = progress.finish().await; + emit_baked_line(elapsed); + + match result { + Ok(turn) => { + let compacted_count = to_compact.len(); + let keep = HISTORY_TURN_BUDGET.min(compacted_count); + let mut guard = state.cli_session.write().await; + if let Some(sess) = guard.as_mut() { + session::apply_compaction(sess, turn.text, keep); + let snapshot = sess.clone(); + drop(guard); + if let Err(e) = session::save(&state.store_path, &snapshot) { + state.emit_log("cli", &format!("compact save: {e}")).await; + } + } + CliReply::text(format!( + "Compacted {compacted_count} turn(s) → summary + {keep} recent turn(s) kept verbatim." + )) + } + Err(e) => CliReply::error(format!("compact: {e}")), + } +} + +/// Trigger background auto-compaction when the session grows beyond the threshold. +/// Runs silently after a turn completes; the compacted session is ready for the next turn. +pub(super) async fn spawn_compaction_if_needed(state: &AppState) { + let needs = { + let g = state.cli_session.read().await; + g.as_ref() + .map(|s| s.turns.len() > session::COMPACT_THRESHOLD) + .unwrap_or(false) + }; + if !needs { + return; + } + let state = state.clone(); + tauri::async_runtime::spawn(async move { + compact_session_background(&state).await; + }); +} + +async fn compact_session_background(state: &AppState) { + let (to_compact, prior_summary, keep) = { + let g = state.cli_session.read().await; + let Some(sess) = g.as_ref() else { return }; + if sess.turns.len() <= session::COMPACT_THRESHOLD { + return; + } + let keep = HISTORY_TURN_BUDGET; + let drop_upto = sess.turns.len().saturating_sub(keep); + ( + sess.turns[..drop_upto].to_vec(), + sess.summary.clone(), + keep, + ) + }; + + if to_compact.is_empty() { + return; + } + + let prompt = session::compact_prompt(prior_summary.as_deref(), &to_compact); + match agent::run_system_turn(state, &prompt, None).await { + Ok(result) => { + let mut g = state.cli_session.write().await; + if let Some(sess) = g.as_mut() { + if sess.turns.len() > HISTORY_TURN_BUDGET { + session::apply_compaction(sess, result.text, keep); + let snapshot = sess.clone(); + drop(g); + if let Err(e) = session::save(&state.store_path, &snapshot) { + state.emit_log("cli", &format!("auto-compact save: {e}")).await; + } else { + state + .emit_log("cli", "session auto-compacted in background") + .await; + } + } + } + } + Err(e) => { + state + .emit_log("cli", &format!("auto-compact failed: {e}")) + .await; + } + } +} + /// `ask` variant that lets callers (one-shot CLI vs REPL vs Telegram) decide /// whether to extend the persistent session. pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) -> CliReply { @@ -663,6 +790,7 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) if let Err(e) = session::save(&state.store_path, &snapshot) { state.emit_log("cli", &format!("session save: {e}")).await; } + spawn_compaction_if_needed(state).await; } let mut body = turn.text; if !expanded.errors.is_empty() { diff --git a/src-tauri/src/modules/cli/repl.rs b/src-tauri/src/modules/cli/repl.rs index 070c03e..afe6e2d 100644 --- a/src-tauri/src/modules/cli/repl.rs +++ b/src-tauri/src/modules/cli/repl.rs @@ -36,30 +36,39 @@ const PROMPT_PLAIN: &str = "> "; pub async fn run(state: &AppState) -> CliReply { let sink = TerminalSink::new(); - // Capture project context (cwd + git root + branch) and preset the - // session so banners, persistence, and per-folder `--continue` all - // use the same identity. If `--continue` already loaded a session - // for this folder, keep it; otherwise create a fresh one. + // Capture project context (cwd + git root + branch) and preset the session. + // If bootstrap already loaded a session (e.g. `--continue`), keep it. + // Otherwise auto-resume the most recent session for this project so the AI + // remembers prior turns without the user needing `--continue` every time. let project = std::env::current_dir() .ok() .map(|cwd| session::detect_project_context(&cwd)); - { + + let resumed_turns = { let mut guard = state.cli_session.write().await; match guard.as_mut() { Some(existing) if existing.project.is_none() => { existing.project = project.clone(); + existing.turns.len() } - Some(_) => {} + Some(existing) => existing.turns.len(), None => { - *guard = Some(match project.clone() { - Some(p) => CliSession::fresh_with_project(p), - None => CliSession::fresh(), + // Auto-resume: try per-project first, fall back to global last. + let loaded = auto_load_session(&state.store_path, project.as_ref()); + let n = loaded.as_ref().map(|s| s.turns.len()).unwrap_or(0); + *guard = Some(match loaded { + Some(s) => s, + None => match project.clone() { + Some(p) => CliSession::fresh_with_project(p), + None => CliSession::fresh(), + }, }); + n } } - } + }; - sink.render(&CliReply::text(format!( + let mut banner = format!( "{}\ \n\ Pengine REPL — slash commands + free text; /exit or Ctrl+D to quit.\n\ @@ -67,7 +76,14 @@ store: {}{}", CLI_WELCOME.trim_start_matches('\n'), state.store_path.display(), format_project_banner_lines(project.as_ref()), - ))); + ); + if resumed_turns > 0 { + banner.push_str(&format!( + "\nsession: resumed {resumed_turns} turn(s) \ + — /compact to summarize, /new to start fresh" + )); + } + sink.render(&CliReply::text(banner)); if std::io::stdout().is_terminal() { sink.render(&CliReply::text(format!( "\n\x1b[2m{}\x1b[0m", @@ -243,6 +259,21 @@ store: {}{}", CliReply::text("bye.") } +/// Try to load the most recent CLI session for `project` from disk. +/// Falls back to the global last session when no project-specific one exists. +/// Returns `None` when there are no saved sessions at all. +fn auto_load_session( + store_path: &std::path::Path, + project: Option<&session::ProjectContext>, +) -> Option { + if let Some(p) = project { + if let Ok(Some(s)) = session::load_last_for_path(store_path, p.match_key()) { + return Some(s); + } + } + session::load_last(store_path).ok().flatten() +} + fn is_clear_command(line: &str) -> bool { let t = line.trim(); matches!(t, "/clear" | "clear") diff --git a/src-tauri/src/modules/cli/session.rs b/src-tauri/src/modules/cli/session.rs index d4b0448..82763bb 100644 --- a/src-tauri/src/modules/cli/session.rs +++ b/src-tauri/src/modules/cli/session.rs @@ -17,9 +17,13 @@ const BY_PATH_POINTER: &str = "cli_session_by_path.json"; /// Cap applied when building the context prefix for a new turn. /// Keeps the prompt size predictable across long sessions. -const HISTORY_TURN_BUDGET: usize = 6; +pub const HISTORY_TURN_BUDGET: usize = 6; const HISTORY_BYTES_BUDGET: usize = 12_000; +/// When the session exceeds this many turns, a background compaction is +/// triggered automatically after the next turn completes. +pub const COMPACT_THRESHOLD: usize = HISTORY_TURN_BUDGET * 2; + /// Max bytes read from the hidden project file `.pengine` (file only, not a directory). const DOT_PENGINE_MAX_BYTES: usize = 32_768; @@ -148,6 +152,44 @@ impl CliSession { } } +/// Build a prompt asking the AI to summarize `turns` into a compact memory block. +/// Merges `prior_summary` when present so repeated compactions accumulate. +pub fn compact_prompt(prior_summary: Option<&str>, turns: &[SessionTurn]) -> String { + let mut out = String::from( + "Summarize the following conversation for future context. \ + Capture: key decisions, file paths or commands used, outcomes, open questions, \ + anything the AI should remember for follow-up turns. \ + If a prior summary is included, merge it into the new summary. \ + Output ONLY the merged summary — no preamble, no meta-commentary.\n\n", + ); + if let Some(s) = prior_summary.filter(|s| !s.trim().is_empty()) { + out.push_str("## Prior summary (merge this in)\n"); + out.push_str(s.trim()); + out.push_str("\n\n"); + } + out.push_str("## Turns to summarize\n"); + for t in turns { + out.push_str(&format!( + "[user] {}\n[assistant] {}\n\n", + t.user.trim(), + t.assistant.trim() + )); + } + out +} + +/// Compact the session in place: drain `turns[0..len-keep]`, store `summary`. +/// Caller is responsible for saving the session afterward. +pub fn apply_compaction(session: &mut CliSession, summary: String, keep: usize) { + let drop_upto = session.turns.len().saturating_sub(keep); + if drop_upto == 0 { + session.summary = Some(summary); + return; + } + session.turns.drain(0..drop_upto); + session.summary = Some(summary); +} + fn sessions_dir(store_path: &Path) -> PathBuf { store_path .parent() diff --git a/src-tauri/src/prelaunch.rs b/src-tauri/src/prelaunch.rs index a3e14e0..a003eef 100644 --- a/src-tauri/src/prelaunch.rs +++ b/src-tauri/src/prelaunch.rs @@ -9,14 +9,30 @@ //! flash before [`crate::modules::cli::bootstrap::handle_cli_or_continue`] //! runs from inside Tauri's `setup` callback. We close that gap by reading //! `argv` / `env` ourselves at the top of `lib::run()` and calling -//! `[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]` -//! before `tauri::Builder::default()` initializes anything. +//! `[NSApp setActivationPolicy:…]` before `tauri::Builder::default()` +//! initializes anything. +//! +//! ## Policy choice +//! +//! CLI invocations (all paths that exit without opening a window) use +//! `NSApplicationActivationPolicyProhibited`, which is the strongest +//! possible hide: the process does not appear in the Dock, App Switcher, +//! Stage Manager, Mission Control, or any other macOS UI surface. +//! `Prohibited` cannot be overridden once set, so even if Tauri's internal +//! NSApplication initialization briefly promotes to `Regular`, the policy +//! holds. +//! +//! GUI invocations (Finder double-click, `open -a`, stdin not a TTY) do +//! **not** call this function; they start with whatever policy `Info.plist` +//! declares (`Accessory` via `LSUIElement=true`) and are promoted to +//! `Regular` inside `bootstrap::handle_cli_or_continue`. //! //! This mirrors the CLI/GUI detection in `bootstrap::handle_cli_or_continue` //! — but it can't import that code because it must run before `tauri::App` //! exists. Keep the two in sync when CLI subcommands or env markers change. use std::env; +use std::ffi::CString; use std::io::IsTerminal; use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; @@ -73,15 +89,38 @@ pub fn is_cli_invocation() -> bool { | "logs" | "ask" | "app" + | "compact" + | "new" ) } -/// Promote NSApp to `Accessory` before Tauri's run loop starts. No-op when +/// Rename the process to `pengine-cli` so it is distinguishable from the +/// GUI process in Activity Monitor and `ps`. Uses the POSIX `setprogname(3)` +/// call available on macOS/BSD — changes the argv[0] name used by the OS. +pub fn rename_to_cli() { + if let Ok(name) = CString::new("pengine-cli") { + // SAFETY: `setprogname` only reads the pointer; the CString lives + // for the rest of `main`, so the pointer stays valid. + unsafe { + extern "C" { + fn setprogname(name: *const std::ffi::c_char); + } + setprogname(name.as_ptr()); + } + } +} + +/// Set NSApp to `Prohibited` before Tauri's run loop starts so the CLI +/// process is invisible to every macOS UI surface (Dock, App Switcher, +/// Stage Manager, Mission Control). +/// +/// `Prohibited` cannot be upgraded to `Regular` later, which is exactly +/// right for CLI paths — they always end in `process::exit`. No-op when /// not on the main thread (defensive — `lib::run()` is always main). pub fn hide_dock_icon() { let Some(mtm) = MainThreadMarker::new() else { return; }; let app = NSApplication::sharedApplication(mtm); - app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); + app.setActivationPolicy(NSApplicationActivationPolicy::Prohibited); } From b27bb03e9c1f3473e19fde564fdf11eaddad7b30 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Tue, 12 May 2026 19:36:10 +0200 Subject: [PATCH 17/23] refactor: streamline session compaction logic in CLI handlers - Simplified the return statement in the `compact_session_background` function for improved readability. - Enhanced logging formatting for session auto-compaction errors, ensuring better clarity in error messages. --- src-tauri/src/modules/cli/handlers.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index 6d9457e..5a5abeb 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -656,11 +656,7 @@ async fn compact_session_background(state: &AppState) { } let keep = HISTORY_TURN_BUDGET; let drop_upto = sess.turns.len().saturating_sub(keep); - ( - sess.turns[..drop_upto].to_vec(), - sess.summary.clone(), - keep, - ) + (sess.turns[..drop_upto].to_vec(), sess.summary.clone(), keep) }; if to_compact.is_empty() { @@ -677,7 +673,9 @@ async fn compact_session_background(state: &AppState) { let snapshot = sess.clone(); drop(g); if let Err(e) = session::save(&state.store_path, &snapshot) { - state.emit_log("cli", &format!("auto-compact save: {e}")).await; + state + .emit_log("cli", &format!("auto-compact save: {e}")) + .await; } else { state .emit_log("cli", "session auto-compacted in background") From 42623cff60c137331e6b612e319fa280227c84e4 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Wed, 13 May 2026 00:52:38 +0200 Subject: [PATCH 18/23] feat: enhance CLI session management with new session commands and live output tracking - Introduced a new `/session` command for managing named sessions, allowing users to list, create, switch, rename, and delete sessions. - Implemented session auto-compaction when exceeding a defined turn threshold, optimizing memory usage. - Added support for live output token tracking during chat interactions, improving user feedback on token generation. - Enhanced session metadata handling by saving session summaries and project context, streamlining user experience during CLI interactions. --- src-tauri/src/modules/agent/mod.rs | 70 ++++- src-tauri/src/modules/bot/service.rs | 2 +- src-tauri/src/modules/cli/banner.rs | 42 +-- src-tauri/src/modules/cli/commands.rs | 19 +- src-tauri/src/modules/cli/dispatch.rs | 7 + src-tauri/src/modules/cli/flavor.rs | 43 ++- src-tauri/src/modules/cli/handlers.rs | 347 +++++++++++++++++++++--- src-tauri/src/modules/cli/output.rs | 151 ++++++++++- src-tauri/src/modules/cli/repl.rs | 150 +++++++++- src-tauri/src/modules/cli/session.rs | 145 +++++++++- src-tauri/src/modules/ollama/service.rs | 138 +++++++++- src-tauri/src/modules/skills/service.rs | 62 ++--- tools/skills/weather/SKILL.md | 3 +- 13 files changed, 1033 insertions(+), 146 deletions(-) diff --git a/src-tauri/src/modules/agent/mod.rs b/src-tauri/src/modules/agent/mod.rs index 3bf4f5d..1255a74 100644 --- a/src-tauri/src/modules/agent/mod.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -14,6 +14,7 @@ use chrono::Utc; use serde_json::json; use std::collections::HashSet; use std::path::Path; +use std::sync::{atomic::AtomicU64, Arc}; use std::time::{Duration, Instant}; /// Tool rounds + at least one completion-only step. Research flows (sitemap + several @@ -546,6 +547,18 @@ fn message_implies_apply_repo_fix(msg: &str) -> bool { .any(|h| skills::user_message_needle_match(msg, h)) } +/// Extracts only the current turn's user message from the full context string. +/// The full context passed to `run_model_turn` includes session history separated +/// by "## New user message\n" markers; skill gates must only see the NEW message, +/// not old turns (which may contain unrelated keywords like weather from a prior query). +fn current_turn_message(user_message: &str) -> &str { + const MARKER: &str = "## New user message\n"; + match user_message.rfind(MARKER) { + Some(pos) => user_message[pos + MARKER.len()..].trim_start(), + None => user_message, + } +} + /// Lint/fix flows plus explicit create/write/scaffold requests (`.pengine`, new files, etc.). fn user_expects_repo_write_followthrough(msg: &str) -> bool { message_implies_apply_repo_fix(msg) @@ -756,6 +769,11 @@ pub struct TurnResult { pub prompt_tokens: u64, pub eval_tokens: u64, pub model: String, + /// Whether the model was asked to think (extended reasoning / chain-of-thought). + /// `eval_tokens` includes thinking tokens when this is `true`. + pub think_enabled: bool, + /// Number of agent steps taken (tool-call rounds + final completion). + pub steps: u32, } impl TurnResult { @@ -767,6 +785,8 @@ impl TurnResult { prompt_tokens: 0, eval_tokens: 0, model: String::new(), + think_enabled: false, + steps: 0, } } @@ -778,6 +798,8 @@ impl TurnResult { prompt_tokens: 0, eval_tokens: 0, model: String::new(), + think_enabled: false, + steps: 0, } } } @@ -796,7 +818,7 @@ pub async fn run_system_turn( skills_slug_filter: Option<&[String]>, ) -> Result { let think = decide_think(None, prompt).enabled(); - let result = run_model_turn(state, prompt, think, skills_slug_filter).await?; + let result = run_model_turn(state, prompt, think, skills_slug_filter, None).await?; let body = result.text.trim(); if !body.is_empty() { let tag = match result.source { @@ -812,7 +834,11 @@ pub async fn run_system_turn( Ok(result) } -pub async fn run_turn(state: &AppState, user_message: &str) -> Result { +pub async fn run_turn( + state: &AppState, + user_message: &str, + live_out_tokens: Option>, +) -> Result { let (think_override, user_message) = parse_think_override(user_message); if let Some(cmd) = memory::detect_session_command(user_message) { @@ -835,7 +861,9 @@ pub async fn run_turn(state: &AppState, user_message: &str) -> Result, + live_out_tokens: Option>, ) -> Result { let plan_mode = *state.plan_mode.read().await; - let repo_write_followthrough = user_expects_repo_write_followthrough(user_message); + let repo_write_followthrough = + user_expects_repo_write_followthrough(current_turn_message(user_message)); let max_steps = if repo_write_followthrough { MAX_STEPS_APPLY_FIX } else { @@ -1263,8 +1297,10 @@ async fn run_model_turn( // memory provider exists, not only after explicit memory keywords. let cli_session_active = state.cli_session.read().await.is_some(); - let allow_brave_web_search = - skills::allow_brave_web_search_for_message(&state.store_path, user_message); + let allow_brave_web_search = skills::allow_brave_web_search_for_message( + &state.store_path, + current_turn_message(user_message), + ); let mut tool_ctx = { let reg = state.mcp.read().await; @@ -1345,12 +1381,13 @@ async fn run_model_turn( }; let post_tool = tool_rounds > 0; let json_only_user_reply = !has_tools; - let chat_opts = chat_options_for_agent_step( + let mut chat_opts = chat_options_for_agent_step( post_tool, think, json_only_user_reply, repo_write_followthrough, ); + chat_opts.live_out_tokens = live_out_tokens.clone(); let inject_post_tool = post_tool; if inject_post_tool { @@ -1444,6 +1481,8 @@ async fn run_model_turn( r.prompt_tokens = tokens_in; r.eval_tokens = tokens_out; r.model = model.clone(); + r.think_enabled = think; + r.steps = step as u32 + 1; return Ok(r); } if tool_results.is_empty() { @@ -1451,6 +1490,8 @@ async fn run_model_turn( r.prompt_tokens = tokens_in; r.eval_tokens = tokens_out; r.model = model.clone(); + r.think_enabled = think; + r.steps = step as u32 + 1; return Ok(r); } if repo_write_followthrough @@ -1685,6 +1726,8 @@ async fn run_model_turn( prompt_tokens: tokens_in, eval_tokens: tokens_out, model: model.clone(), + think_enabled: think, + steps: step as u32 + 1, }); } } @@ -1712,6 +1755,7 @@ async fn run_model_turn( num_predict: Some(SUMMARY_NUM_PREDICT), temperature: Some(SUMMARY_TEMPERATURE), format: Some(ollama::summarize_reply_json_schema()), + live_out_tokens: live_out_tokens.clone(), ..ChatOptions::default() }; let t0 = Instant::now(); @@ -1747,6 +1791,8 @@ async fn run_model_turn( prompt_tokens: tokens_in, eval_tokens: tokens_out, model: model.clone(), + think_enabled: think, + steps: max_steps as u32, }); } @@ -1758,6 +1804,8 @@ async fn run_model_turn( prompt_tokens: tokens_in, eval_tokens: tokens_out, model: model.clone(), + think_enabled: think, + steps: max_steps as u32, }); } diff --git a/src-tauri/src/modules/bot/service.rs b/src-tauri/src/modules/bot/service.rs index e8fa824..b44118a 100644 --- a/src-tauri/src/modules/bot/service.rs +++ b/src-tauri/src/modules/bot/service.rs @@ -141,7 +141,7 @@ async fn text_handler(bot: Bot, msg: Message, state: AppState) -> ResponseResult return Ok(()); } - let result = agent::run_turn(&state, &incoming).await; + let result = agent::run_turn(&state, &incoming, None).await; typing_task.abort(); match result { diff --git a/src-tauri/src/modules/cli/banner.rs b/src-tauri/src/modules/cli/banner.rs index 3297dfb..dd40675 100644 --- a/src-tauri/src/modules/cli/banner.rs +++ b/src-tauri/src/modules/cli/banner.rs @@ -2,27 +2,27 @@ /// Shown above the REPL prompt (bare `pengine` in a TTY). pub const CLI_WELCOME: &str = concat!( - r" - :; ;; - ;;;;; ; - ;;;;;;;; - ;;;;;;;;,;;;; - ;•◘◘◘○◘◘t;○◘;;;; - ;I;;;;;::;;;;▒:;;; - ;;;;;;;;;;;;;◘◘;;; - ;◘;▓▓▓▓▓,;◘◘◘◘•;;; - ~,◘◘;;:;○◘◘◘◘◘;;; - ;;;•◘◘◘○◘◘◘;;;;;;;. - ;;I;◘◘◘;;♣◘◘◘◘:;iI;;;; - ;;;::•;◘;;◘.◘;○;I:;;;;;; - ;;;;II;◘◘;;◘◘○I;II;;;;;;;; - ;;;;;I;◘;;;◘◘;II;;;;;l;;;; - ;;;;;;;;;;;:I;;;;;;;;I;;;; - ;;W;;;;;;;;;;;;;;;;;;;W,; - WWWWWW▓;;WW;;WW:▓WWWWW~ - %;;W,,W:WWMWW;;WW;;& ; - ;; ; ;W;;! ;W;; - ;W; ; + r" + :; ;; + ;;;;; ; + ;;;;;;;; + ;;;;;;;;,;;;; + ;•◘◘◘○◘◘t;○◘;;;; + ;I;;;;;::;;;;▒:;;; + ;;;;;;;;;;;;;◘◘;;; + ;◘;▓▓▓▓▓,;◘◘◘◘•;;; + ~,◘◘;;:;○◘◘◘◘◘;;; + ;;;•◘◘◘○◘◘◘;;;;;;;. + ;;I;◘◘◘;;♣◘◘◘◘:;iI;;;; + ;;;::•;◘;;◘.◘;○;I:;;;;;; + ;;;;II;◘◘;;◘◘○I;II;;;;;;;; + ;;;;;I;◘;;;◘◘;II;;;;;l;;;; + ;;;;;;;;;;;:I;;;;;;;;I;;;; + ;;W;;;;;;;;;;;;;;;;;;;W,; + WWWWWW▓;;WW;;WW:▓WWWWW~ + %;;W,,W:WWMWW;;WW;;& ; + ;; ; ;W;;! ;W;; + ;W; ; ", "\n\nPengine CLI — type /help for slash commands.\n", ); diff --git a/src-tauri/src/modules/cli/commands.rs b/src-tauri/src/modules/cli/commands.rs index 1c96b3d..9188b7b 100644 --- a/src-tauri/src/modules/cli/commands.rs +++ b/src-tauri/src/modules/cli/commands.rs @@ -92,13 +92,26 @@ pub const COMMANDS: &[NativeCommand] = &[ full prompt window. Compaction also runs automatically in the background\n\ when the session exceeds 12 turns.", }, + NativeCommand { + name: "session", + summary: "Manage named sessions: list, new, switch, rename (REPL-only).", + details: "Usage: /session list # list all saved sessions\n \ + /session new [name] # start a fresh session (optional name)\n \ + /session switch # resume a saved session\n \ + /session rename # name or rename the active session\n \ + /session delete # delete a saved session from disk\n \ + /session help # show all session subcommands\n\n\ + Sessions persist across restarts. Each session keeps a turn history and\n\ + a compacted summary for context. /session switch saves the current session\n\ + before switching. The previous /new command is a shortcut for /session new.", + }, NativeCommand { name: "new", - summary: "Start a fresh session (clears in-memory history; disk copy is kept) (REPL-only).", + summary: "Start a fresh session (shortcut for /session new) (REPL-only).", details: "Usage: /new\n\n\ Creates an empty session for the current project. The previous session\n\ - is still saved on disk and can be resumed by restarting pengine\n\ - (sessions auto-resume per project).", + is still saved on disk and can be resumed with /session switch.\n\ + Shortcut for /session new.", }, NativeCommand { name: "clear", diff --git a/src-tauri/src/modules/cli/dispatch.rs b/src-tauri/src/modules/cli/dispatch.rs index a7ea69e..0185728 100644 --- a/src-tauri/src/modules/cli/dispatch.rs +++ b/src-tauri/src/modules/cli/dispatch.rs @@ -140,6 +140,13 @@ async fn dispatch_native( } handlers::compact_session(state).await } + "session" => { + if ctx.telegram_surface { + return CliReply::error("session: not supported over Telegram."); + } + let (action, tail) = split_first(rest); + handlers::session_cmd(state, action, tail).await + } "new" => { if ctx.telegram_surface { return CliReply::error("new: not supported over Telegram."); diff --git a/src-tauri/src/modules/cli/flavor.rs b/src-tauri/src/modules/cli/flavor.rs index d5a63ae..160bf21 100644 --- a/src-tauri/src/modules/cli/flavor.rs +++ b/src-tauri/src/modules/cli/flavor.rs @@ -20,19 +20,40 @@ pub fn fun_pair(a: &'static str, b: &'static str) -> String { } } -/// Main spinner label (replaces a static "Thinking"). +/// Main spinner label — one funny invented word shown as a present participle. +/// Format in the spinner: `⠹ Thonking · 4s` pub fn thinking_label() -> &'static str { pick_str(&[ - "Thinking", - "Pondering", - "Consulting the weights", - "Herding tensors", - "Asking the model nicely", - "Brewing an answer", - "Entangling context", - "Feeding the prompt beast", - "One sec — math is happening", - "Staring at matrices until they blink", + "Thonking", // thinking + honk + "Grokking", // hacker lore: deep understanding + "Blorping", // invented + "Wibbling", // wobbly invented word + "Frobnifying", // frobnicate = fiddle (hacker lore) + "Zorbulating", // invented + "Kerfluffling", // from kerfuffle + "Discombobulating", + "Quuxing", // quux: hacker placeholder (foo/bar/baz/quux) + "Schmozzling", // invented + "Noodling", // slang: loosely brainstorming + "Percolating", // ideas brewing slowly + "Boffinating", // boffin = British slang for a clever scientist + "Glonking", // invented + "Tronking", // Tron reference + "Flerbulating", // invented + "Blorbinating", // invented + "Cogitating", // real but sounds delightfully stuffy + "Woolgathering", // real expression: absent-minded musing + "Slorbing", // invented + "Murgling", // invented + "Gnurfling", // invented + "Zippulating", // invented + "Bebboning", // invented + "Snorkling", // near-snorkeling, but for data + "Wuffling", // invented + "Schmoogling", // invented + "Grumpulating", // invented + "Bewildering", // turning confusion into answers + "Simmering", // ideas on low heat ]) } diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index 5a5abeb..284fa28 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -105,12 +105,19 @@ pub async fn status(state: &AppState) -> CliReply { let session_line = { let snap = state.cli_session.read().await.clone(); match snap { - Some(s) => format!( - "session: turns={} tokens_in={} tokens_out={}", - s.turns.len(), - s.prompt_tokens_total, - s.eval_tokens_total - ), + Some(s) => { + let name_part = s + .name + .as_deref() + .map(|n| format!("name={n} ")) + .unwrap_or_default(); + format!( + "session: {name_part}turns={} tokens_in={} tokens_out={}", + s.turns.len(), + s.prompt_tokens_total, + s.eval_tokens_total + ) + } None => "session: no active CLI session".to_string(), } }; @@ -567,16 +574,221 @@ pub async fn ask(state: &AppState, text: &str) -> CliReply { ask_in_session(state, text, true).await } -/// `/new` — discard the in-memory session and start fresh for the current project. -/// The previous session is still on disk and will be auto-resumed on the next REPL start. -pub async fn new_session(state: &AppState) -> CliReply { +/// `/session` — named session management: list, new, switch, rename. +pub async fn session_cmd(state: &AppState, action: &str, rest: &str) -> CliReply { + match action.trim() { + "" | "list" => session_list(state).await, + "help" => session_help(), + "new" => session_new(state, rest.trim()).await, + "switch" => session_switch(state, rest.trim()).await, + "rename" => session_rename(state, rest.trim()).await, + "delete" => session_delete(state, rest.trim()).await, + other => CliReply::error(format!( + "session: unknown action `{other}` — try /session help" + )), + } +} + +fn session_help() -> CliReply { + CliReply::code( + "bash", + "\ +/session list list all saved sessions (newest first) +/session new [name] start a fresh session; save and compact current first +/session switch resume a saved session; save current first +/session rename give the active session a name +/session delete delete a saved session from disk (cannot delete the active one) +/session help show this help + +Notes: + - Sessions persist on disk across restarts. + - Auto-compaction runs in the background when a session exceeds 12 turns. + - /new is a shortcut for /session new (no name)." + .trim_end(), + ) +} + +async fn session_list(state: &AppState) -> CliReply { + let manifest = session::load_manifest(&state.store_path); + if manifest.entries.is_empty() { + return CliReply::text("no saved sessions"); + } + let active_id = state + .cli_session + .read() + .await + .as_ref() + .map(|s| s.id.clone()); + let mut out = format!( + "{:<3} {:<15} {:<22} {:<5} {}\n{}\n", + " ", + "id", + "name", + "turns", + "branch", + "─".repeat(72), + ); + for e in manifest.entries.iter().rev() { + let marker = if active_id.as_deref() == Some(e.id.as_str()) { + "▶" + } else { + " " + }; + let name = e.name.as_deref().unwrap_or("—"); + let branch = e.git_branch.as_deref().unwrap_or("—"); + out.push_str(&format!( + "{marker:<3} {:<15} {name:<22} {:<5} {branch}\n", + e.id, e.turn_count, + )); + if let Some(snippet) = &e.summary_snippet { + out.push_str(&format!(" {snippet}\n")); + } + } + CliReply::code("bash", out.trim_end()) +} + +async fn session_new(state: &AppState, name: &str) -> CliReply { + // Save current session before replacing it. + { + let snap = state.cli_session.read().await.clone(); + if let Some(s) = snap { + if let Err(e) = session::save(&state.store_path, &s) { + state.emit_log("cli", &format!("session save: {e}")).await; + } + } + } + spawn_compaction_if_needed(state).await; + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let project = session::detect_project_context(&cwd); - *state.cli_session.write().await = Some(CliSession::fresh_with_project(project)); - CliReply::text( - "started a new session — history cleared in memory. \ - Previous session is still on disk and will auto-resume on next restart.", - ) + let mut new_sess = CliSession::fresh_with_project(project); + if !name.is_empty() { + new_sess.name = Some(name.to_string()); + } + let label = new_sess.name.clone().unwrap_or_else(|| new_sess.id.clone()); + *state.cli_session.write().await = Some(new_sess); + CliReply::text(format!("started new session: {label}")) +} + +async fn session_switch(state: &AppState, query: &str) -> CliReply { + if query.is_empty() { + return CliReply::error("session switch: name or id required (see /session list)"); + } + let target = match session::load_by_name_or_id(&state.store_path, query) { + Ok(Some(s)) => s, + Ok(None) => { + return CliReply::error(format!( + "session switch: no session matching `{query}` (see /session list)" + )) + } + Err(e) => return CliReply::error(format!("session switch: {e}")), + }; + // Don't switch to the already-active session. + let active_id = state + .cli_session + .read() + .await + .as_ref() + .map(|s| s.id.clone()); + if active_id.as_deref() == Some(target.id.as_str()) { + return CliReply::text(format!( + "session `{}` is already active", + target.name.as_deref().unwrap_or(&target.id) + )); + } + // Save + compact current session before switching. + spawn_compaction_if_needed(state).await; + { + let snap = state.cli_session.read().await.clone(); + if let Some(s) = snap { + if let Err(e) = session::save(&state.store_path, &s) { + state.emit_log("cli", &format!("session save: {e}")).await; + } + } + } + let turn_count = target.turns.len(); + let label = target.name.clone().unwrap_or_else(|| target.id.clone()); + let summary_line = target + .summary + .as_deref() + .map(|s| { + let snippet: String = s.trim().chars().take(100).collect(); + format!("\n summary: {snippet}") + }) + .unwrap_or_default(); + *state.cli_session.write().await = Some(target); + CliReply::text(format!( + "switched to session: {label} (turns={turn_count}){summary_line}" + )) +} + +async fn session_delete(state: &AppState, query: &str) -> CliReply { + if query.is_empty() { + return CliReply::error("session delete: name or id required (see /session list)"); + } + let manifest = session::load_manifest(&state.store_path); + let q_lower = query.to_ascii_lowercase(); + let entry = manifest + .entries + .iter() + .rev() + .find(|e| { + e.name + .as_deref() + .map(|n| n.eq_ignore_ascii_case(query)) + .unwrap_or(false) + || e.id == query + || e.id.to_ascii_lowercase().starts_with(&q_lower) + }) + .cloned(); + let Some(entry) = entry else { + return CliReply::error(format!( + "session delete: no session matching `{query}` (see /session list)" + )); + }; + // Refuse to delete the currently active session. + let active_id = state + .cli_session + .read() + .await + .as_ref() + .map(|s| s.id.clone()); + if active_id.as_deref() == Some(entry.id.as_str()) { + return CliReply::error( + "session delete: cannot delete the active session — switch away first", + ); + } + let label = entry + .name + .as_deref() + .map(|n| n.to_string()) + .unwrap_or_else(|| entry.id.clone()); + if let Err(e) = session::delete(&state.store_path, &entry.id) { + return CliReply::error(format!("session delete: {e}")); + } + CliReply::text(format!("deleted session: {label}")) +} + +async fn session_rename(state: &AppState, name: &str) -> CliReply { + if name.is_empty() { + return CliReply::error("session rename: new name required"); + } + let mut guard = state.cli_session.write().await; + let Some(sess) = guard.as_mut() else { + return CliReply::error("session rename: no active session"); + }; + sess.name = Some(name.to_string()); + let snapshot = sess.clone(); + drop(guard); + if let Err(e) = session::save(&state.store_path, &snapshot) { + return CliReply::error(format!("session rename: save failed: {e}")); + } + CliReply::text(format!("session renamed to: {name}")) +} + +/// `/new` — shortcut for `/session new` (no name). +pub async fn new_session(state: &AppState) -> CliReply { + session_new(state, "").await } /// `/compact` — call the AI to summarize old turns, store as `session.summary`, @@ -754,18 +966,22 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) }; let progress = Progress::start(flavor::thinking_label().to_string()); + let token_counter = progress.token_counter(); let forwarder = spawn_status_forwarder(state, progress.status_sender()).await; - let result = agent::run_turn(state, &prompt_for_agent).await; + let result = agent::run_turn(state, &prompt_for_agent, Some(token_counter)).await; if let Some(h) = forwarder { h.abort(); } let elapsed = progress.finish().await; - emit_baked_line(elapsed); match result { - Ok(turn) if turn.suppress_telegram_reply => CliReply::text("(no reply)"), + Ok(turn) if turn.suppress_telegram_reply => { + emit_baked_line(elapsed); + CliReply::text("(no reply)") + } Ok(turn) => { if turn.text.trim().is_empty() { + emit_baked_line(elapsed); return CliReply::text("(no reply)"); } if persist_session { @@ -790,6 +1006,7 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) } spawn_compaction_if_needed(state).await; } + emit_turn_footer(elapsed, &turn); let mut body = turn.text; if !expanded.errors.is_empty() { body.push_str("\n\n_Note: "); @@ -798,7 +1015,10 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) } CliReply::text(body) } - Err(e) => CliReply::error(format!("agent error: {e}")), + Err(e) => { + emit_baked_line(elapsed); + CliReply::error(format!("agent error: {e}")) + } } } @@ -965,7 +1185,8 @@ fn inline_tool_block(message: &str) -> Option { } else { msg.to_string() }; - const MAX: usize = 100; + // Visible prefix is " ⎿ · " = 7 chars; cap so the full line ≤ 78 cols. + const MAX: usize = 70; let clipped: String = rendered.chars().take(MAX).collect(); let suffix = if rendered.chars().count() > MAX { "…" @@ -985,25 +1206,18 @@ fn inline_tool_block(message: &str) -> Option { /// Returns `None` for log kinds that would just echo ourselves. fn summarize_log_for_status(ev: &LogEntry) -> Option { match ev.kind.as_str() { - // Self-echo + the final reply — the user is already about to see it. + // Self-echo + final reply — user is already about to see it. "cli" | "reply" | "msg" | "auth" | "ok" => None, + // Internal debug / routing info — not useful as spinner status. + "tool_ctx" | "run" | "memory" | "mcp" => None, "tool" => humanize_tool_status_line(&ev.message), - _ => { - const MAX: usize = 60; - let msg = ev.message.trim(); - let msg: String = msg.chars().take(MAX).collect(); - let ellipsed = if msg.chars().count() == MAX { - format!("{msg}…") - } else { - msg - }; - Some(format!("{}: {}", ev.kind, ellipsed)) - } + // Suppress unknown kinds to avoid raw debug strings in the spinner. + _ => None, } } -/// ` ⎿ Baked for 4.8s` on stderr once the spinner has been cleared. -/// Only emitted when stderr is a TTY, matching the spinner gate. +/// ` ⎿ Baked for 4.8s — quip` on stderr — used when no token data is available +/// (errors, suppressed replies, compaction). fn emit_baked_line(elapsed: std::time::Duration) { if !std::io::stderr().is_terminal() { return; @@ -1017,6 +1231,73 @@ fn emit_baked_line(elapsed: std::time::Duration) { let _ = err.flush(); } +/// Full turn footer with elapsed time, token counts, model, and think flag. +/// Shown on stderr after every successful agent turn so the user has +/// per-turn data for optimisation decisions. +/// +/// Example: +/// ```text +/// ⎿ Baked for 4.8s — chef's kiss · in:1,234 out:567 · qwen3:1.5b · think:on +/// ``` +fn emit_turn_footer(elapsed: std::time::Duration, turn: &agent::TurnResult) { + if !std::io::stderr().is_terminal() { + return; + } + let baked = flavor::baked_message(elapsed, fmt_elapsed); + + // Always show token counts. Use "?" when Ollama didn't return the field + // (e.g. model doesn't report counts, or prompt was fully KV-cached giving in:0). + let in_s = if turn.prompt_tokens > 0 { + fmt_num(turn.prompt_tokens) + } else { + "?".to_string() + }; + let out_s = if turn.eval_tokens > 0 { + fmt_num(turn.eval_tokens) + } else { + "?".to_string() + }; + let tokens = format!(" · in:{in_s} out:{out_s}"); + + let model = if !turn.model.is_empty() { + // Trim off `:latest` suffix — it adds noise without signal. + let m = turn.model.trim_end_matches(":latest"); + format!(" · {m}") + } else { + String::new() + }; + + let think = if turn.think_enabled { + " · think:on" + } else { + "" + }; + + let steps = if turn.steps > 1 { + format!(" · {}steps", turn.steps) + } else { + String::new() + }; + + let line = format!(" \x1b[2m⎿\x1b[0m \x1b[2m{baked}{tokens}{model}{think}{steps}\x1b[0m\n"); + let mut err = std::io::stderr().lock(); + let _ = err.write_all(line.as_bytes()); + let _ = err.flush(); +} + +/// Format a token count with thousands separators: 1234 → "1,234". +fn fmt_num(n: u64) -> String { + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, ch) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out.chars().rev().collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/modules/cli/output.rs b/src-tauri/src/modules/cli/output.rs index 58fe8d5..a190c96 100644 --- a/src-tauri/src/modules/cli/output.rs +++ b/src-tauri/src/modules/cli/output.rs @@ -7,7 +7,10 @@ use regex::Regex; use serde::Serialize; use std::io::{IsTerminal, Write}; -use std::sync::{Arc, OnceLock}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, OnceLock, +}; use std::time::{Duration, Instant}; use tokio::sync::Mutex as AsyncMutex; use tokio::task::JoinHandle; @@ -473,6 +476,7 @@ impl Progress { pub fn start(label: impl Into) -> ProgressHandle { let start = Instant::now(); let animate = std::io::stderr().is_terminal(); + let token_counter = Arc::new(AtomicU64::new(0)); let state = Arc::new(AsyncMutex::new(ProgressState { label: label.into(), last_status: None, @@ -482,11 +486,17 @@ impl Progress { })); let task = if animate { let state = state.clone(); - Some(tokio::spawn(spinner_loop(state, start))) + let tc = token_counter.clone(); + Some(tokio::spawn(spinner_loop(state, start, tc))) } else { None }; - ProgressHandle { task, start, state } + ProgressHandle { + task, + start, + state, + token_counter, + } } } @@ -494,6 +504,7 @@ pub struct ProgressHandle { task: Option>, start: Instant, state: Arc>, + token_counter: Arc, } pub struct ProgressStatus { @@ -519,6 +530,12 @@ impl ProgressHandle { } } + /// Returns a counter the caller can pass to the agent for live output-token display. + /// The spinner reads it atomically each tick and shows `out:N↑` in the status line. + pub fn token_counter(&self) -> Arc { + self.token_counter.clone() + } + pub async fn finish(self) -> Duration { { let mut s = self.state.lock().await; @@ -548,9 +565,116 @@ impl ProgressStatus { } } -async fn spinner_loop(state: Arc>, start: Instant) { +/// Visible-character width of the terminal's stderr, capped for safety. +/// Uses TIOCGWINSZ, then falls back to the COLUMNS env var, then 80. +fn term_cols() -> usize { + #[cfg(unix)] + { + #[repr(C)] + struct Winsize { + ws_row: u16, + ws_col: u16, + ws_xpixel: u16, + ws_ypixel: u16, + } + // SAFETY: ioctl with TIOCGWINSZ only reads from the kernel into our + // local Winsize struct; no aliasing or other unsafe preconditions. + unsafe { + extern "C" { + fn ioctl(fd: i32, request: u64, ...) -> i32; + } + #[cfg(target_os = "macos")] + const TIOCGWINSZ: u64 = 0x40087468; + #[cfg(not(target_os = "macos"))] + const TIOCGWINSZ: u64 = 0x5413; + let mut ws = Winsize { + ws_row: 0, + ws_col: 0, + ws_xpixel: 0, + ws_ypixel: 0, + }; + if ioctl(2, TIOCGWINSZ, &mut ws as *mut Winsize) == 0 && ws.ws_col > 20 { + return ws.ws_col as usize; + } + } + } + std::env::var("COLUMNS") + .ok() + .and_then(|s| s.parse().ok()) + .filter(|&n: &usize| n > 20) + .unwrap_or(80) +} + +/// Thousands-separator formatter for the live token counter (no dep on handlers). +fn spinner_fmt_num(n: u64) -> String { + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, ch) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out.chars().rev().collect() +} + +/// Build the spinner line, capped to `cols` visible characters so it never +/// wraps. Wrapping is the root cause of the spinner not overwriting in place: +/// `\r\x1b[2K` clears only the current terminal line, so any wrapped +/// continuation from the previous tick stays and accumulates. +/// +/// `live_out` is the approximate output-token count so far (0 = hide). +fn build_spinner_line( + frame: &str, + label: &str, + status: Option<&str>, + elapsed: &str, + live_out: u64, + cols: usize, +) -> String { + // Leave a 1-char margin so the cursor never lands on column 0 of the next + // line due to an off-by-one in the terminal's auto-wrap logic. + let budget = cols.saturating_sub(1); + + let tok = if live_out > 0 { + format!(" · out:{}↑", spinner_fmt_num(live_out)) + } else { + String::new() + }; + + let base_with_status = match status { + Some(s) if !s.is_empty() => format!("{frame} {label} · {s} · {elapsed}"), + _ => format!("{frame} {label} · {elapsed}"), + }; + let base_plain = format!("{frame} {label} · {elapsed}"); + + // Prefer longest line that fits; drop token suffix first, then status. + let visible: String = { + let full = format!("{base_with_status}{tok}"); + if full.chars().count() <= budget { + full + } else if base_with_status.chars().count() <= budget { + base_with_status + } else if base_plain.chars().count() <= budget { + base_plain + } else { + let max = budget.saturating_sub(1); + format!("{}…", base_plain.chars().take(max).collect::()) + } + }; + + format!("\r\x1b[2K\x1b[2m{visible}\x1b[0m") +} + +async fn spinner_loop( + state: Arc>, + start: Instant, + token_counter: Arc, +) { const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let mut i: usize = 0; + // Read terminal width once; it rarely changes mid-turn. + let cols = term_cols(); loop { // Check done + drain interjects + build line, all under the lock. let (line, interjects) = { @@ -560,16 +684,15 @@ async fn spinner_loop(state: Arc>, start: Instant) { } let interjects = std::mem::take(&mut st.interjects); let elapsed = fmt_elapsed(start.elapsed()); - let line = match st.last_status.as_deref() { - Some(status) if !status.is_empty() => format!( - "\r\x1b[2K\x1b[2m{} {} · {} · {}\x1b[0m", - FRAMES[i], st.label, status, elapsed - ), - _ => format!( - "\r\x1b[2K\x1b[2m{} {} · {}\x1b[0m", - FRAMES[i], st.label, elapsed - ), - }; + let live_out = token_counter.load(Ordering::Relaxed); + let line = build_spinner_line( + FRAMES[i], + &st.label, + st.last_status.as_deref(), + &elapsed, + live_out, + cols, + ); (line, interjects) }; // `StderrLock` is `!Send`; scope all writes so nothing crosses `.await`. diff --git a/src-tauri/src/modules/cli/repl.rs b/src-tauri/src/modules/cli/repl.rs index afe6e2d..e8de366 100644 --- a/src-tauri/src/modules/cli/repl.rs +++ b/src-tauri/src/modules/cli/repl.rs @@ -5,6 +5,7 @@ //! special to this file lives outside line editing and history management. use super::banner::CLI_WELCOME; +use super::commands; use super::dispatch::{dispatch_line, format_repl_line_for_audit, DispatchContext}; use super::flavor; use super::folder_trust::{self, PromptOutcome}; @@ -12,9 +13,14 @@ use super::output::{render_reply, CliReply, OutputSink, RenderStyle, TerminalSin use super::session::{self, CliSession}; use crate::modules::mcp::service as mcp_service; use crate::shared::state::AppState; +use rustyline::completion::{Completer, Pair}; use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::{Hint, Hinter}; use rustyline::history::FileHistory; -use rustyline::{Config, Editor}; +use rustyline::validate::Validator; +use rustyline::{CompletionType, Config, Context, Editor, Helper}; +use std::borrow::Cow; use std::io::IsTerminal; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -23,6 +29,137 @@ use std::time::{Duration, Instant}; /// duration breaks the REPL loop instead of just clearing the line. const DOUBLE_INTERRUPT_WINDOW: Duration = Duration::from_secs(2); +// ── Slash-command completion / hint ───────────────────────────────────────── + +/// Ghost-text hint returned by [`SlashHelper`]. +struct SlashHint(String); + +impl Hint for SlashHint { + fn display(&self) -> &str { + &self.0 + } + fn completion(&self) -> Option<&str> { + None + } +} + +/// rustyline [`Helper`] that provides: +/// - Tab-completion of `/command` names with their summaries +/// - Ghost-text hint that updates as the user types (filters live) +/// - Cyan highlight of the `/command` portion +struct SlashHelper; + +impl Completer for SlashHelper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let Some(slash) = line[..pos].find('/') else { + return Ok((0, vec![])); + }; + let filter = line[slash + 1..pos].to_lowercase(); + + let max_name = commands::COMMANDS + .iter() + .map(|c| c.name.len()) + .max() + .unwrap_or(8); + + let candidates = commands::COMMANDS + .iter() + .filter(|c| { + c.name != "quit" + && (filter.is_empty() + || c.name.contains(filter.as_str()) + || c.summary.to_lowercase().contains(filter.as_str())) + }) + .map(|c| Pair { + display: format!("/{:) -> Option { + // Only show when the cursor is at the end of the line. + if pos < line.len() { + return None; + } + let slash = line.find('/')?; + // Ignore `/` that appears in the middle of a sentence. + if slash > 0 && !line[..slash].trim().is_empty() { + return None; + } + let filter = line[slash + 1..].to_lowercase(); + + let matches: Vec<_> = commands::COMMANDS + .iter() + .filter(|c| { + c.name != "quit" + && (filter.is_empty() + || c.name.starts_with(filter.as_str()) + || c.summary.to_lowercase().contains(filter.as_str())) + }) + .collect(); + + if matches.is_empty() { + return Some(SlashHint("\n\x1b[2m (no matching command)\x1b[0m".into())); + } + + let max_name = matches.iter().map(|c| c.name.len()).max().unwrap_or(8); + let mut out = String::new(); + + for cmd in &matches { + // Clamp summary to avoid wrapping on narrow terminals. + let summary: &str = if cmd.summary.len() > 58 { + &cmd.summary[..58] + } else { + cmd.summary + }; + out.push_str(&format!( + "\n \x1b[1;36m/{:(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { + if line.trim_start().starts_with('/') { + Cow::Owned(format!("\x1b[1;36m{line}\x1b[0m")) + } else { + Cow::Borrowed(line) + } + } + + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + Cow::Borrowed(hint) + } + + fn highlight_char(&self, line: &str, _pos: usize, _forced: bool) -> bool { + // Re-render on every keystroke while in slash-command mode so the hint + // updates live as the user types filter characters. + line.trim_start().starts_with('/') + } +} + +impl Validator for SlashHelper {} +impl Helper for SlashHelper {} + /// Continuation prompt shown for additional lines while a backslash-escaped /// multi-line edit is in progress. const PROMPT_CONT_TTY: &str = "\x1b[2;36m·\x1b[0m "; @@ -291,9 +428,14 @@ fn clear_screen(tty: bool) { let _ = out.flush(); } -fn build_editor() -> Result, String> { - let cfg = Config::builder().auto_add_history(false).build(); - Editor::with_config(cfg).map_err(|e| e.to_string()) +fn build_editor() -> Result, String> { + let cfg = Config::builder() + .auto_add_history(false) + .completion_type(CompletionType::List) + .build(); + let mut rl = Editor::with_config(cfg).map_err(|e| e.to_string())?; + rl.set_helper(Some(SlashHelper)); + Ok(rl) } fn history_path(store_path: &std::path::Path) -> PathBuf { diff --git a/src-tauri/src/modules/cli/session.rs b/src-tauri/src/modules/cli/session.rs index 82763bb..7144ed3 100644 --- a/src-tauri/src/modules/cli/session.rs +++ b/src-tauri/src/modules/cli/session.rs @@ -14,6 +14,8 @@ use std::path::{Path, PathBuf}; const SESSIONS_DIRNAME: &str = "cli_sessions"; const LAST_POINTER: &str = "cli_session_last.json"; const BY_PATH_POINTER: &str = "cli_session_by_path.json"; +const MANIFEST_FILE: &str = "manifest.json"; +const SUMMARY_SNIPPET_LEN: usize = 120; /// Cap applied when building the context prefix for a new turn. /// Keeps the prompt size predictable across long sessions. @@ -59,9 +61,37 @@ impl ProjectContext { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestEntry { + pub id: String, + #[serde(default)] + pub name: Option, + pub started_at: DateTime, + #[serde(default)] + pub last_turn_at: Option>, + pub turn_count: usize, + #[serde(default)] + pub prompt_tokens_total: u64, + #[serde(default)] + pub eval_tokens_total: u64, + #[serde(default)] + pub summary_snippet: Option, + pub cwd: PathBuf, + #[serde(default)] + pub git_branch: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SessionManifest { + pub entries: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CliSession { pub id: String, + /// Optional user-given name (set via `/session new ` or `/session rename`). + #[serde(default)] + pub name: Option, pub started_at: DateTime, pub turns: Vec, /// Set by `/compact`. When present, replaces the older turns when @@ -80,6 +110,7 @@ impl CliSession { let now = Utc::now(); Self { id: now.format("%Y%m%dT%H%M%S").to_string(), + name: None, started_at: now, turns: Vec::new(), summary: None, @@ -190,6 +221,101 @@ pub fn apply_compaction(session: &mut CliSession, summary: String, keep: usize) session.summary = Some(summary); } +fn manifest_path(store_path: &Path) -> PathBuf { + sessions_dir(store_path).join(MANIFEST_FILE) +} + +pub fn load_manifest(store_path: &Path) -> SessionManifest { + let path = manifest_path(store_path); + let Ok(body) = fs::read_to_string(&path) else { + return SessionManifest::default(); + }; + serde_json::from_str(&body).unwrap_or_default() +} + +fn save_manifest(store_path: &Path, manifest: &SessionManifest) -> Result<(), String> { + let dir = sessions_dir(store_path); + fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + let body = + serde_json::to_string_pretty(manifest).map_err(|e| format!("encode manifest: {e}"))?; + fs::write(manifest_path(store_path), body).map_err(|e| format!("write manifest: {e}")) +} + +fn make_manifest_entry(session: &CliSession) -> ManifestEntry { + let last_turn_at = session.turns.last().map(|t| t.at); + let summary_snippet = session.summary.as_deref().map(|s| { + let t = s.trim(); + let chars: String = t.chars().take(SUMMARY_SNIPPET_LEN).collect(); + if t.chars().count() > SUMMARY_SNIPPET_LEN { + format!("{chars}…") + } else { + chars + } + }); + let (cwd, git_branch) = session + .project + .as_ref() + .map(|p| (p.cwd.clone(), p.git_branch.clone())) + .unwrap_or_else(|| (PathBuf::from("."), None)); + ManifestEntry { + id: session.id.clone(), + name: session.name.clone(), + started_at: session.started_at, + last_turn_at, + turn_count: session.turns.len(), + prompt_tokens_total: session.prompt_tokens_total, + eval_tokens_total: session.eval_tokens_total, + summary_snippet, + cwd, + git_branch, + } +} + +fn upsert_manifest_entry(store_path: &Path, session: &CliSession) { + let mut manifest = load_manifest(store_path); + let entry = make_manifest_entry(session); + if let Some(pos) = manifest.entries.iter().position(|e| e.id == session.id) { + manifest.entries[pos] = entry; + } else { + manifest.entries.push(entry); + } + if let Err(e) = save_manifest(store_path, &manifest) { + log::warn!("session: manifest update failed: {e}"); + } +} + +/// Look up a session by name (case-insensitive, most recent first) or by full/prefix id. +pub fn load_by_name_or_id(store_path: &Path, query: &str) -> Result, String> { + let manifest = load_manifest(store_path); + let q_lower = query.to_ascii_lowercase(); + // Exact name match (most recent entry wins on duplicates). + if let Some(e) = manifest.entries.iter().rev().find(|e| { + e.name + .as_deref() + .map(|n| n.eq_ignore_ascii_case(query)) + .unwrap_or(false) + }) { + let id = e.id.clone(); + return load_by_id(store_path, &id); + } + // Exact id match. + if let Some(e) = manifest.entries.iter().rev().find(|e| e.id == query) { + let id = e.id.clone(); + return load_by_id(store_path, &id); + } + // Id prefix match (useful for typing just the date portion). + if let Some(e) = manifest + .entries + .iter() + .rev() + .find(|e| e.id.to_ascii_lowercase().starts_with(&q_lower)) + { + let id = e.id.clone(); + return load_by_id(store_path, &id); + } + Ok(None) +} + fn sessions_dir(store_path: &Path) -> PathBuf { store_path .parent() @@ -252,6 +378,7 @@ pub fn save(store_path: &Path, session: &CliSession) -> Result<(), String> { let path = dir.join(format!("{}.json", session.id)); let body = serde_json::to_string_pretty(session).map_err(|e| format!("encode: {e}"))?; fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?; + upsert_manifest_entry(store_path, session); let pointer = LastPointer { last_session_id: session.id.clone(), }; @@ -273,6 +400,22 @@ pub fn save(store_path: &Path, session: &CliSession) -> Result<(), String> { Ok(()) } +/// Remove a session file and its manifest entry. Does not touch the last/by-path pointers +/// (those will simply point to a missing file, which callers handle as `Ok(None)`). +pub fn delete(store_path: &Path, id: &str) -> Result<(), String> { + let dir = sessions_dir(store_path); + let path = dir.join(format!("{id}.json")); + if path.exists() { + fs::remove_file(&path).map_err(|e| format!("remove {}: {e}", path.display()))?; + } + let mut manifest = load_manifest(store_path); + manifest.entries.retain(|e| e.id != id); + if let Err(e) = save_manifest(store_path, &manifest) { + log::warn!("session: manifest update after delete failed: {e}"); + } + Ok(()) +} + pub fn load_last(store_path: &Path) -> Result, String> { let pointer_path = last_pointer(store_path); let body = match fs::read_to_string(&pointer_path) { @@ -296,7 +439,7 @@ pub fn load_last_for_path(store_path: &Path, key: &Path) -> Result Result, String> { +pub fn load_by_id(store_path: &Path, id: &str) -> Result, String> { let dir = sessions_dir(store_path); let path = dir.join(format!("{id}.json")); let body = match fs::read_to_string(&path) { diff --git a/src-tauri/src/modules/ollama/service.rs b/src-tauri/src/modules/ollama/service.rs index 6876afb..e3be8f6 100644 --- a/src-tauri/src/modules/ollama/service.rs +++ b/src-tauri/src/modules/ollama/service.rs @@ -1,7 +1,10 @@ use crate::modules::ollama::cloud; use crate::modules::ollama::constants::{OLLAMA_CHAT_URL, OLLAMA_PS_URL, OLLAMA_TAGS_URL}; use crate::shared::text::normalize_assistant_message_content; -use std::sync::OnceLock; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, OnceLock, +}; static HTTP: OnceLock = OnceLock::new(); @@ -299,6 +302,10 @@ pub struct ChatOptions { /// for plain chat requests (no tools); grammar masks invalid tokens so the /// model emits JSON matching the schema. pub format: Option, + /// When set, the Ollama request uses streaming mode and each generated chunk + /// increments this counter by 1 (≈ 1 token/chunk). The spinner reads it + /// atomically every tick to show a live `out:N↑` display. + pub live_out_tokens: Option>, } impl Default for ChatOptions { @@ -310,6 +317,7 @@ impl Default for ChatOptions { temperature: None, keep_alive: "30m", format: None, + live_out_tokens: None, } } } @@ -342,6 +350,9 @@ pub async fn chat_with_tools( let has_tools = tools.as_array().is_some_and(|a| !a.is_empty()); let mut payload = build_payload(model, messages, options); + if options.live_out_tokens.is_some() { + payload["stream"] = serde_json::Value::Bool(true); + } if has_tools { payload["tools"] = tools.clone(); // `format` constrains the whole completion; tool turns need native `tool_calls` shape. @@ -350,11 +361,16 @@ pub async fn chat_with_tools( } } - let (status, body) = post_chat(&payload).await?; + let (status, body) = if let Some(counter) = &options.live_out_tokens { + post_chat_streaming(&payload, counter).await? + } else { + post_chat(&payload).await? + }; if !status.is_success() { let err_text = body["error"].as_str().unwrap_or(""); if has_tools && err_text.contains("does not support tools") { + // Retry without tools using non-streaming (edge-case fallback). let plain = build_payload(model, messages, options); let (st, b) = post_chat(&plain).await?; if !st.is_success() { @@ -450,6 +466,124 @@ If the daemon is not running, start `ollama serve`. Otherwise retry — long too } } +/// Streaming variant of [`post_chat`]. Reads NDJSON chunks from Ollama's +/// `"stream": true` response, accumulates content and tool_calls, and +/// increments `token_counter` once per content/thinking chunk (≈ 1 per token). +/// Returns a body shaped identically to the non-streaming response so the rest +/// of the call stack can stay unchanged. +async fn post_chat_streaming( + payload: &serde_json::Value, + token_counter: &Arc, +) -> Result<(reqwest::StatusCode, serde_json::Value), String> { + let mut resp = http_client() + .post(OLLAMA_CHAT_URL) + .json(payload) + .timeout(std::time::Duration::from_secs( + OLLAMA_CHAT_REQUEST_TIMEOUT_SECS, + )) + .send() + .await + .map_err(|e| explain_ollama_chat_transport_error(&e.to_string()))?; + + let status = resp.status(); + + // For non-success responses there is no NDJSON stream; read body normally. + if !status.is_success() { + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("ollama response JSON decode ({OLLAMA_CHAT_URL}): {e}"))?; + return Ok((status, body)); + } + + let mut content_buf = String::new(); + let mut tool_calls_opt: Option = None; + let mut done_chunk = serde_json::Value::Null; + // Raw bytes buffer for reassembling partial HTTP chunks into complete lines. + let mut byte_buf: Vec = Vec::with_capacity(4096); + + 'read: loop { + let Some(raw) = resp + .chunk() + .await + .map_err(|e| format!("ollama stream read ({OLLAMA_CHAT_URL}): {e}"))? + else { + break; + }; + byte_buf.extend_from_slice(&raw); + + // Drain every complete \n-terminated JSON line from the buffer. + while let Some(nl) = byte_buf.iter().position(|&b| b == b'\n') { + let line_bytes: Vec = byte_buf.drain(..=nl).collect(); + let line = match std::str::from_utf8(&line_bytes) { + Ok(s) => s.trim().to_string(), + Err(_) => continue, + }; + if line.is_empty() { + continue; + } + let Ok(ev) = serde_json::from_str::(&line) else { + continue; + }; + + let msg = ev.get("message"); + + // Increment live counter for each content or thinking chunk (≈ 1 token). + let has_content = msg + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .is_some_and(|s| !s.is_empty()); + let has_thinking = msg + .and_then(|m| m.get("thinking")) + .and_then(|t| t.as_str()) + .is_some_and(|s| !s.is_empty()); + if has_content || has_thinking { + token_counter.fetch_add(1, Ordering::Relaxed); + } + + // Accumulate content (thinking is intentionally excluded — extract_message strips it). + if let Some(c) = msg + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .filter(|s| !s.is_empty()) + { + content_buf.push_str(c); + } + + // Ollama emits tool_calls as a complete object in a single chunk. + if let Some(tc) = msg.and_then(|m| m.get("tool_calls")) { + if tc.as_array().is_some_and(|a| !a.is_empty()) { + tool_calls_opt = Some(tc.clone()); + } + } + + if ev.get("done").and_then(|v| v.as_bool()).unwrap_or(false) { + done_chunk = ev; + break 'read; + } + } + } + + // Reconstruct a message object matching the non-streaming shape. + let mut message = serde_json::json!({ + "role": "assistant", + "content": content_buf, + }); + if let Some(tc) = tool_calls_opt { + message["tool_calls"] = tc; + } + + // Carry forward the done-chunk's stat fields (prompt_eval_count, eval_count, …) + // and replace `message` with our reassembled version. + let mut body = match done_chunk.as_object() { + Some(obj) => serde_json::Value::Object(obj.clone()), + None => serde_json::json!({}), + }; + body["message"] = message; + + Ok((status, body)) +} + async fn post_chat( payload: &serde_json::Value, ) -> Result<(reqwest::StatusCode, serde_json::Value), String> { diff --git a/src-tauri/src/modules/skills/service.rs b/src-tauri/src/modules/skills/service.rs index e304855..305b2e9 100644 --- a/src-tauri/src/modules/skills/service.rs +++ b/src-tauri/src/modules/skills/service.rs @@ -220,10 +220,10 @@ const SKILL_MANDATORY_HINT_CAP: usize = 1200; const SKILL_HINT_INTRO: &str = "\n\nSkills: follow each recipe exactly — \ it lists WHICH URL and HOW MANY calls. Stop when you can answer; \ -don't probe alternate hosts. Unless a skill’s **`mandatory.md`** says otherwise, prefer **`fetch`** whenever you have a concrete URL; use **`brave_web_search`** when the recipe lists it in `requires` (and this turn matches that skill), when **`mandatory.md`** orders it, or when the user explicitly asked to search the open web.\n\ -**Weather, forecasts, temperature, precipitation:** use **skill:weather** (wttr.in / Open-Meteo) as the only recipe — never government-portal or “.gv.at” skills for those topics.\n\ -Portal- or government-specific skills you install yourself apply **only** when the user is clearly asking about that jurisdiction’s government, law, official forms, or public administration — \ -not for recipes, hobbies, general knowledge, software, weather, or unrelated chit-chat. If the topic does not match the skill’s scope, ignore that recipe entirely."; +don't probe alternate hosts. Unless a skill's **`mandatory.md`** says otherwise, prefer **`fetch`** whenever you have a concrete URL; use **`brave_web_search`** when the recipe lists it in `requires` (and this turn matches that skill), when **`mandatory.md`** orders it, or when the user explicitly asked to search the open web.\n\ +**Skill scope discipline:** only invoke a skill when the current user message is clearly about that skill's topic. \ +A skill that fetches weather data must not be used for programming questions; a skill for government portals must not be used for recipes or general knowledge. \ +If the topic does not match the skill's scope, ignore that skill entirely for this turn."; /// True when the user (or cron) message is clearly about weather / forecast. pub fn user_message_suggests_weather(user_message: &str) -> bool { @@ -254,41 +254,12 @@ pub fn user_message_suggests_weather(user_message: &str) -> bool { .any(|n| user_message_needle_match(user_message, n)) } -/// Default “only when talking about AT public administration” needles for known portal skill slugs. -pub fn default_hint_needles_for_slug(slug: &str) -> Option<&'static [&'static str]> { - let s = slug.to_lowercase(); - let portal = s == "austria-gv-data" - || s == "austrian-gv" - || s == "austrian-gv-data" - || s.contains("austria-gv") - || s.contains("austrian-gv") - || s.contains("oesterreich-gv") - || (s.contains("oesterreich") && s.contains("gv")) - || (s.contains("austria") && s.contains("gv") && s.contains("data")); - if !portal { - return None; - } - Some(&[ - "oesterreich.gv", - ".gv.at", - "oesterreich", - "bundesrecht", - "verwaltung", - "behörde", - "behoerde", - "formular", - "bürgerservice", - "buergerservice", - "e-government", - "egov", - "ministerium", - "amt", - "landesregierung", - "gemeinde", - "bescheid", - "verordnung", - "österreich", - ]) +/// Returns skill-level hint-gate needles from the skill's own `hint_allow_substrings` field. +/// Skills that have no `hint_allow_substrings` set pass the gate unconditionally (always included). +/// This function exists for callers that want the needles without a full `Skill` struct; it +/// always returns `None` here — all gating is driven by `Skill::hint_allow_substrings`. +pub fn default_hint_needles_for_slug(_slug: &str) -> Option<&'static [&'static str]> { + None } /// Whether `skill` may appear in the skills system-prompt fragment for this turn. @@ -507,7 +478,7 @@ fn read_dir_skills(dir: &Path, origin: SkillOrigin) -> Vec { skills } -/// Parse a skill’s `SKILL.md`. Frontmatter is the `---`-delimited YAML-ish block at the top. +/// Parse a skill's `SKILL.md`. Frontmatter is the `---`-delimited YAML-ish block at the top. /// The parser is deliberately tiny — scalars, quoted strings, and inline `[a, b]` arrays only. pub fn parse_skill(slug: &str, raw: &str, origin: SkillOrigin) -> Result { let (fm, body) = split_frontmatter(raw).ok_or("missing frontmatter block")?; @@ -644,7 +615,7 @@ fn skill_triggers_brave_web_search(skill: &Skill, user_message: &str) -> bool { } /// Expose the billed `brave_web_search` tool when catalogued **search keywords** match -/// ([`super::keywords::brave_search_allowed_by_keywords`]) or when an enabled skill’s +/// ([`super::keywords::brave_search_allowed_by_keywords`]) or when an enabled skill's /// `requires` / `brave_allow_substrings` / tags gate this turn. pub fn allow_brave_web_search_for_message(store_path: &Path, user_message: &str) -> bool { if super::keywords::brave_search_allowed_by_keywords(user_message) { @@ -973,7 +944,7 @@ pub struct ClawHubSearchOptions { pub sort: Option, pub limit: Option, pub tag: Option, - /// When true, fetch each skill’s public `/openclaw/{slug}` HTML for author + stats (slower). + /// When true, fetch each skill's public `/openclaw/{slug}` HTML for author + stats (slower). pub enrich: bool, } @@ -1361,13 +1332,16 @@ mod tests { } #[test] - fn portal_skill_hint_gated_without_admin_keywords() { + fn portal_skill_hint_gated_by_hint_allow_substrings() { + // Skills declare their own gate via `hint_allow_substrings` — no hardcoded slug logic. let tmp = tempdir().unwrap(); let fake_store = tmp.path().join("connection.json"); - let gv = "---\nname: G\ndescription: d\ntags: []\n---\n\nGVONLY\n"; + let gv = "---\nname: G\ndescription: d\ntags: []\nhint_allow_substrings: [oesterreich, formular, .gv.at, verwaltung]\n---\n\nGVONLY\n"; write_custom_skill(&fake_store, "austria-gv-data", gv, None).unwrap(); + // Non-matching message: skill must be excluded. let hint = skills_prompt_hint_for_turn(&fake_store, Some("wie ist das wetter"), None); assert!(!hint.contains("GVONLY"), "hint={hint}"); + // Matching message: skill must be included. let hint2 = skills_prompt_hint_for_turn(&fake_store, Some("Formular auf oesterreich.gv.at"), None); assert!(hint2.contains("GVONLY"), "hint={hint2}"); diff --git a/tools/skills/weather/SKILL.md b/tools/skills/weather/SKILL.md index 356681e..c5f0de4 100644 --- a/tools/skills/weather/SKILL.md +++ b/tools/skills/weather/SKILL.md @@ -2,8 +2,9 @@ name: weather description: Weather and forecasts (no API key). One wttr.in fetch by default; Open-Meteo if that fails. Chat-style answers — see “How to answer”. homepage: https://wttr.in/:help -metadata: { "clawdbot": { "emoji": "🌤️", "requires": { "bins": ["curl"] } } } +metadata: { “clawdbot”: { “emoji”: “🌤️”, “requires”: { “bins”: [“curl”] } } } tags: [weather, forecast, wttr, open-meteo] +hint_allow_substrings: [wetter, weather, forecast, vorhersage, temperatur, gewitter, schnee, hagel, wind, niederschlag, wttr, regen, luftdruck, hitze, eisregen] --- # Weather From 7507d4d4012c4ba04d7222057fecaa9f01cf0055 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Wed, 13 May 2026 14:21:50 +0200 Subject: [PATCH 19/23] refactor: remove deprecated `/new` command and update session help text - Eliminated the `/new` command as a shortcut for starting a fresh session, streamlining session management. - Updated session help text to clarify the usage of `/session new` for creating named sessions. - Enhanced the session list output format to include token count, improving user visibility on session details. --- src-tauri/src/modules/cli/commands.rs | 10 +--------- src-tauri/src/modules/cli/dispatch.rs | 6 ------ src-tauri/src/modules/cli/handlers.rs | 23 ++++++++++------------- 3 files changed, 11 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/modules/cli/commands.rs b/src-tauri/src/modules/cli/commands.rs index 9188b7b..5d95566 100644 --- a/src-tauri/src/modules/cli/commands.rs +++ b/src-tauri/src/modules/cli/commands.rs @@ -103,15 +103,7 @@ pub const COMMANDS: &[NativeCommand] = &[ /session help # show all session subcommands\n\n\ Sessions persist across restarts. Each session keeps a turn history and\n\ a compacted summary for context. /session switch saves the current session\n\ - before switching. The previous /new command is a shortcut for /session new.", - }, - NativeCommand { - name: "new", - summary: "Start a fresh session (shortcut for /session new) (REPL-only).", - details: "Usage: /new\n\n\ - Creates an empty session for the current project. The previous session\n\ - is still saved on disk and can be resumed with /session switch.\n\ - Shortcut for /session new.", + before switching. Run /session help for the full subcommand list.", }, NativeCommand { name: "clear", diff --git a/src-tauri/src/modules/cli/dispatch.rs b/src-tauri/src/modules/cli/dispatch.rs index 0185728..40f5f3a 100644 --- a/src-tauri/src/modules/cli/dispatch.rs +++ b/src-tauri/src/modules/cli/dispatch.rs @@ -147,12 +147,6 @@ async fn dispatch_native( let (action, tail) = split_first(rest); handlers::session_cmd(state, action, tail).await } - "new" => { - if ctx.telegram_surface { - return CliReply::error("new: not supported over Telegram."); - } - handlers::new_session(state).await - } "app" => { if ctx.telegram_surface { return CliReply::error("app: starting the GUI is not supported over Telegram."); diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index 284fa28..2d94f2b 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -594,16 +594,15 @@ fn session_help() -> CliReply { "bash", "\ /session list list all saved sessions (newest first) -/session new [name] start a fresh session; save and compact current first -/session switch resume a saved session; save current first -/session rename give the active session a name -/session delete delete a saved session from disk (cannot delete the active one) +/session new create a named session and switch to it immediately +/session switch resume a saved session; saves current first +/session rename rename the active session +/session delete delete a saved session from disk (not the active one) /session help show this help Notes: - Sessions persist on disk across restarts. - - Auto-compaction runs in the background when a session exceeds 12 turns. - - /new is a shortcut for /session new (no name)." + - Auto-compaction runs in the background when a session exceeds 12 turns." .trim_end(), ) } @@ -620,13 +619,14 @@ async fn session_list(state: &AppState) -> CliReply { .as_ref() .map(|s| s.id.clone()); let mut out = format!( - "{:<3} {:<15} {:<22} {:<5} {}\n{}\n", + "{:<3} {:<15} {:<22} {:<5} {:<10} {}\n{}\n", " ", "id", "name", "turns", + "in", "branch", - "─".repeat(72), + "─".repeat(84), ); for e in manifest.entries.iter().rev() { let marker = if active_id.as_deref() == Some(e.id.as_str()) { @@ -636,8 +636,9 @@ async fn session_list(state: &AppState) -> CliReply { }; let name = e.name.as_deref().unwrap_or("—"); let branch = e.git_branch.as_deref().unwrap_or("—"); + let tokens_in = fmt_num(e.prompt_tokens_total); out.push_str(&format!( - "{marker:<3} {:<15} {name:<22} {:<5} {branch}\n", + "{marker:<3} {:<15} {name:<22} {:<5} {tokens_in:<10} {branch}\n", e.id, e.turn_count, )); if let Some(snippet) = &e.summary_snippet { @@ -786,10 +787,6 @@ async fn session_rename(state: &AppState, name: &str) -> CliReply { CliReply::text(format!("session renamed to: {name}")) } -/// `/new` — shortcut for `/session new` (no name). -pub async fn new_session(state: &AppState) -> CliReply { - session_new(state, "").await -} /// `/compact` — call the AI to summarize old turns, store as `session.summary`, /// and keep only the last `HISTORY_TURN_BUDGET` turns verbatim. From a5b159e51b1dc41be66f3786f27934e361783dfd Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Wed, 13 May 2026 17:44:56 +0200 Subject: [PATCH 20/23] docs: update .pengine file with project context and usage instructions - Expanded the .pengine file to include detailed project context, emphasizing the importance of using specific file paths and avoiding directory scans. - Added a comprehensive layout section outlining the structure of frontend and backend components. - Updated the agent module's prompt to include a new sources section for web content, enhancing clarity on how to reference fetched data. - Cleaned up the session handling code by removing unnecessary whitespace and improving formatting for better readability. --- .gitignore | 1 + .pengine | 4 ---- src-tauri/src/modules/agent/mod.rs | 17 +++++++++-------- src-tauri/src/modules/cli/handlers.rs | 1 - src-tauri/src/modules/cli/session.rs | 8 +++++++- 5 files changed, 17 insertions(+), 14 deletions(-) delete mode 100644 .pengine diff --git a/.gitignore b/.gitignore index 920409d..88023d8 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,6 @@ playwright-report/ test-results/ .env +.pengine # Apple signing certificate-base64.txt \ No newline at end of file diff --git a/.pengine b/.pengine deleted file mode 100644 index aa49a0b..0000000 --- a/.pengine +++ /dev/null @@ -1,4 +0,0 @@ -# .pengine -session_id: auto-generated -project_dir: /app/pengine -last_action: created by user request \ No newline at end of file diff --git a/src-tauri/src/modules/agent/mod.rs b/src-tauri/src/modules/agent/mod.rs index 1255a74..4595f6c 100644 --- a/src-tauri/src/modules/agent/mod.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -54,13 +54,14 @@ const SUMMARY_SYSTEM_PROMPT: &str = "You synthesize tool results for the user. R 1) Use ONLY the text in the user message's Data section (tool outputs). Do not add facts, legal claims, or country-specific rules that are not clearly supported there.\n\ 2) If the Data is insufficient, say so briefly and list what is missing — do not invent answers.\n\ 3) Language: match the user's question where possible.\n\ -4) After the substantive answer, add a final section **Quellen** with a bullet list of every relevant full URL you relied on:\n\ - - Include URLs from `brave_web_search` results and from every `fetch` block (including lines like `--- fetch (auto: https://…) ---`).\n\ - - Copy URLs exactly as they appear in the Data (fetch bodies, HTML links, or Location lines).\n\ - - If the Data shows only page text without URLs, write one bullet per tool block naming the fetch target if it appears in the `--- fetch ---` headers or quoted links in the excerpt.\n\ - - Never omit **Quellen** when the Data came from web search or fetches.\n\ -5) Keep the body concise but do not drop **Quellen** to save space.\n\ -6) No chain-of-thought, planning, or English meta: write only text that should appear in the user's chat bubble."; +4) **Sources section — only when web content was fetched or searched:**\n\ + If the Data contains output from `fetch` calls or `brave_web_search` results, end with a sources block. Use the word for \"References\" translated into the same language as the user's message (e.g. \"References\" (English), \"Quellen\" (German), \"Sources\" (French), \"Fuentes\" (Spanish), \"Fonti\" (Italian), \"Referências\" (Portuguese), \"Bronnen\" (Dutch), \"Källor\" (Swedish), \"Kilder\" (Danish/Norwegian), \"Lähteet\" (Finnish), \"Источники\" (Russian), \"来源\" (Chinese), \"参考文献\" (Japanese), \"출처\" (Korean), \"Kaynak\" (Turkish), \"Zdroje\" (Czech/Slovak), \"Źródła\" (Polish), \"Surse\" (Romanian), \"Πηγές\" (Greek), \"מקורות\" (Hebrew), \"المصادر\" (Arabic)). Format it exactly like this:\n---\n\ + ****\n\ + 1. https://example.com/page-one\n\ + 2. https://example.com/page-two\n\ + Copy URLs exactly as they appear in the Data (fetch headers like `--- fetch (auto: https://…) ---`, HTML links, or Location lines).\n\ + **If the Data contains no web fetches or searches (e.g. only filesystem reads, git output, local tool results), omit this block entirely.** Do not invent or guess URLs.\n\ +5) No chain-of-thought, planning, or English meta: write only text that should appear in the user's chat bubble."; const REPO_WRITE_CONTINUE_AFTER_PROSE: &str = "CONTINUE (repo files): This turn is **not** finished. Either you have not called **`edit_file`** / **`write_file`** / **`create_directory`** yet, or the **latest** tool output still shows a compile/clippy/lint/pre-commit failure. \ Your next step must be **tool calls** on the repo (absolute **`/app/...`** paths): create missing files/folders the user asked for, or patch what the log cites — then verify if appropriate. \ @@ -1239,7 +1240,7 @@ async fn build_system_prompt( After tool results, answer immediately. Be concise. \ `brave_web_search` is only in the tool list when the user asked to search the open web (e.g. \"search the internet\", \"suche im Internet\", \"suche nach ...\") or a skill's `requires` matches this turn — otherwise prefer **`fetch`** on any `http(s)` URL you have (including from the user). \ At most one `brave_web_search` per user message when it is available. \ - After an allowed search, the host may auto-`fetch` several top result URLs — use those excerpts and end with **Quellen** listing every source URL. \ + After an allowed search, the host may auto-`fetch` several top result URLs — use those excerpts and end with a sources block (after `---`) using the word for \"References\" in the user's language (e.g. \"Quellen\" in German, \"References\" in English, \"Sources\" in French) with a numbered URL list. Only add this block when the reply actually used web fetches or search results — never for code reviews, filesystem reads, or answers from training knowledge. \ **Skill discipline:** each skill in the list below defines fetch URLs for a specific domain. Only call those URLs when the user message is actually about that skill's topic.{fs_hint}{code_edit_hint}{scaffold_hint}{mem_hint}{weather_directive}{skills_hint}" ) } diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index 2d94f2b..1f11361 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -787,7 +787,6 @@ async fn session_rename(state: &AppState, name: &str) -> CliReply { CliReply::text(format!("session renamed to: {name}")) } - /// `/compact` — call the AI to summarize old turns, store as `session.summary`, /// and keep only the last `HISTORY_TURN_BUDGET` turns verbatim. pub async fn compact_session(state: &AppState) -> CliReply { diff --git a/src-tauri/src/modules/cli/session.rs b/src-tauri/src/modules/cli/session.rs index 7144ed3..118b1a0 100644 --- a/src-tauri/src/modules/cli/session.rs +++ b/src-tauri/src/modules/cli/session.rs @@ -529,7 +529,13 @@ pub fn read_dot_pengine_context(ctx: &ProjectContext) -> Option { /// project context first. pub fn dot_pengine_prompt_block(ctx: &ProjectContext) -> String { read_dot_pengine_context(ctx).map_or_else(String::new, |body| { - format!("## Project context (.pengine)\n{body}\n\n") + format!( + "## Project context (.pengine)\n\ + {body}\n\n\ + > **Do NOT call `directory_tree` on the repo root** — output is truncated and will \ + incorrectly appear to show missing files. Use `read_file` directly on the paths \ + listed above, or call `list_directory` / `search_files` on a narrow sub-path.\n\n" + ) }) } From 6c9c4386333c7478bd6b46111efcdc8642ad0bb7 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Wed, 13 May 2026 18:20:43 +0200 Subject: [PATCH 21/23] refactor: update agent prompt and CLI session handling for improved clarity and functionality - Revised the agent's system prompt to clarify instructions for code changes, emphasizing concise summaries instead of markdown diffs. - Enhanced the `ask_in_session` function to automatically attach git diffs when changes are made, improving user feedback. - Introduced a new function to list project files while skipping common build directories, optimizing project context handling. - Updated the `dot_pengine_prompt_block` function to include an optional MCP prefix for better file path management in project contexts. --- src-tauri/src/modules/agent/mod.rs | 12 +-- src-tauri/src/modules/cli/handlers.rs | 87 +++++++++++++++++++--- src-tauri/src/modules/cli/session.rs | 103 +++++++++++++++++++++++--- 3 files changed, 175 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/modules/agent/mod.rs b/src-tauri/src/modules/agent/mod.rs index 4595f6c..6f7e925 100644 --- a/src-tauri/src/modules/agent/mod.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -1154,14 +1154,14 @@ async fn build_system_prompt( let base = if has_edit && has_diff { "\nCode changes: when the user asks for a review with changes, a fix, a refactor, or any \ modification to repo files, **apply the change yourself** with **`edit_file`** / **`write_file`** \ - — do not write a markdown summary describing what to change. After editing, call **`git_diff`** \ - (unstaged) and embed the diff inside `` as a fenced ```diff block, followed by a \ - brief one-line rationale per file. If the user only asked for a review without changes, answer \ - with prose only and do not edit." + — do not write a markdown summary describing what to change. After editing, reply with **1-2 \ + sentences** summarising what you changed and why. Do **NOT** call `git_diff` or embed a diff \ + block — the system renders the diff automatically after your reply. \ + If the user only asked for a review without changes, answer with prose only and do not edit." } else if has_edit { "\nCode changes: when the user asks for changes/fixes/refactors, apply them yourself with \ - **`edit_file`** / **`write_file`** instead of describing them. End with a short bullet list of the \ - files you changed." + **`edit_file`** / **`write_file`** instead of describing them. Reply with 1-2 sentences \ + summarising what changed." } else { "" }; diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index 1f11361..beb31b6 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -12,6 +12,7 @@ use crate::modules::mcp::service as mcp_service; use crate::modules::ollama::service::{self as ollama, ModelInfo}; use crate::modules::secure_store; use crate::modules::skills::service as skills_service; +use crate::modules::tool_engine::service::workspace_app_bind_pairs; use crate::shared::state::{AppState, ConnectionData, ConnectionMetadata, LogEntry}; use crate::shared::user_settings; use chrono::Utc; @@ -944,7 +945,25 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) (String::new(), session::detect_project_context(&cwd)) }; - let dot_prefix = session::dot_pengine_prompt_block(&project_for_dot); + let mcp_prefix_for_dot: Option = { + let fs_paths = state.cached_filesystem_paths.read().await.clone(); + let pairs = workspace_app_bind_pairs(&fs_paths); + let root = project_for_dot + .git_root + .as_deref() + .unwrap_or(&project_for_dot.cwd); + pairs.into_iter().find_map(|(host, container)| { + let hp = std::path::Path::new(host.trim()); + let hcanon = std::fs::canonicalize(hp).unwrap_or_else(|_| hp.to_path_buf()); + if root.starts_with(&hcanon) { + Some(container) + } else { + None + } + }) + }; + let dot_prefix = + session::dot_pengine_prompt_block(&project_for_dot, mcp_prefix_for_dot.as_deref()); let prompt_for_agent = { let mut head = String::new(); @@ -1009,6 +1028,45 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) body.push_str(&expanded.errors.join("; ")); body.push('_'); } + // Auto-attach a git diff when the model changed files but didn't embed one. + if !body.contains("```diff") { + if let Some(git_root) = project_for_dot.git_root.as_deref() { + let stat = std::process::Command::new("git") + .args(["diff", "--stat"]) + .current_dir(git_root) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim_end().to_string()) + .filter(|s| !s.is_empty()); + let full = std::process::Command::new("git") + .args(["diff"]) + .current_dir(git_root) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .filter(|s| !s.trim().is_empty()); + if let Some(diff_text) = full { + const DIFF_CAP: usize = 20_000; + let diff_capped = if diff_text.len() > DIFF_CAP { + format!( + "{}\n…(truncated at {} chars)", + &diff_text[..DIFF_CAP], + DIFF_CAP + ) + } else { + diff_text + }; + if let Some(s) = stat { + body.push_str("\n\n```\n"); + body.push_str(&s); + body.push_str("\n```"); + } + body.push_str("\n\n```diff\n"); + body.push_str(&diff_capped); + body.push_str("\n```"); + } + } + } CliReply::text(body) } Err(e) => { @@ -1283,21 +1341,32 @@ fn emit_turn_footer(elapsed: std::time::Duration, turn: &agent::TurnResult) { /// Format a token count with thousands separators: 1234 → "1,234". fn fmt_num(n: u64) -> String { - let s = n.to_string(); - let mut out = String::with_capacity(s.len() + s.len() / 3); - for (i, ch) in s.chars().rev().enumerate() { - if i > 0 && i % 3 == 0 { - out.push(','); - } - out.push(ch); + if n < 1_000 { + return n.to_string(); + } + if n < 10_000 { + return format!("{:.1}k", n as f64 / 1_000.0); } - out.chars().rev().collect() + format!("{}k", n / 1_000) } #[cfg(test)] mod tests { use super::*; + #[test] + fn fmt_num_uses_k_suffix() { + assert_eq!(fmt_num(0), "0"); + assert_eq!(fmt_num(512), "512"); + assert_eq!(fmt_num(999), "999"); + assert_eq!(fmt_num(1_000), "1.0k"); + assert_eq!(fmt_num(1_711), "1.7k"); + assert_eq!(fmt_num(9_012), "9.0k"); + assert_eq!(fmt_num(10_000), "10k"); + assert_eq!(fmt_num(13_362), "13k"); + assert_eq!(fmt_num(100_000), "100k"); + } + #[test] fn inline_tool_block_rewrites_step_call() { let out = inline_tool_block("[0] fetch").unwrap(); diff --git a/src-tauri/src/modules/cli/session.rs b/src-tauri/src/modules/cli/session.rs index 118b1a0..764b12c 100644 --- a/src-tauri/src/modules/cli/session.rs +++ b/src-tauri/src/modules/cli/session.rs @@ -525,18 +525,97 @@ pub fn read_dot_pengine_context(ctx: &ProjectContext) -> Option { None } +/// Walk `root` and return a sorted list of source files, mapped to `mcp_prefix + rel_path`. +/// Skips common build / vendor dirs. Capped at `max_files` entries. +pub fn project_file_listing(root: &Path, mcp_prefix: &str, max_files: usize) -> Vec { + const SKIP_DIRS: &[&str] = &[ + "node_modules", + "target", + ".git", + "dist", + "build", + ".next", + "__pycache__", + ".cache", + "coverage", + ".turbo", + "Pods", + ".venv", + "venv", + ".pengine", + ]; + let mut out: Vec = Vec::new(); + let mut stack: Vec = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + let mut children: Vec = entries + .filter_map(|e| e.ok().map(|e| e.path())) + .collect(); + children.sort(); + for path in children { + if path.is_dir() { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !SKIP_DIRS.contains(&name) { + stack.push(path); + } + } else if path.is_file() { + if let Ok(rel) = path.strip_prefix(root) { + if out.len() >= max_files { + out.push(format!("… ({} files shown, more exist)", max_files)); + return out; + } + out.push(format!("{mcp_prefix}/{}", rel.display())); + } + } + } + } + out.sort(); + out +} + /// Markdown block prepended **before** session history so the agent sees stable -/// project context first. -pub fn dot_pengine_prompt_block(ctx: &ProjectContext) -> String { - read_dot_pengine_context(ctx).map_or_else(String::new, |body| { - format!( - "## Project context (.pengine)\n\ - {body}\n\n\ - > **Do NOT call `directory_tree` on the repo root** — output is truncated and will \ - incorrectly appear to show missing files. Use `read_file` directly on the paths \ - listed above, or call `list_directory` / `search_files` on a narrow sub-path.\n\n" - ) - }) +/// project context first. `mcp_prefix` is the `/app/` container path for the +/// project root (pass `None` when unknown — the file listing will be omitted). +pub fn dot_pengine_prompt_block(ctx: &ProjectContext, mcp_prefix: Option<&str>) -> String { + let root = ctx.git_root.as_deref().unwrap_or(&ctx.cwd); + + // User-written notes from the .pengine file (optional). + let notes = read_dot_pengine_context(ctx); + + // Auto-generated file listing (only when we know the MCP prefix). + let file_listing = mcp_prefix.map(|prefix| { + let files = project_file_listing(root, prefix, 300); + if files.is_empty() { + String::new() + } else { + format!( + "## Project files ({prefix})\n\ + > Use `read_file` on these paths directly. \ + Do **NOT** call `directory_tree` on the repo root — \ + output is truncated on large repos and will incorrectly appear to show missing files.\n\n\ + {}\n\n", + files.join("\n") + ) + } + }); + + match (notes, file_listing) { + (None, None) => String::new(), + (notes, listing) => { + let mut block = String::from("## Project context (.pengine)\n\n"); + if let Some(n) = notes { + block.push_str(&n); + block.push_str("\n\n"); + } + if let Some(l) = listing.filter(|s| !s.is_empty()) { + block.push_str(&l); + } + block + } + } } fn detect_git(start: &Path) -> (Option, Option) { @@ -734,7 +813,7 @@ mod tests { git_root: None, git_branch: None, }; - let b = dot_pengine_prompt_block(&ctx); + let b = dot_pengine_prompt_block(&ctx, None); assert!(b.starts_with("## Project context (.pengine)")); assert!(b.contains("x")); } From 76549cd59d4e7d767be04f46830a9d83fd9f255d Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Wed, 13 May 2026 18:57:07 +0200 Subject: [PATCH 22/23] refactor: improve agent prompt clarity and enhance git diff handling in CLI - Updated the agent's system prompt to specify that only web URLs should be listed, clarifying instructions for data referencing. - Enhanced the `ask_in_session` function to capture and display only the new changes introduced during a session, improving user feedback. - Introduced utility functions to manage and display git diffs more effectively, ensuring only relevant changes are shown to users. - Cleaned up code formatting in the CLI session file for better readability and maintainability. --- src-tauri/src/modules/agent/mod.rs | 5 +- src-tauri/src/modules/cli/handlers.rs | 174 +++++++++++++++++++++----- src-tauri/src/modules/cli/session.rs | 4 +- 3 files changed, 149 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/modules/agent/mod.rs b/src-tauri/src/modules/agent/mod.rs index 6f7e925..4683903 100644 --- a/src-tauri/src/modules/agent/mod.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -59,8 +59,9 @@ const SUMMARY_SYSTEM_PROMPT: &str = "You synthesize tool results for the user. R ****\n\ 1. https://example.com/page-one\n\ 2. https://example.com/page-two\n\ - Copy URLs exactly as they appear in the Data (fetch headers like `--- fetch (auto: https://…) ---`, HTML links, or Location lines).\n\ - **If the Data contains no web fetches or searches (e.g. only filesystem reads, git output, local tool results), omit this block entirely.** Do not invent or guess URLs.\n\ + Only list **https:// or http:// URLs** that appear in the Data (fetch headers like `--- fetch (auto: https://…) ---`, HTML links, or Location lines). \ + **Never** list local file paths (`/app/…`, `/Users/…`, relative paths) as sources — those are filesystem reads, not web references.\n\ + **If the Data contains no web fetches or searches (e.g. only filesystem reads, git output, code edits, local tool results), omit this block entirely.** Do not invent or guess URLs.\n\ 5) No chain-of-thought, planning, or English meta: write only text that should appear in the user's chat bubble."; const REPO_WRITE_CONTINUE_AFTER_PROSE: &str = "CONTINUE (repo files): This turn is **not** finished. Either you have not called **`edit_file`** / **`write_file`** / **`create_directory`** yet, or the **latest** tool output still shows a compile/clippy/lint/pre-commit failure. \ diff --git a/src-tauri/src/modules/cli/handlers.rs b/src-tauri/src/modules/cli/handlers.rs index beb31b6..2e3ddee 100644 --- a/src-tauri/src/modules/cli/handlers.rs +++ b/src-tauri/src/modules/cli/handlers.rs @@ -980,6 +980,20 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) } }; + // Snapshot unstaged diff before the turn so we can show only the model's new changes. + let diff_before: String = project_for_dot + .git_root + .as_deref() + .and_then(|r| { + std::process::Command::new("git") + .args(["diff"]) + .current_dir(r) + .output() + .ok() + }) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default(); + let progress = Progress::start(flavor::thinking_label().to_string()); let token_counter = progress.token_counter(); let forwarder = spawn_status_forwarder(state, progress.status_sender()).await; @@ -995,7 +1009,11 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) CliReply::text("(no reply)") } Ok(turn) => { - if turn.text.trim().is_empty() { + // When the model ran tool calls but produced no final text (e.g. the + // block was empty or missing), fall through with a + // placeholder so the auto-diff block still gets appended. Only bail + // early when there were genuinely no tool calls either (steps ≤ 1). + if turn.text.trim().is_empty() && turn.steps <= 1 { emit_baked_line(elapsed); return CliReply::text("(no reply)"); } @@ -1022,48 +1040,48 @@ pub async fn ask_in_session(state: &AppState, text: &str, persist_session: bool) spawn_compaction_if_needed(state).await; } emit_turn_footer(elapsed, &turn); - let mut body = turn.text; + let mut body = if turn.text.trim().is_empty() { + String::from("Done.") + } else { + turn.text + }; if !expanded.errors.is_empty() { body.push_str("\n\n_Note: "); body.push_str(&expanded.errors.join("; ")); body.push('_'); } - // Auto-attach a git diff when the model changed files but didn't embed one. + // Auto-attach only the diff the model introduced this turn. if !body.contains("```diff") { if let Some(git_root) = project_for_dot.git_root.as_deref() { - let stat = std::process::Command::new("git") - .args(["diff", "--stat"]) - .current_dir(git_root) - .output() - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim_end().to_string()) - .filter(|s| !s.is_empty()); - let full = std::process::Command::new("git") + let diff_after: String = std::process::Command::new("git") .args(["diff"]) .current_dir(git_root) .output() .ok() .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) - .filter(|s| !s.trim().is_empty()); - if let Some(diff_text) = full { - const DIFF_CAP: usize = 20_000; - let diff_capped = if diff_text.len() > DIFF_CAP { - format!( - "{}\n…(truncated at {} chars)", - &diff_text[..DIFF_CAP], - DIFF_CAP - ) - } else { - diff_text - }; - if let Some(s) = stat { - body.push_str("\n\n```\n"); - body.push_str(&s); + .unwrap_or_default(); + + let delta = diff_new_sections(&diff_before, &diff_after); + if !delta.trim().is_empty() { + const FILE_DIFF_CAP: usize = 8_000; + for section in diff_file_sections(&delta) { + let (label, content) = section; + body.push_str("\n\n`"); + body.push_str(&label); + body.push('`'); + let capped = if content.len() > FILE_DIFF_CAP { + format!( + "{}\n…(truncated at {} chars)", + &content[..FILE_DIFF_CAP], + FILE_DIFF_CAP + ) + } else { + content + }; + body.push_str("\n```diff\n"); + body.push_str(&capped); body.push_str("\n```"); } - body.push_str("\n\n```diff\n"); - body.push_str(&diff_capped); - body.push_str("\n```"); } } } @@ -1350,6 +1368,80 @@ fn fmt_num(n: u64) -> String { format!("{}k", n / 1_000) } +/// Split a unified diff into `(display_label, diff_content)` per file. +/// Label is the `b/` portion from the `diff --git` header, stripped to a relative path. +fn diff_file_sections(diff: &str) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + let mut current_label = String::new(); + let mut current_buf = String::new(); + + for line in diff.lines() { + if let Some(rest) = line.strip_prefix("diff --git ") { + if !current_label.is_empty() { + out.push((current_label.clone(), std::mem::take(&mut current_buf))); + } + // "a/ b/" → take b/ side as the display label + current_label = rest + .split_once(" b/") + .map(|(_, b)| b.to_string()) + .unwrap_or_else(|| rest.to_string()); + current_buf.clear(); + } else { + current_buf.push_str(line); + current_buf.push('\n'); + } + } + if !current_label.is_empty() { + out.push((current_label, current_buf)); + } + out +} + +/// Return the sections of `after` that are absent from `before` (new or modified files). +/// Each section starts with a `diff --git …` line. +fn diff_new_sections(before: &str, after: &str) -> String { + if after == before { + return String::new(); + } + fn split_sections(s: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut start = 0; + let bytes = s.as_bytes(); + let marker = b"\ndiff --git "; + let mut i = 0; + while i + marker.len() <= bytes.len() { + if bytes[i..].starts_with(marker) { + if i > start { + out.push(&s[start..i]); + } + start = i + 1; // skip the leading newline + i += marker.len(); + } else { + i += 1; + } + } + if start < s.len() { + out.push(&s[start..]); + } + out + } + + let before_sections: std::collections::HashSet<&str> = split_sections(before) + .into_iter() + .map(str::trim_end) + .collect(); + let mut out = String::new(); + for section in split_sections(after) { + if !before_sections.contains(section.trim_end()) { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(section); + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -1382,6 +1474,30 @@ mod tests { ); } + #[test] + fn diff_new_sections_returns_only_added_file() { + let before = + "diff --git a/old.rs b/old.rs\n--- a/old.rs\n+++ b/old.rs\n@@ -1 +1 @@\n-old\n+new\n"; + let after = format!( + "{before}diff --git a/new.rs b/new.rs\n--- a/new.rs\n+++ b/new.rs\n@@ -0,0 +1 @@\n+added\n" + ); + let delta = diff_new_sections(before, &after); + assert!( + delta.contains("new.rs"), + "expected new.rs in delta: {delta}" + ); + assert!( + !delta.contains("old.rs"), + "old.rs should not appear: {delta}" + ); + } + + #[test] + fn diff_new_sections_empty_when_unchanged() { + let diff = "diff --git a/x.rs b/x.rs\n+something\n"; + assert!(diff_new_sections(diff, diff).is_empty()); + } + #[test] fn inline_tool_block_passes_result_line() { let out = inline_tool_block("fetch: 4012 bytes").unwrap(); diff --git a/src-tauri/src/modules/cli/session.rs b/src-tauri/src/modules/cli/session.rs index 764b12c..475a9d5 100644 --- a/src-tauri/src/modules/cli/session.rs +++ b/src-tauri/src/modules/cli/session.rs @@ -551,9 +551,7 @@ pub fn project_file_listing(root: &Path, mcp_prefix: &str, max_files: usize) -> Ok(e) => e, Err(_) => continue, }; - let mut children: Vec = entries - .filter_map(|e| e.ok().map(|e| e.path())) - .collect(); + let mut children: Vec = entries.filter_map(|e| e.ok().map(|e| e.path())).collect(); children.sort(); for path in children { if path.is_dir() { From 77d6dd543ec2c0812b4b05f4ec0334ded229b2b8 Mon Sep 17 00:00:00 2001 From: MaximEdogawa Date: Wed, 13 May 2026 19:43:12 +0200 Subject: [PATCH 23/23] refactor: enhance CLI output formatting and improve project file listing - Updated the spinner output formatting to include color coding for better visibility and user experience. - Improved the project file listing functionality to group files by top-level directory, enhancing organization and clarity. - Cleaned up the code in the session module for better readability and maintainability. --- src-tauri/src/modules/agent/mod.rs | 27 +++++++--- src-tauri/src/modules/cli/flavor.rs | 81 ++++++++++++++++++---------- src-tauri/src/modules/cli/output.rs | 12 ++++- src-tauri/src/modules/cli/session.rs | 45 ++++++++++++---- 4 files changed, 119 insertions(+), 46 deletions(-) diff --git a/src-tauri/src/modules/agent/mod.rs b/src-tauri/src/modules/agent/mod.rs index 4683903..1a0a3e2 100644 --- a/src-tauri/src/modules/agent/mod.rs +++ b/src-tauri/src/modules/agent/mod.rs @@ -543,6 +543,13 @@ fn message_implies_apply_repo_fix(msg: &str) -> bool { "make the change", "update the file", "patch the file", + "code review", + "code clean", + "clean up", + "cleanup", + "refactor", + "improve the code", + "change some code", ]; HINTS .iter() @@ -1155,16 +1162,24 @@ async fn build_system_prompt( let base = if has_edit && has_diff { "\nCode changes: when the user asks for a review with changes, a fix, a refactor, or any \ modification to repo files, **apply the change yourself** with **`edit_file`** / **`write_file`** \ - — do not write a markdown summary describing what to change. After editing, reply with **1-2 \ - sentences** summarising what you changed and why. Do **NOT** call `git_diff` or embed a diff \ - block — the system renders the diff automatically after your reply. \ + — do not write a markdown summary describing what to change. \ + **IMPORTANT:** Do **NOT** write `--- a/…` / `+++ b/…` diff text inline in your reply — \ + that has no effect. Use `edit_file` / `write_file` to actually modify files; \ + the system renders the real diff automatically. \ + After editing, reply with **1-2 sentences** summarising what you changed and why. \ If the user only asked for a review without changes, answer with prose only and do not edit." } else if has_edit { "\nCode changes: when the user asks for changes/fixes/refactors, apply them yourself with \ - **`edit_file`** / **`write_file`** instead of describing them. Reply with 1-2 sentences \ - summarising what changed." + **`edit_file`** / **`write_file`** instead of describing them. \ + Do **NOT** write inline `--- a/…` diff text — use the tools. \ + Reply with 1-2 sentences summarising what changed." } else { - "" + // No file-edit tools available — forbid fake diffs so the model gives + // a useful prose review instead of inventing changes it cannot apply. + "\nCode review: you do **not** have file-editing tools in this session. \ + Provide a prose-only review: list findings, suggest improvements, explain reasoning. \ + **Do NOT write inline diffs, `--- a/…` hunks, or pretend to apply changes** — \ + you have no way to actually modify files right now." }; let apply_fix = if message_implies_apply_repo_fix(user_message) && has_edit { "\n**Apply-fix order (pre-commit / lint / format):** (1) read or search if needed; (2) **you must \ diff --git a/src-tauri/src/modules/cli/flavor.rs b/src-tauri/src/modules/cli/flavor.rs index 160bf21..96d1367 100644 --- a/src-tauri/src/modules/cli/flavor.rs +++ b/src-tauri/src/modules/cli/flavor.rs @@ -24,36 +24,61 @@ pub fn fun_pair(a: &'static str, b: &'static str) -> String { /// Format in the spinner: `⠹ Thonking · 4s` pub fn thinking_label() -> &'static str { pick_str(&[ - "Thonking", // thinking + honk - "Grokking", // hacker lore: deep understanding - "Blorping", // invented - "Wibbling", // wobbly invented word - "Frobnifying", // frobnicate = fiddle (hacker lore) - "Zorbulating", // invented + // ── classic hacker lore ── + "Grokking", // deep understanding (Heinlein) + "Frobnifying", // frobnicate = to fiddle with knobs + "Quuxing", // quux: the fourth hacker placeholder + "Thonking", // thinking + honk + // ── sounds like real work ── + "Cogitating", // stuffy but correct + "Percolating", // ideas slowly brewing + "Woolgathering", // absent-minded musing + "Deliberating", // weighing all options solemnly + "Ruminating", // chewing on the problem + "Pontificating", // making it sound important + "Extrapolating", // going beyond the data boldly + "Triangulating", // finding the answer from three bad clues + "Defragmenting", // classic Windows nostalgia + "Overclocking", // pushing silicon to its limits + "Compiling", // it's always compiling + "Initialising", // British spelling for extra gravitas + // ── invented nonsense ── + "Blorping", + "Wibbling", + "Zorbulating", "Kerfluffling", // from kerfuffle "Discombobulating", - "Quuxing", // quux: hacker placeholder (foo/bar/baz/quux) - "Schmozzling", // invented - "Noodling", // slang: loosely brainstorming - "Percolating", // ideas brewing slowly - "Boffinating", // boffin = British slang for a clever scientist - "Glonking", // invented - "Tronking", // Tron reference - "Flerbulating", // invented - "Blorbinating", // invented - "Cogitating", // real but sounds delightfully stuffy - "Woolgathering", // real expression: absent-minded musing - "Slorbing", // invented - "Murgling", // invented - "Gnurfling", // invented - "Zippulating", // invented - "Bebboning", // invented - "Snorkling", // near-snorkeling, but for data - "Wuffling", // invented - "Schmoogling", // invented - "Grumpulating", // invented - "Bewildering", // turning confusion into answers - "Simmering", // ideas on low heat + "Schmozzling", + "Glonking", + "Flerbulating", + "Blorbinating", + "Slorbing", + "Murgling", + "Gnurfling", + "Zippulating", + "Snorkling", + "Wuffling", + "Schmoogling", + "Grumpulating", + "Boffinating", // boffin = British clever scientist + "Tronking", // Tron cinematic universe + "Noodling", // loosely brainstorming + "Simmering", // on low heat + "Bewildering", // turning confusion into answers + "Splorching", + "Frumbling", + "Zibulating", + "Plonkulating", + "Greebling", // greeble: tiny surface details on a spaceship + "Yak-shaving", // solving the meta-problem of the meta-problem + "Wiggling", // sometimes you just need to wiggle it + "Bamboozling", // bamboozle reversed + "Spelunking", // exploring deep caves of context + "Untangling", // knots, conceptual and otherwise + "Manifolding", // higher-dimensional problem solving + "Vibing", // when the model just knows + "Debugging", // always, everywhere + "Hallucinating", // (briefly, before correcting itself) ]) } diff --git a/src-tauri/src/modules/cli/output.rs b/src-tauri/src/modules/cli/output.rs index a190c96..203f825 100644 --- a/src-tauri/src/modules/cli/output.rs +++ b/src-tauri/src/modules/cli/output.rs @@ -663,7 +663,17 @@ fn build_spinner_line( } }; - format!("\r\x1b[2K\x1b[2m{visible}\x1b[0m") + // Blue spinner: bold bright-blue frame + medium-blue label + dim-blue suffix. + // Split at the first " · " to colour frame+label vs. the trailing detail. + let colored = if let Some((head, tail)) = visible.split_once(" · ") { + // head = "⠹ Thonking", tail = "status · 4s" or just "4s" + format!( + "\x1b[1;38;2;79;172;255m{head}\x1b[0m \x1b[38;2;99;160;220m·\x1b[0m \x1b[2;38;2;120;170;220m{tail}\x1b[0m" + ) + } else { + format!("\x1b[1;38;2;79;172;255m{visible}\x1b[0m") + }; + format!("\r\x1b[2K{colored}") } async fn spinner_loop( diff --git a/src-tauri/src/modules/cli/session.rs b/src-tauri/src/modules/cli/session.rs index 475a9d5..ff48da0 100644 --- a/src-tauri/src/modules/cli/session.rs +++ b/src-tauri/src/modules/cli/session.rs @@ -583,21 +583,44 @@ pub fn dot_pengine_prompt_block(ctx: &ProjectContext, mcp_prefix: Option<&str>) // User-written notes from the .pengine file (optional). let notes = read_dot_pengine_context(ctx); - // Auto-generated file listing (only when we know the MCP prefix). + // Auto-generated file listing grouped by top-level directory (only when MCP prefix is known). let file_listing = mcp_prefix.map(|prefix| { let files = project_file_listing(root, prefix, 300); if files.is_empty() { - String::new() - } else { - format!( - "## Project files ({prefix})\n\ - > Use `read_file` on these paths directly. \ - Do **NOT** call `directory_tree` on the repo root — \ - output is truncated on large repos and will incorrectly appear to show missing files.\n\n\ - {}\n\n", - files.join("\n") - ) + return String::new(); + } + // Group by the first path component after the prefix (e.g. "src", "src-tauri", …). + let mut groups: Vec<(String, Vec)> = Vec::new(); + for path in &files { + // path = "/app/pengine/src-tauri/src/…" — strip prefix to get relative + let rel = path.strip_prefix(prefix).unwrap_or(path.as_str()); + let top = rel + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or(".") + .to_string(); + if let Some(g) = groups.iter_mut().find(|(k, _)| k == &top) { + g.1.push(path.clone()); + } else { + groups.push((top, vec![path.clone()])); + } + } + let mut out = format!( + "## Project files ({prefix})\n\ + > Use `read_file` on these exact paths. \ + Do **NOT** call `directory_tree` on the repo root — \ + it is truncated on large repos and will incorrectly appear to show missing files.\n\n" + ); + for (dir, paths) in &groups { + out.push_str(&format!("### {dir}/\n")); + for p in paths { + out.push_str(p); + out.push('\n'); + } + out.push('\n'); } + out }); match (notes, file_listing) {